diff --git a/.gitignore b/.gitignore index ed5ffe6..8d44fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ dist/ data/*.db data/backups/*.db .codex/*.log -dynamo/output/ diff --git a/Dockerfile b/Dockerfile index 31d28c4..41a08fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,10 @@ -FROM node:24 +FROM node:22 WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . - -# next build writes the rewrite destinations from next.config.mjs into -# .next/routes-manifest.json, so "next start" cannot pick up a different -# API URL later. The value has to be known here, not just at runtime. -ARG API_INTERNAL_URL=http://localhost:3000 -ENV API_INTERNAL_URL=$API_INTERNAL_URL - RUN npm run build:api && npm run build:web RUN mkdir -p data && chmod +x scripts/docker-start.sh diff --git a/README.md b/README.md index fc82fca..c333917 100644 --- a/README.md +++ b/README.md @@ -62,29 +62,15 @@ docker compose logs --follow docker compose down ``` -`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, -ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in +Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein +Produktionsdeployment. Details stehen in [Deployment und Betrieb](docs/deployment.md). -Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit -Quellcode-Mounts und Watchern: - -```powershell -docker compose -f compose.dev.yaml up --build --detach -docker compose -f compose.dev.yaml logs --follow -docker compose -f compose.dev.yaml down -``` - -Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und -macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn -niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen -Server. - ## Direkte lokale Entwicklung Voraussetzungen: -- Node.js 24 +- Node.js 22 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml deleted file mode 100644 index 86e42e2..0000000 --- a/compose.dev.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# Development stack: source mounts, watching dev servers, hot reload. -# docker compose -f compose.dev.yaml up --build -# -# The polling watchers below are needed for bind mounts on Windows and -# macOS, where inotify events do not cross the VM boundary. They cost -# continuous CPU, which is why the production stack in compose.yaml does -# not run watchers at all. -name: leistungsbilanz-dev - -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" - -services: - api: - build: - context: . - command: - - sh - - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api - environment: - PORT: "3000" - CHOKIDAR_USEPOLLING: "true" - LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging - ports: - - "3000:3000" - volumes: - - ./src:/app/src - - ./scripts:/app/scripts - - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s - timeout: 3s - retries: 5 - start_period: 20s - - web: - build: - context: . - command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 - environment: - API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" - NEXT_TELEMETRY_DISABLED: "1" - LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging - depends_on: - api: - condition: service_healthy - ports: - - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s - timeout: 3s - retries: 5 - start_period: 20s diff --git a/compose.yaml b/compose.yaml index 503a4b2..071f0bb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,73 +1,88 @@ name: leistungsbilanz -x-build: &build - context: . - args: - # Baked into .next/routes-manifest.json by next build; see Dockerfile. - API_INTERNAL_URL: http://api:3000 - -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" - services: api: - build: *build + build: + context: . command: - sh - -c - - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api environment: - NODE_ENV: production PORT: "3000" + CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: *logging + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" ports: - - "3000:3000" + - "3221:3000" volumes: + - ./src:/app/src + - ./scripts:/app/scripts - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s web: - build: *build + build: + context: . command: - - node_modules/.bin/next - - start - - -p - - "3001" + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 environment: - NODE_ENV: production API_INTERNAL_URL: http://api:3000 + WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: *logging + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" depends_on: api: condition: service_healthy ports: - - "3001:3001" + - "3220:3001" + volumes: + - ./src:/app/src + - ./next.config.mjs:/app/next.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.next.json:/app/tsconfig.next.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s + +networks: + default: + ipam: + config: + - subnet: 172.16.61.0/24 diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md new file mode 100644 index 0000000..80b7cc8 --- /dev/null +++ b/docs/cable-sizing-module.md @@ -0,0 +1,243 @@ +# Cable Sizing Module (Proposal) + +**Status: proposal, not yet reviewed or merged by the project owner.** This +document and the accompanying `src/cable-sizing/`, `src/db/schema/cable-sizing-calculations.ts`, +`src/server/{controllers,routes}/cable-sizing.*` and `src/frontend/components/cable-sizing-*` +files were written by a third party (see git history/authorship) against the +project's own stated direction in `docs/spec/06-future-sizing-and-calculations.md` +("The app should later support rule-based protection and cable sizing") and +`AGENTS.md` ("... and later electrical sizing logic"). Nothing here has been +pushed to the project's own repository; it exists as a local branch for +review, testing and discussion. + +## Why a separate module instead of extending core domain code + +`AGENTS.md` is explicit that critical multi-write commands, the revision/undo +system and the Circuit-First domain model are the supported architecture, and +that changes should be small and reviewable. A cable-sizing calculation is +**not** a project mutation - it doesn't need `expectedRevision`, doesn't +belong in the undo/redo stack, and shouldn't grow the `circuit.update` +command's `switch` statement or the DTOs it doesn't already have. + +So this module is built exactly like the existing `src/external-model/` +adapter (the Revit/CSV import foundation): a one-way dependency boundary. + +``` +src/cable-sizing/domain/ <- pure functions and types, zero imports from + db/, server/ or frontend/ +src/db/schema/ <- one new, fully additive table +src/db/repositories/ <- one plain repository (no revision semantics) +src/server/{controllers,routes}/cable-sizing.* <- one new, isolated route + group, mounted with a single app.use() line +src/frontend/components/cable-sizing-* <- one new modal + one small + API client, following the existing + FormModal / CircuitProtectionModal pattern +``` + +Nothing outside these files imports from them except the two required +one-line hooks described below. If this module is rejected or needs to move +out again, removing it is a matter of deleting these files, the two +one-line hooks, and running a `DROP TABLE` migration - it never touches +`circuits`, `project_revisions` or any command/history table. + +## The two required hooks into existing code + +1. `src/server/index.ts`: one import + one `app.use("/api/cable-sizing", cableSizingRouter)` + line, next to the existing `app.use("/api/projects", ...)` etc. +2. `src/frontend/components/circuit-tree-editor.tsx`: one new `useState` for + the open modal, one new trigger condition (mirroring the existing + `isProtectionTrigger` / `protectionEditorCircuit` pattern almost exactly), + and one new conditionally-rendered `` alongside the + existing ``. See the diff for the exact lines. + +No changes to `circuit-project-command.model.ts`, `project-command.service.ts`, +any Zod command schema, any migration for `circuits`/`project_revisions`, or +any existing test. + +## Coupling to the grid UI (the one fragile point) + +Everything described above - the calculation, the audit table, applying a +result via `circuit.update` - depends only on stable, explicitly documented +domain fields (`cableType`/`cableCrossSection`/`cableLength`/ +`circuitTotalPower`/`voltage`/`protectionDevice.ratedCurrentA`), the same +ones `AGENTS.md` already treats as protected ("Protection and cable data +belong to the circuit"). None of that breaks if the grid UI changes. + +The one place that *is* coupled to a UI implementation detail: the click +trigger in `circuit-tree-editor.tsx` matches grid **column keys** +(`cableSummary` and `cableCrossSection`, defined in +`circuit-grid-model.ts`) to decide where to show the calculator icon. +Column visibility and order in this app are a per-browser user preference, +not fixed - so this is checked against both known cable-related columns to +reduce (not eliminate) the chance a user's personal column layout hides the +trigger entirely. If a future column rename removes both keys, the failure +mode is purely cosmetic: the calculator icon/click stops appearing on that +cell, nothing crashes, no data is affected, and the rest of the app +(including manual cable-field editing) is completely unaffected - the +module's actual logic and persistence never depend on this column key. + +## Feature overview + +- **Calculate a recommendation**: laying method, insulation, conductor + material, ambient temperature, grouping, cos phi and max voltage drop - + same VDE 0298-4 reference-method model as the sibling Kabelliste tool + (see Verification status below). +- **Breaker-aware sizing**: if the circuit already has a protection device + (`circuit.protectionDevice`), the cross-section is selected against + `designCurrentA = max(operatingCurrentA, protectionDevice.ratedCurrentA)`, + not the load current alone - the standard `In <= Iz` rule (a breaker only + trips at its own rated current, so a cable sized for the actual load + alone could overheat under sustained current below that threshold). If no + cross-section can cover an oversized breaker, that is reported as the + limiting factor by name, not a generic capacity error. +- **Maximum length for the voltage-drop limit**: each cross-section option + also reports the longest single run that still meets the requested + `maxVoltageDropPercent` at the given load - the inverse of the + voltage-drop check, shown for the recommended cross-section. +- **Practical minimum for socket circuits**: `single_phase`-category + circuits are raised to at least 2.5 mm² if the calculation alone would + recommend less - see the dedicated note under Verification status. Never + lowers a calculation that already needs more. +- **Manual entry, always available**: the modal has a plain text Kabeltyp/ + Querschnitt field. Running a calculation pre-fills it as a suggestion, + but the field is the single source of truth for what gets applied, and + stays freely editable - satisfying + `docs/spec/06-future-sizing-and-calculations.md`'s explicit requirement + that "users must remain able to manually override suggestions" without + requiring a calculation to have been run first. A non-blocking warning + appears if the typed value is not a standard cross-section, is smaller + than the last calculation, or is below the practical minimum above - + informational only, never forced, matching the same spec doc's "shown as + a warning or status indicator, not as an automatic forced change." + +## How a suggestion or manual entry is applied + +The module never writes to `circuits` directly. The modal's "Übernehmen" +button calls the **existing, unmodified** frontend helper with whatever is +currently in the Kabeltyp/Querschnitt fields, calculated or hand-typed: + +```ts +updateCircuitById(projectId, expectedRevision, circuitId, { + cableCrossSection: "4 mm²", + cableType: "NYM-J 3x2.5", + cableLength: 30, +}); +``` + +which is the same `circuit.update` command the grid already uses for manual +cell edits. This means: + +- optimistic concurrency (`expectedRevision`) is respected automatically +- the change appears in the project's revision history and is undoable/ + redoable exactly like a manual edit +- nothing is ever written automatically - calculating only fills the modal's + own fields, applying is always an explicit, separate click + +## API contract + +`POST /api/cable-sizing/calculate` + +```jsonc +{ + "phase": 1, // 1 | 3 + "mode": "power", // "power" | "current" + "powerKw": 4.2, // circuit.circuitTotalPower, when mode="power" + "cosPhi": 1, + "voltage": 230, // circuit.voltage (already project-derived, read-only) + "lengthM": 23.5, // circuit.cableLength + "layingMethod": "C", // DIN VDE 0298-4 reference method, A1|A2|B1|B2|C|D1|D2|E|F|G + "conductorMaterial": "copper", // "copper" | "aluminum" + "insulation": "pvc", // "pvc" | "xlpe" + "ambientTemperatureC": 30, + "groupingCircuits": 1, + "maxVoltageDropPercent": 3, + "harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only + "existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice + "circuitCategory": "single_phase", // optional, from the circuit's section.category - + // enables the practical-minimum convention below + "context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" } +} +``` + +Response: `{ calculationId, result: CableSizingResult, alerts: CableSizingAlert[] }` +(see `src/cable-sizing/domain/cable-sizing-calculation.ts` for the exact +shape). `GET /api/cable-sizing/laying-methods` and `.../insulation-materials` +expose the pick-list metadata (including which combinations are verified) so +a future non-modal UI could build its own form without hard-coding the enum. + +## Verification status - important + +The reference current-carrying-capacity tables are ported from a sibling +project's Kabelliste module, which was itself only verified against ~8 +public sources for **six** of the ten DIN VDE 0298-4 reference laying +methods (A1, B2, C, E, D1, D2) and **PVC insulation only**. The other four +methods (A2, B1, F, G) and XLPE/VPE insulation could not be verified without +contradiction across sources during that earlier work, so +`calculateCableSizing` deliberately returns `dataVerified: false` and no +numeric recommendation for those combinations, rather than a guessed value. +The UI surfaces this as a plain critical alert. Anyone with access to the +actual norm text can extend `LAYING_METHOD_VERIFIED` and +`CURRENT_CAPACITY_A` in `src/cable-sizing/domain/cable-sizing-calculation.ts` +once the missing tables are confirmed. + +The protection-coordination check (`existingProtectionRatedCurrentA` vs. the +recommended cross-section's corrected capacity) is a **simplified** `In <= +Iz` check only - it is not a full IEC 60364-4-43 overload (`I2 <= 1.45 x +Iz`) or short-circuit withstand check. + +`PRACTICAL_MINIMUM_CROSS_SECTION_MM2` (2.5 mm² for `single_phase`) is +**not** a verified norm value at all - it is a named planning convention, +sourced directly from this project's own +`docs/spec/06-future-sizing-and-calculations.md` ("Standard Single-Phase +Circuits ... usually use ... cable cross-section: 2.5 mm²"), applied as a +floor on top of the calculated recommendation. It intentionally only +covers the one category and one convention that document already states; +it is not a general substitute for norm-compliant calculation. + +## Relationship to the project's own future-sizing spec + +`docs/spec/06-future-sizing-and-calculations.md` separately describes simple +category-based defaults (e.g. lighting circuits -> 10 A / 1.5 mm²) as +"common planning defaults, not a replacement for full norm-compliant +calculation". This module is the latter: an on-demand, norm-referenced +calculation for one circuit at a time, not a bulk default-filling tool. The +two are complementary and could later be wired together (e.g. the category +defaults pre-fill this module's laying-method/insulation fields), but that +integration is out of scope here. + +The spec's `isPublicBuilding` halogen-free rule and the `Control Requirement` +(DALI/KNX/core-count) field are **not** implemented by this module - both +concern cable *type* selection, not cross-section sizing, and the latter +isn't in the current schema yet. They're natural follow-ups once this module +is reviewed. + +## Maintenance plan + +The `src/cable-sizing/domain/` folder has zero imports from the rest of the +app by design (see the dependency-direction note above), so it can be +extracted into an independently versioned/published package later (e.g. a +private npm package or a git subtree) without touching anything outside the +five files/folders listed above - "separately maintained but part of the +app for now", per the intent of this proposal. + +## Local testing + +Deployed and manually exercised end-to-end on an internal test host at +`http://192.168.0.133:3220` (own docker deployment, own subnet/ports to +avoid clashing with ~50 other containers on that host - see the two +commits marked "Local-only" in this branch, which are not part of this +proposal and should not be carried over if it is ever proposed upstream). +Exercised against seeded real project/circuit data created through the +actual command API (not direct DB writes), including a deliberately long +(85 m) circuit to trigger the voltage-drop-critical path. + +`npm test`, `npm run build:api`, `npm run build:web` and +`npm run typecheck:scripts` all pass with this module included at every +commit in this branch. + +This branch (`feature/cable-sizing-module`) is pushed to a mirror of this +repository under this homelab's own Forgejo instance, not to +`git.jappel.io` - no write access to the upstream repository was available +or used. See the branch's commit history for the incremental history of +this module, including fixes made during manual testing (trigger column, +icon rendering, breaker-aware sizing). diff --git a/docs/deployment.md b/docs/deployment.md index 06289a7..dfbb3f1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,27 +2,19 @@ ## Aktueller Status -Es gibt zwei Compose-Stacks. +Es gibt derzeit kein unterstütztes Produktionsdeployment. -`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und -`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack -für einen Server. +`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet +`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, +Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes +Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder +öffentlich erreichbar gemacht werden. -`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom -Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows -und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch -ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen -Entwicklungsrechner. - -Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch -ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich -erreichbar gemacht werden. - -## Topologie +## Entwicklungs-Topologie | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /web-health` | keine | +| Next.js Web | 3001 | `GET /` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -32,22 +24,15 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in - `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale + Dateibeobachtung in Docker - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung -(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im -Entwicklungsstack über `npm run db:migrate` und -`npm run db:verify:circuit-schema`). - -`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` -schreibt die Rewrite-Ziele aus `next.config.mjs` fest in -`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als -Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. +Beim API-Start laufen zuerst `npm run db:migrate` und +`npm run db:verify:circuit-schema`. ## Logging diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py deleted file mode 100644 index a3fe009..0000000 --- a/dynamo/01_check_model_identity.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. - -Optional Dynamo input: - IN[0]: output directory or complete .json file path - -The script intentionally performs no Revit transaction and changes no model -data. OUT contains a compact summary plus the complete report. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = { - "hasValue": False, - "raw": None, - "display": None, - } - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - data_type = None - group_type = None - if definition is not None: - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def sorted_parameters(element): - parameters = [] - try: - parameters = [describe_parameter(parameter) for parameter in element.Parameters] - except Exception: - return [] - return sorted( - parameters, - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ), - ) - - -def named_parameter_occurrences(element, parameter_name): - result = [] - try: - parameters = element.GetParameters(parameter_name) - if parameters is not None: - result = [describe_parameter(parameter) for parameter in parameters] - except Exception: - parameter = None - try: - parameter = element.LookupParameter(parameter_name) - except Exception: - pass - if parameter is not None: - result = [describe_parameter(parameter)] - return result - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def get_cloud_identity(document): - result = {"isModelInCloud": False} - try: - result["isModelInCloud"] = bool(document.IsModelInCloud) - except Exception: - return result - if not result["isModelInCloud"]: - return result - - try: - model_path = document.GetCloudModelPath() - result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - for property_name, output_name in ( - ("GetProjectGUID", "projectGuid"), - ("GetModelGUID", "modelGuid"), - ): - try: - result[output_name] = str(getattr(model_path, property_name)()) - except Exception: - pass - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def get_worksharing_identity(document): - result = {"isWorkshared": False} - try: - result["isWorkshared"] = bool(document.IsWorkshared) - except Exception: - return result - if not result["isWorkshared"]: - return result - - try: - model_path = document.GetWorksharingCentralModelPath() - result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def resolve_output_path(configured_path, report_name): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - - project_information = document.ProjectInformation - if project_information is None: - raise RuntimeError("The active document has no Project Information element.") - - application = document.Application - checked_parameters = { - name: named_parameter_occurrences(project_information, name) - for name in CHECKED_PARAMETER_NAMES - } - warnings = [] - for name in CHECKED_PARAMETER_NAMES: - occurrences = checked_parameters[name] - populated = [ - parameter - for parameter in occurrences - if parameter.get("value", {}).get("raw") not in (None, "") - ] - if not occurrences: - warnings.append(name + " is not bound to Project Information.") - elif not populated: - warnings.append(name + " exists but has no value on Project Information.") - elif len(occurrences) > 1: - warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "isFamilyDocument": bool(document.IsFamilyDocument), - "cloud": get_cloud_identity(document), - "worksharing": get_worksharing_identity(document), - }, - "modelIdentityCandidates": { - "projectInformationUniqueId": safe_text(project_information.UniqueId), - "projectInformationElementId": element_id_text(project_information.Id), - "checkedProjectParameters": checked_parameters, - }, - "projectInformationParameters": sorted_parameters(project_information), - "warnings": warnings, - } - - -try: - report = build_report() - output_path = resolve_output_path(get_input(0), "model-identity") - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "projectInformationUniqueId": report["modelIdentityCandidates"][ - "projectInformationUniqueId" - ], - "warnings": report["warnings"], - "report": report, - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py deleted file mode 100644 index 440a222..0000000 --- a/dynamo/02_export_electrical_fixture_parameter_inventory.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Export all Electrical Fixtures instance/type parameters from Revit 2026. - -Optional Dynamo inputs: - IN[0]: output directory or complete .json file path - IN[1]: include empty parameters (default True) - IN[2]: maximum aggregated sample values (default 5) - -The script is read-only and performs no Revit transaction. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ( # noqa: E402 - BuiltInCategory, - FilteredElementCollector, - ModelPathUtils, - StorageType, -) -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = {"hasValue": False, "raw": None, "display": None} - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter, scope): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - data_type = None - group_type = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "scope": scope, - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def has_meaningful_value(parameter_description): - value = parameter_description.get("value", {}) - return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( - "display" - ) not in (None, "") - - -def read_parameters(element, scope, include_empty): - result = [] - try: - for parameter in element.Parameters: - description = describe_parameter(parameter, scope) - if include_empty or has_meaningful_value(description): - result.append(description) - except Exception as error: - return [], [safe_text(error)] - result.sort( - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ) - ) - return result, [] - - -def read_space(element, document): - try: - space = element.Space - except Exception as error: - return None, safe_text(error) - if space is None: - return None, None - - level_name = None - try: - level = document.GetElement(space.LevelId) - level_name = None if level is None else safe_text(level.Name) - except Exception: - pass - - return { - "uniqueId": safe_text(space.UniqueId), - "elementId": element_id_text(space.Id), - "number": safe_text(getattr(space, "Number", None)), - "name": safe_text(getattr(space, "Name", None)), - "levelName": level_name, - }, None - - -def read_family_identity(element, document): - symbol = None - try: - symbol = element.Symbol - except Exception: - try: - symbol = document.GetElement(element.GetTypeId()) - except Exception: - pass - - family_name = None - type_name = None - type_unique_id = None - type_element_id = None - if symbol is not None: - try: - family_name = safe_text(symbol.Family.Name) - except Exception: - family_name = safe_text(getattr(symbol, "FamilyName", None)) - type_name = safe_text(getattr(symbol, "Name", None)) - type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) - type_element_id = element_id_text(getattr(symbol, "Id", None)) - - return { - "familyName": family_name, - "typeName": type_name, - "typeUniqueId": type_unique_id, - "typeElementId": type_element_id, - }, symbol - - -def parameter_inventory_key(parameter): - stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" - return "|".join( - ( - parameter.get("scope") or "", - stable_id, - parameter.get("name") or "", - parameter.get("dataTypeId") or "", - ) - ) - - -def sample_value_key(value): - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def add_to_inventory(inventory, parameter, max_samples): - key = parameter_inventory_key(parameter) - entry = inventory.get(key) - if entry is None: - entry = { - "scope": parameter.get("scope"), - "name": parameter.get("name"), - "parameterId": parameter.get("parameterId"), - "isShared": parameter.get("isShared"), - "sharedGuid": parameter.get("sharedGuid"), - "storageType": parameter.get("storageType"), - "dataTypeId": parameter.get("dataTypeId"), - "groupTypeId": parameter.get("groupTypeId"), - "unitTypeId": parameter.get("unitTypeId"), - "occurrenceCount": 0, - "populatedCount": 0, - "sampleValues": [], - "_sampleKeys": set(), - } - inventory[key] = entry - entry["occurrenceCount"] += 1 - if has_meaningful_value(parameter): - entry["populatedCount"] += 1 - value = parameter.get("value") - value_key = sample_value_key(value) - if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: - entry["_sampleKeys"].add(value_key) - entry["sampleValues"].append(value) - - -def finalize_inventory(inventory): - result = [] - for entry in inventory.values(): - clean_entry = dict(entry) - clean_entry.pop("_sampleKeys", None) - result.append(clean_entry) - return sorted( - result, - key=lambda entry: ( - entry.get("scope") or "", - (entry.get("name") or "").casefold(), - entry.get("sharedGuid") or entry.get("parameterId") or "", - ), - ) - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def resolve_output_path(configured_path): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(include_empty, max_samples): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - if document.IsFamilyDocument: - raise RuntimeError("Open a Revit project document, not a family document.") - - application = document.Application - collector = ( - FilteredElementCollector(document) - .OfCategory(BuiltInCategory.OST_ElectricalFixtures) - .WhereElementIsNotElementType() - ) - source_elements = list(collector) - source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") - - elements = [] - types_by_unique_id = {} - inventory = {} - errors = [] - elements_without_space = 0 - - for element in source_elements: - element_errors = [] - try: - family_identity, symbol = read_family_identity(element, document) - instance_parameters, parameter_errors = read_parameters( - element, "instance", include_empty - ) - element_errors.extend(parameter_errors) - for parameter in instance_parameters: - add_to_inventory(inventory, parameter, max_samples) - - type_unique_id = family_identity.get("typeUniqueId") - if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: - type_parameters, type_errors = read_parameters(symbol, "type", include_empty) - element_errors.extend(type_errors) - for parameter in type_parameters: - add_to_inventory(inventory, parameter, max_samples) - types_by_unique_id[type_unique_id] = { - **family_identity, - "parameters": type_parameters, - } - - space, space_error = read_space(element, document) - if space_error: - element_errors.append("MEP Space: " + space_error) - if space is None: - elements_without_space += 1 - - elements.append( - { - "uniqueId": safe_text(element.UniqueId), - "elementId": element_id_text(element.Id), - "categoryName": safe_text( - None if element.Category is None else element.Category.Name - ), - "family": family_identity, - "space": space, - "instanceParameters": instance_parameters, - "warnings": element_errors, - } - ) - except Exception as error: - errors.append( - { - "uniqueId": safe_text(getattr(element, "UniqueId", None)), - "elementId": element_id_text(getattr(element, "Id", None)), - "error": safe_text(error), - } - ) - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "complete": len(errors) == 0, - "scope": { - "builtInCategory": "OST_ElectricalFixtures", - "wholeDocument": True, - "elementTypesExcluded": True, - "includeEmptyParameters": include_empty, - }, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), - }, - "summary": { - "elementCount": len(source_elements), - "exportedElementCount": len(elements), - "typeCount": len(types_by_unique_id), - "parameterDefinitionCount": len(inventory), - "elementsWithoutMepSpace": elements_without_space, - "elementErrorCount": len(errors), - }, - "parameterInventory": finalize_inventory(inventory), - "types": sorted( - types_by_unique_id.values(), - key=lambda entry: ( - (entry.get("familyName") or "").casefold(), - (entry.get("typeName") or "").casefold(), - entry.get("typeUniqueId") or "", - ), - ), - "elements": elements, - "errors": errors, - } - - -try: - include_empty_input = get_input(1, True) - include_empty = bool(include_empty_input) - try: - max_samples = int(get_input(2, 5)) - except Exception: - max_samples = 5 - max_samples = max(0, min(max_samples, 50)) - - report = build_report(include_empty, max_samples) - output_path = resolve_output_path(get_input(0)) - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "complete": report["complete"], - "summary": report["summary"], - "errors": report["errors"], - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/README.md b/dynamo/README.md deleted file mode 100644 index 800eded..0000000 --- a/dynamo/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Revit 2026 / Dynamo diagnostics - -This directory contains self-contained Python scripts for a Dynamo **Python -Script** node. They use only Dynamo's built-in Revit integration, the Revit API -and the Python standard library. No Dynamo package is required. - -The scripts are read-only. They do not start a Revit transaction and do not -change the open model. - -## Python engine - -Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with -Revit; optional PythonNet3 packages are not required by these diagnostics. - -## 01 - Check model identity - -File: `01_check_model_identity.py` - -The script reports: - -- Revit, Dynamo and Python versions; -- `ProjectInformation.UniqueId` as a native model-identity candidate; -- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project - Information; -- all Project Information parameters; -- optional cloud/worksharing identity information when the API exposes it. - -Input `IN[0]` is optional. It may be either an output directory or a complete -`.json` file path. With no input, the report is written below the current -Windows temporary directory in `leistungsbilanz-dynamo`. - -## 02 - Inventory Electrical Fixtures parameters - -File: `02_export_electrical_fixture_parameter_inventory.py` - -The script reads every instance of -`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It -exports: - -- element, family, type and MEP Space identities; -- every instance parameter and value; -- every unique family-type parameter and value; -- an aggregated parameter inventory with occurrence counts and sample values; -- per-element warnings instead of aborting at the first unreadable element. - -Inputs: - -- `IN[0]` (optional): output directory or complete `.json` path; -- `IN[1]` (optional): include empty parameters, default `true`; -- `IN[2]` (optional): maximum sample values per aggregated parameter, default - `5`. - -The default output location is again the Windows temporary directory. The -generated report can contain model paths and project-specific parameter values; -review it before sharing or committing it. - -## Running a script - -1. Open the target model in Revit 2026. -2. Open Dynamo from **Manage > Visual Programming > Dynamo**. -3. Create a graph and add a **Python Script** node. -4. Select the `CPython3` engine for the node. -5. Copy the complete content of the desired `.py` file into the node. -6. Optionally connect a String node containing the output path to `IN[0]`. -7. Run the graph and inspect `OUT` for status, counts and the generated path. - -For the first test, run `01_check_model_identity.py` in the Revit main model. -Then run the parameter inventory. Keep both generated JSON files so their -structure can be checked before the production snapshot DTO is finalized. diff --git a/next.config.mjs b/next.config.mjs index e0610b9..486b23f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,7 +5,8 @@ const nextConfig = { allowedDevOrigins: [ "192.168.3.13", "docker01.int.jappel.io", - "lb.jappel.io" + "lb.jappel.io", + "192.168.0.133" ], typescript: { diff --git a/package-lock.json b/package-lock.json index 417421b..b0abb41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,15 +22,12 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" - }, - "engines": { - "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1517,13 +1514,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, - "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -3692,11 +3688,10 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "devOptional": true }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 414eb53..9bc5ae3 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,6 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", - "engines": { - "node": "24.x" - }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -13,9 +10,6 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", - "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", - "docker:dev:down": "docker compose -f compose.dev.yaml down", - "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", @@ -46,7 +40,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..88e1c35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1209,6 +1209,48 @@ a.kpi:hover { box-shadow: inset 0 0 0 1px var(--color-signal); } +.tree-grid .cell-cable-sizing-trigger { + color: var(--color-primary); + cursor: pointer; + font-weight: 600; + transition: + background-color 0.12s ease, + box-shadow 0.12s ease; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-cable-sizing-trigger:hover { + background: rgba(63, 130, 166, 0.15); + box-shadow: inset 0 0 0 1px var(--color-primary); +} + +.tree-grid .cell-cable-sizing-trigger { + position: relative; + padding-left: 1.4em; +} + +.tree-grid .cell-cable-sizing-trigger::before { + content: ""; + position: absolute; + left: 0.2em; + top: 50%; + transform: translateY(-50%); + width: 0.9em; + height: 0.9em; + opacity: 0.85; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233f82a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E"); +} + +:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger::before { + background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a9d2e6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E"); +} + .tree-grid .device-drag-handle { cursor: grab; } diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts deleted file mode 100644 index ce6f2c4..0000000 --- a/src/app/web-health/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from "next/server"; - -// Liveness probe for the web container itself. The "/health" path is -// rewritten to the API in next.config.mjs, so it cannot answer for this -// process. Kept as a route handler so a probe does not render a page. -export const dynamic = "force-dynamic"; - -export function GET() { - return NextResponse.json({ ok: true }); -} diff --git a/src/cable-sizing/domain/cable-sizing-calculation.ts b/src/cable-sizing/domain/cable-sizing-calculation.ts new file mode 100644 index 0000000..3f228f4 --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -0,0 +1,547 @@ +// Pure cable sizing domain logic. No imports from db/, server/ or frontend/ - +// mirrors the dependency direction of src/external-model/ (see +// docs/cable-sizing-module.md). Everything here is a stateless function on +// plain data; persistence and the versioned circuit.update command live +// outside this module. +// +// The numeric reference tables below (cross-section current-carrying +// capacity, temperature/grouping correction factors) are ported from a +// sibling project's already-verified Kabelliste module +// (elt-planung-suite/src/tools/kabel/index.ts), not re-derived. See +// docs/cable-sizing-module.md for the verification history and the explicit +// "not yet verified" methods, which intentionally return no numeric result +// instead of a guessed one (dataVerified: false). + +// -- Laying methods (DIN VDE 0298-4 reference installation methods) -------- + +export const LAYING_METHODS = [ + "A1", + "A2", + "B1", + "B2", + "C", + "D1", + "D2", + "E", + "F", + "G", +] as const; +export type LayingMethod = (typeof LAYING_METHODS)[number]; + +export const LAYING_METHOD_GROUP: Record = { + A1: "air", + A2: "air", + B1: "air", + B2: "air", + C: "air", + E: "air", + F: "air", + D1: "ground", + D2: "ground", + G: "ground", +}; + +// A1, B2, C, E, D1, D2: verified against elt-planung-suite's ported original +// tool. A2, B1, F, G: no verified capacity table available yet (see +// docs/cable-sizing-module.md) - calculateCableSizing returns +// dataVerified: false for these instead of a guessed number. +export const LAYING_METHOD_VERIFIED: Record = { + A1: true, + B2: true, + C: true, + E: true, + D1: true, + D2: true, + A2: false, + B1: false, + F: false, + G: false, +}; + +export const LAYING_METHOD_LABELS: Record = { + A1: "A1 - Rohr in wärmegedämmter Wand", + A2: "A2 - Mehradriges Kabel im Rohr in wärmegedämmter Wand (Werte nicht hinterlegt)", + B1: "B1 - Einzeladern im Rohr auf/unter Putz (Werte nicht hinterlegt)", + B2: "B2 - Rohr auf/unter Putz", + C: "C - Kabel direkt auf Wand verlegt", + E: "E - Kabelpritsche / frei in Luft", + F: "F - Kabel frei in Luft, einzeln mit Abstand (Werte nicht hinterlegt)", + D1: "D1 - Kabel direkt im Erdreich", + D2: "D2 - Kabel im Rohr im Erdreich", + G: "G - Erdverlegung, Sonderfall (Werte nicht hinterlegt)", +}; + +// -- Insulation / conductor material ---------------------------------------- + +export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const; +export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number]; + +export const INSULATION_MATERIAL_LABELS: Record = { + pvc: "PVC (Grenztemperatur 70 °C)", + xlpe: "VPE/XLPE (Grenztemperatur 90 °C) - Strombelastbarkeitswerte nicht hinterlegt", +}; + +export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const; +export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number]; + +// Only PVC combined with one of the six verified laying methods returns a +// numeric result. XLPE has no verified capacity table for any method yet. +export function isCableSizingDataVerified( + layingMethod: LayingMethod, + insulation: InsulationMaterial +): boolean { + return insulation === "pvc" && LAYING_METHOD_VERIFIED[layingMethod]; +} + +// -- Harmonic neutral loading (IEC 60364-5-52, three-phase only) ----------- + +export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [ + "none", + "15to33Percent", + "over33Percent", +] as const; +export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number]; + +export const HARMONIC_NEUTRAL_LOAD_LABELS: Record = { + none: "Keine nennenswerte 3. Harmonische (< 15 %) - Standardfall", + "15to33Percent": + "3. Harmonische 15-33 % - Reduktionsfaktor 0,86 (IEC 60364-5-52)", + over33Percent: + "3. Harmonische > 33 % - Neutralleiter wie Außenleiter dimensionieren", +}; + +export const HARMONIC_REDUCTION_FACTOR = 0.86; + +// -- Reference tables (ported, verified subset only) ------------------------ + +export const CROSS_SECTIONS_MM2 = [ + 1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240, +] as const; + +type CoreCount = 2 | 3; + +export const CURRENT_CAPACITY_A: Partial< + Record> +> = { + A1: { + 2: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, null, null, null], + 3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, null, null, null], + }, + B2: { + 2: [16.5, 23, 30, 38, 52, 69, 90, 111, 134, 171, 207, 239, null, null, null], + 3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, null, null, null], + }, + C: { + 2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461], + 3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403], + }, + E: { + 2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514], + 3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430], + }, + D1: { + 2: [26, 34, 44, 56, 74, 96, 123, 147, 174, 216, 256, 290, 328, 367, 424], + 3: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 361], + }, + D2: { + 2: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 360], + 3: [18.5, 24, 31, 39, 52, 67, 86, 103, 122, 151, 179, 203, 230, 258, 297], + }, +}; + +export const TEMPERATURE_FACTOR_AIR: Record = { + 10: 1.29, + 15: 1.22, + 20: 1.15, + 25: 1.08, + 30: 1.0, + 35: 0.91, + 40: 0.82, + 45: 0.71, + 50: 0.58, +}; + +export const TEMPERATURE_FACTOR_GROUND: Record = { + 10: 1.1, + 15: 1.05, + 20: 1.0, + 25: 0.95, + 30: 0.89, + 35: 0.84, + 40: 0.77, +}; + +export const GROUPING_FACTOR_AIR: Record = { + 1: 1.0, + 2: 0.8, + 3: 0.7, + 4: 0.65, + 5: 0.6, + 6: 0.57, + 8: 0.54, + 10: 0.5, +}; + +export const GROUPING_FACTOR_GROUND: Record = { + 1: 1.0, + 2: 0.75, + 3: 0.65, + 4: 0.6, + 5: 0.55, + 6: 0.52, + 8: 0.49, + 10: 0.46, +}; + +export const ALUMINUM_CAPACITY_FACTOR = 0.78; +export const KAPPA: Record = { copper: 48, aluminum: 30 }; + +export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const; + +export const GROUPING_LABELS: Record = { + 1: "1 (keine Häufung)", + 2: "2", + 3: "3", + 4: "4", + 5: "5", + 6: "6", + 8: "7-9", + 10: "≥10", +}; + +// -- Practical minimum cross-sections by circuit category ------------------ +// Not a thermal/voltage-drop calculation result - a widely used planning +// convention for margin against future load growth, mechanical robustness +// and fault-current withstand, beyond what a pure Ib-based calculation +// would give. Matches this project's own docs/spec/06-future-sizing-and- +// calculations.md ("Standard Single-Phase Circuits ... usually use ... +// cable cross-section: 2.5 mm²"). Only applied for the "single_phase" +// circuit category (general sockets and similar loads); lighting and +// three-phase circuits are not covered by this specific convention. +export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial> = { + single_phase: 2.5, +}; + +export type CircuitCategory = "lighting" | "single_phase" | "three_phase"; + +// -- Input / result ----------------------------------------------------- + +export interface CableSizingInput { + phase: 1 | 3; + mode: "power" | "current"; + /** kW, only used when mode === "power" */ + powerKw?: number; + /** A, only used when mode === "current" */ + currentA?: number; + cosPhi: number; + /** Line voltage in V. The circuit-list app derives this from project + * settings (singlePhaseVoltageV / threePhaseVoltageV); pass it through + * rather than hard-coding 230/400 here. */ + voltage: number; + /** Single-run cable length in meters (circuits.cableLength). */ + lengthM: number; + layingMethod: LayingMethod; + conductorMaterial: ConductorMaterial; + insulation: InsulationMaterial; + ambientTemperatureC: number; + groupingCircuits: number; + maxVoltageDropPercent: number; + harmonicNeutralLoad: HarmonicNeutralLoad; + /** Optional: existing protection device rated current (LS/circuit + * breaker), for the simplified coordination check below. */ + existingProtectionRatedCurrentA?: number; + /** Optional: enables the practical-minimum-cross-section convention for + * "single_phase" circuits, see PRACTICAL_MINIMUM_CROSS_SECTION_MM2. */ + circuitCategory?: CircuitCategory; +} + +export interface CrossSectionRow { + crossSectionMm2: number; + ratedCurrentA: number | null; + correctedCurrentA: number | null; + currentSufficient: boolean | null; + voltageDropPercent: number | null; + voltageDropSufficient: boolean | null; + /** Longest single-run length (m) at which this cross-section still meets + * the requested max voltage drop, at the given load/cosPhi/phase - the + * inverse of the voltage-drop formula used for voltageDropPercent above. + * null when the operating current is zero (division by zero). */ + maxLengthForVoltageDropM: number | null; + recommended: boolean; +} + +export interface ProtectionCoordinationResult { + ratedCurrentA: number; + /** Simplified In <= Iz check only - not full overload (I2 <= 1.45 x Iz) + * or short-circuit coordination. */ + coordinated: boolean; +} + +export interface CableSizingResult { + /** false: no verified capacity table for this layingMethod/insulation + * combination - every field below is null/empty, never a guess. */ + dataVerified: boolean; + operatingCurrentA: number; + /** max(operatingCurrentA, existingProtectionRatedCurrentA). The cable + * capacity (Iz) must cover the protective device's rated current (In), + * not just the actual load current - a breaker only trips at In, so a + * cable sized for Ib alone could overheat under sustained load below the + * trip threshold. This is the value cross-section selection actually + * uses; operatingCurrentA is kept for display/voltage-drop only. */ + designCurrentA: number; + crossSectionByCapacityMm2: number | null; + crossSectionByVoltageDropMm2: number | null; + recommendedCrossSectionMm2: number | null; + voltageDropAtRecommendedPercent: number | null; + combinedDerationFactor: number; + harmonicReductionApplied: boolean; + /** true if the recommendation was raised to satisfy + * PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (a convention, not a thermal/ + * voltage-drop requirement of this specific circuit). */ + practicalMinimumApplied: boolean; + rows: CrossSectionRow[]; + protectionCoordination: ProtectionCoordinationResult | null; +} + +function operatingCurrentA(input: CableSizingInput): number { + if (input.mode === "current") { + return input.currentA ?? 0; + } + const powerW = (input.powerKw ?? 0) * 1000; + return input.phase === 1 + ? powerW / (input.voltage * input.cosPhi) + : powerW / (Math.sqrt(3) * input.voltage * input.cosPhi); +} + +export function calculateCableSizing(input: CableSizingInput): CableSizingResult { + const ib = operatingCurrentA(input); + + const designCurrentA = + input.existingProtectionRatedCurrentA != null + ? Math.max(ib, input.existingProtectionRatedCurrentA) + : ib; + + if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) { + return { + dataVerified: false, + operatingCurrentA: ib, + designCurrentA, + crossSectionByCapacityMm2: null, + crossSectionByVoltageDropMm2: null, + recommendedCrossSectionMm2: null, + voltageDropAtRecommendedPercent: null, + combinedDerationFactor: 1, + harmonicReductionApplied: false, + practicalMinimumApplied: false, + rows: [], + protectionCoordination: null, + }; + } + + const coreCount: CoreCount = input.phase === 1 ? 2 : 3; + const aluminumFactor = + input.conductorMaterial === "aluminum" ? ALUMINUM_CAPACITY_FACTOR : 1; + const kappa = KAPPA[input.conductorMaterial]; + const group = LAYING_METHOD_GROUP[input.layingMethod]; + const temperatureTable = + group === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND; + const groupingTable = group === "air" ? GROUPING_FACTOR_AIR : GROUPING_FACTOR_GROUND; + const temperatureFactor = temperatureTable[input.ambientTemperatureC] ?? 1; + const groupingFactor = groupingTable[input.groupingCircuits] ?? 1; + + const harmonicReductionApplied = + input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent"; + const harmonicFactor = harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1; + + const combinedDerationFactor = temperatureFactor * groupingFactor * harmonicFactor; + const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3); + + const ratedCurrents = CURRENT_CAPACITY_A[input.layingMethod]![coreCount]; + const correctedCurrents = ratedCurrents.map((value) => + value == null ? null : value * aluminumFactor * combinedDerationFactor + ); + const voltageDrops = CROSS_SECTIONS_MM2.map( + (crossSection) => + ((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) / + (kappa * crossSection) / + input.voltage) * + 100 + ); + + const indexByCapacity = correctedCurrents.findIndex( + (value) => value != null && value >= designCurrentA + ); + const indexByVoltageDrop = voltageDrops.findIndex( + (value) => value <= input.maxVoltageDropPercent + ); + let indexRecommended = -1; + if (indexByCapacity >= 0 && indexByVoltageDrop >= 0) { + indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop); + } + + // Practical-minimum convention (see PRACTICAL_MINIMUM_CROSS_SECTION_MM2): + // only ever raises the recommendation, never lowers what the thermal/ + // voltage-drop calculation already required. + const practicalMinimumMm2 = input.circuitCategory + ? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory] + : undefined; + let practicalMinimumApplied = false; + if ( + indexRecommended >= 0 && + practicalMinimumMm2 != null && + CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2 + ) { + const minimumIndex = CROSS_SECTIONS_MM2.indexOf( + practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number] + ); + if (minimumIndex >= 0) { + indexRecommended = minimumIndex; + practicalMinimumApplied = true; + } + } + + const voltageDropCurrentBasis = ib * input.cosPhi; + const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => { + const rated = ratedCurrents[i]; + const corrected = correctedCurrents[i]; + const currentSufficient = corrected == null ? null : corrected >= designCurrentA; + const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent; + const maxLengthForVoltageDropM = + rated == null || voltageDropCurrentBasis <= 0 + ? null + : (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) / + (100 * voltageDropCoefficient * voltageDropCurrentBasis); + return { + crossSectionMm2: crossSection, + ratedCurrentA: rated, + correctedCurrentA: corrected, + currentSufficient, + voltageDropPercent: rated == null ? null : voltageDrops[i], + voltageDropSufficient: rated == null ? null : voltageDropSufficient, + maxLengthForVoltageDropM, + recommended: i === indexRecommended, + }; + }); + + let protectionCoordination: ProtectionCoordinationResult | null = null; + if (input.existingProtectionRatedCurrentA != null && indexRecommended >= 0) { + const recommendedRow = rows[indexRecommended]; + protectionCoordination = { + ratedCurrentA: input.existingProtectionRatedCurrentA, + coordinated: + recommendedRow.correctedCurrentA != null && + input.existingProtectionRatedCurrentA <= recommendedRow.correctedCurrentA, + }; + } + + return { + dataVerified: true, + operatingCurrentA: ib, + designCurrentA, + crossSectionByCapacityMm2: + indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null, + crossSectionByVoltageDropMm2: + indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null, + recommendedCrossSectionMm2: + indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null, + voltageDropAtRecommendedPercent: + indexRecommended >= 0 ? voltageDrops[indexRecommended] : null, + combinedDerationFactor, + harmonicReductionApplied, + practicalMinimumApplied, + rows, + protectionCoordination, + }; +} + +export interface CableSizingAlert { + kind: "critical" | "warn" | "info" | "ok"; + text: string; +} + +export function buildCableSizingAlerts( + input: CableSizingInput, + result: CableSizingResult +): CableSizingAlert[] { + const alerts: CableSizingAlert[] = []; + + if (!result.dataVerified) { + alerts.push({ + kind: "critical", + text: `Für Verlegeart ${input.layingMethod} mit Isolierstoff ${input.insulation.toUpperCase()} liegen keine geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich.`, + }); + return alerts; + } + + const byCapacity = result.crossSectionByCapacityMm2; + const byDrop = result.crossSectionByVoltageDropMm2; + const recommended = result.recommendedCrossSectionMm2; + + if (byCapacity == null) { + const dueToBreaker = + input.existingProtectionRatedCurrentA != null && + input.existingProtectionRatedCurrentA > result.operatingCurrentA; + alerts.push({ + kind: "critical", + text: dueToBreaker + ? `Vorhandene Sicherung (${input.existingProtectionRatedCurrentA} A) übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod} - nicht der Betriebsstrom (${result.operatingCurrentA.toFixed(1)} A). Größere Verlegeart oder kleinere Sicherung prüfen.` + : `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`, + }); + } else if (byDrop != null && byDrop > byCapacity) { + alerts.push({ + kind: "warn", + text: `Spannungsfall ist maßgeblich, nicht die Belastbarkeit: ${byCapacity} mm² würde thermisch reichen, ${byDrop} mm² ist wegen ΔU ≤ ${input.maxVoltageDropPercent}% nötig.`, + }); + } + if (result.harmonicReductionApplied) { + alerts.push({ + kind: "info", + text: "Reduktionsfaktor 0,86 wegen Oberschwingungsanteil 15-33 % im Neutralleiter angewendet (IEC 60364-5-52).", + }); + } + if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") { + alerts.push({ + kind: "warn", + text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.", + }); + } + if (result.practicalMinimumApplied) { + alerts.push({ + kind: "info", + text: `Auf ${result.recommendedCrossSectionMm2} mm² angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine reine Berechnungsanforderung).`, + }); + } + if (result.combinedDerationFactor < 1) { + alerts.push({ + kind: "info", + text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`, + }); + } + if (result.protectionCoordination) { + alerts.push( + result.protectionCoordination.coordinated + ? { + kind: "info", + text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) ist bei der Dimensionierung berücksichtigt (In ≤ Iz, vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`, + } + : { + kind: "warn", + text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen.`, + } + ); + } + if (alerts.length === 0 && recommended != null) { + alerts.push({ + kind: "ok", + text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`, + }); + } + const recommendedRow = result.rows.find((row) => row.recommended); + if (recommendedRow?.maxLengthForVoltageDropM != null) { + alerts.push({ + kind: "info", + text: `Maximale Länge bei ${recommendedRow.crossSectionMm2} mm² und ΔU ≤ ${input.maxVoltageDropPercent}%: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`, + }); + } + return alerts; +} diff --git a/src/cable-sizing/domain/cable-sizing-contracts.ts b/src/cable-sizing/domain/cable-sizing-contracts.ts new file mode 100644 index 0000000..5648d8d --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-contracts.ts @@ -0,0 +1,49 @@ +// Request/response validation for the cable-sizing HTTP API. Pure - depends +// only on zod and the sibling calculation module, never on db/server. +import { z } from "zod"; +import { + CONDUCTOR_MATERIALS, + HARMONIC_NEUTRAL_LOAD_OPTIONS, + INSULATION_MATERIALS, + LAYING_METHODS, + type CableSizingInput, +} from "./cable-sizing-calculation.js"; + +export const cableSizingRequestSchema = z + .object({ + phase: z.union([z.literal(1), z.literal(3)]), + mode: z.enum(["power", "current"]), + powerKw: z.number().min(0).optional(), + currentA: z.number().min(0).optional(), + cosPhi: z.number().min(0.6).max(1), + voltage: z.number().positive(), + lengthM: z.number().min(0), + layingMethod: z.enum(LAYING_METHODS), + conductorMaterial: z.enum(CONDUCTOR_MATERIALS), + insulation: z.enum(INSULATION_MATERIALS), + ambientTemperatureC: z.number(), + groupingCircuits: z.number().int().min(1), + maxVoltageDropPercent: z.number().min(1).max(5), + harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS), + existingProtectionRatedCurrentA: z.number().positive().optional(), + circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(), + // Optional context, persisted with the audit-log entry only - never + // used for the calculation itself. + context: z + .object({ + projectId: z.string().min(1).optional(), + circuitId: z.string().min(1).optional(), + equipmentIdentifier: z.string().min(1).optional(), + }) + .optional(), + }) + .refine((v) => (v.mode === "power" ? v.powerKw != null : v.currentA != null), { + message: "powerKw is required for mode 'power', currentA for mode 'current'", + }); + +export type CableSizingRequest = z.infer; + +// Structural check that the request schema stays a superset of the domain +// input shape (minus context, which is API-only metadata). +type _AssertAssignable = T; +type _Check = _AssertAssignable>; diff --git a/src/db/migrations/0007_watery_kingpin.sql b/src/db/migrations/0007_watery_kingpin.sql new file mode 100644 index 0000000..a4f22bf --- /dev/null +++ b/src/db/migrations/0007_watery_kingpin.sql @@ -0,0 +1,14 @@ +CREATE TABLE `cable_sizing_calculations` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text, + `circuit_id` text, + `equipment_identifier` text, + `input` text NOT NULL, + `result` text NOT NULL, + `applied_to_circuit` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `cable_sizing_calculations_circuit_id_idx` ON `cable_sizing_calculations` (`circuit_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0007_snapshot.json b/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..8a67832 --- /dev/null +++ b/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,2522 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "98546058-8ad8-4aa1-99ad-f4e915bea89c", + "prevId": "b25e1a40-c400-4848-bc24-a04bda7a8209", + "tables": { + "cable_sizing_calculations": { + "name": "cable_sizing_calculations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_to_circuit": { + "name": "applied_to_circuit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "cable_sizing_calculations_circuit_id_idx": { + "name": "cable_sizing_calculations_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cable_sizing_calculations_project_id_projects_id_fk": { + "name": "cable_sizing_calculations_project_id_projects_id_fk", + "tableFrom": "cable_sizing_calculations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cable_sizing_calculations_circuit_id_circuits_id_fk": { + "name": "cable_sizing_calculations_circuit_id_circuits_id_fk", + "tableFrom": "cable_sizing_calculations", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index eedfc7a..0b056a8 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786043080323, "tag": "0006_damp_skrulls", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1786114831610, + "tag": "0007_watery_kingpin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/cable-sizing-calculation.repository.ts b/src/db/repositories/cable-sizing-calculation.repository.ts new file mode 100644 index 0000000..803965b --- /dev/null +++ b/src/db/repositories/cable-sizing-calculation.repository.ts @@ -0,0 +1,37 @@ +import { desc, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { + cableSizingCalculations, + type CableSizingCalculation, + type NewCableSizingCalculation, +} from "../schema/cable-sizing-calculations.js"; + +// Plain repository, no revision/command semantics - mirrors +// application-repositories.ts's flat repository pattern, not the +// project-command-transaction boundary. A calculation is an audit record, +// not a project mutation. +export class CableSizingCalculationRepository { + constructor(private readonly db: AppDatabase) {} + + create(input: NewCableSizingCalculation): CableSizingCalculation { + return this.db.insert(cableSizingCalculations).values(input).returning().get(); + } + + listByCircuit(circuitId: string): CableSizingCalculation[] { + return this.db + .select() + .from(cableSizingCalculations) + .where(eq(cableSizingCalculations.circuitId, circuitId)) + .orderBy(desc(cableSizingCalculations.createdAt)) + .all(); + } + + markApplied(id: string): CableSizingCalculation | undefined { + return this.db + .update(cableSizingCalculations) + .set({ appliedToCircuit: 1 }) + .where(eq(cableSizingCalculations.id, id)) + .returning() + .get(); + } +} diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index eb722d7..da17458 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,7 +14,6 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; -import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -120,32 +119,9 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { - const values: typeof assignment.target & { - manualQuantity?: number; - } = { ...assignment.target }; - if (assignment.target.quantity !== assignment.expected.quantity) { - const externalTotal = tx - .select({ planningValues: externalModelObjects.planningValues }) - .from(externalModelObjects) - .where( - eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) - ) - .all() - .reduce( - (sum, object) => sum + object.planningValues.effectiveQuantity, - 0 - ); - const manualQuantity = assignment.target.quantity - externalTotal; - if (manualQuantity < 0) { - throw new Error( - "Synchronized quantity is below the total quantity of linked external objects." - ); - } - values.manualQuantity = manualQuantity; - } const updated = tx .update(circuitDeviceRows) - .set(values) + .set(assignment.target) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/src/db/schema/cable-sizing-calculations.ts b/src/db/schema/cable-sizing-calculations.ts new file mode 100644 index 0000000..511fab3 --- /dev/null +++ b/src/db/schema/cable-sizing-calculations.ts @@ -0,0 +1,30 @@ +import { sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { circuits } from "./circuits.js"; +import { projects } from "./projects.js"; + +// Audit log for the cable-sizing module (see docs/cable-sizing-module.md). +// Deliberately separate from the revision/command system: a calculation is +// not itself a project mutation, only applying its result via the existing +// circuit.update command is. circuitId/projectId are nullable and +// onDelete "set null" so a deleted circuit or project never blocks or +// cascades into losing the audit trail. +export const cableSizingCalculations = sqliteTable( + "cable_sizing_calculations", + { + id: text("id").primaryKey(), + projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }), + circuitId: text("circuit_id").references(() => circuits.id, { onDelete: "set null" }), + equipmentIdentifier: text("equipment_identifier"), + input: text("input", { mode: "json" }).notNull(), + result: text("result", { mode: "json" }).notNull(), + appliedToCircuit: integer("applied_to_circuit").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [index("cable_sizing_calculations_circuit_id_idx").on(table.circuitId)] +); + +export type CableSizingCalculation = typeof cableSizingCalculations.$inferSelect; +export type NewCableSizingCalculation = typeof cableSizingCalculations.$inferInsert; diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts new file mode 100644 index 0000000..ee52aef --- /dev/null +++ b/src/frontend/components/cable-sizing-api.ts @@ -0,0 +1,71 @@ +// Thin, self-contained fetch client for the cable-sizing module. Deliberately +// not merged into ../utils/api.ts to keep this module's frontend footprint a +// single new file rather than growing the existing shared API file - see +// docs/cable-sizing-module.md. +import type { + CableSizingAlert, + CableSizingInput, + CableSizingResult, + InsulationMaterial, + LayingMethod, +} from "../../cable-sizing/domain/cable-sizing-calculation"; + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + cache: "no-store", + }); + if (!response.ok) { + let details = await response.text(); + try { + const parsed = JSON.parse(details) as { error?: unknown }; + if (typeof parsed.error === "string") details = parsed.error; + } catch { + // keep raw text + } + throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`); + } + return response.json() as Promise; +} + +export interface CalculateCableSizingResponse { + calculationId: string; + result: CableSizingResult; + alerts: CableSizingAlert[]; +} + +export function calculateCableSizing( + input: CableSizingInput, + context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string } +): Promise { + return request("/api/cable-sizing/calculate", { + method: "POST", + body: JSON.stringify({ ...input, context }), + }); +} + +export function markCableSizingCalculationApplied(calculationId: string): Promise { + return request(`/api/cable-sizing/calculations/${calculationId}/applied`, { + method: "POST", + }); +} + +export interface LayingMethodOption { + method: LayingMethod; + label: string; + dataVerified: boolean; +} + +export function listLayingMethods(): Promise { + return request("/api/cable-sizing/laying-methods"); +} + +export interface InsulationMaterialOption { + insulation: InsulationMaterial; + label: string; +} + +export function listInsulationMaterials(): Promise { + return request("/api/cable-sizing/insulation-materials"); +} diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx new file mode 100644 index 0000000..36b204c --- /dev/null +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { type FormEvent, useMemo, useState } from "react"; +import { + CONDUCTOR_MATERIALS, + CROSS_SECTIONS_MM2, + GROUPING_LABELS, + GROUPING_OPTIONS, + HARMONIC_NEUTRAL_LOAD_LABELS, + HARMONIC_NEUTRAL_LOAD_OPTIONS, + INSULATION_MATERIALS, + INSULATION_MATERIAL_LABELS, + LAYING_METHODS, + LAYING_METHOD_GROUP, + LAYING_METHOD_LABELS, + PRACTICAL_MINIMUM_CROSS_SECTION_MM2, + TEMPERATURE_FACTOR_AIR, + TEMPERATURE_FACTOR_GROUND, + type CableSizingAlert, + type CableSizingInput, + type CableSizingResult, + type CircuitCategory, + type ConductorMaterial, + type HarmonicNeutralLoad, + type InsulationMaterial, + type LayingMethod, +} from "../../cable-sizing/domain/cable-sizing-calculation"; +import { calculateCableSizing } from "./cable-sizing-api"; +import type { CircuitTreeCircuitDto } from "../types"; +import { FormModal } from "./form-modal"; + +interface CableSizingModalProps { + circuit: CircuitTreeCircuitDto; + circuitCategory?: CircuitCategory; + isSaving: boolean; + projectId: string; + onClose: () => void; + onApply: (patch: { + cableCrossSection: string; + cableType?: string; + cableLength?: number; + }) => Promise; +} + +function temperatureOptions(method: LayingMethod) { + const table = + LAYING_METHOD_GROUP[method] === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND; + return Object.keys(table).map(Number).sort((a, b) => a - b); +} + +// Extracts the first decimal number from a free-text cross-section entry +// like "2.5 mm²" or "2,5". Returns null if nothing parseable is found - +// used only for the manual-entry sanity check below, never persisted. +function parseCrossSectionMm2(text: string): number | null { + const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/); + if (!match) return null; + const value = Number(match[1].replace(",", ".")); + return Number.isFinite(value) ? value : null; +} + +export function CableSizingModal({ + circuit, + circuitCategory, + isSaving, + projectId, + onClose, + onApply, +}: CableSizingModalProps) { + const [layingMethod, setLayingMethod] = useState("C"); + const [insulation, setInsulation] = useState("pvc"); + const [conductorMaterial, setConductorMaterial] = useState("copper"); + const [ambientTemperatureC, setAmbientTemperatureC] = useState(30); + const [groupingCircuits, setGroupingCircuits] = useState(1); + const [cosPhi, setCosPhi] = useState(1); + const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3); + const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState("none"); + const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0); + const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1); + + // Manual override: applying this never requires a calculation. Prefilled + // from the circuit's current values so opening the modal on an already + // specified cable doesn't lose that data. + const [manualCableType, setManualCableType] = useState(circuit.cableType ?? ""); + const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? ""); + + const [calculating, setCalculating] = useState(false); + const [calculationId, setCalculationId] = useState(null); + const [result, setResult] = useState(null); + const [alerts, setAlerts] = useState([]); + const [error, setError] = useState(null); + + const voltage = circuit.voltage ?? (phase === 1 ? 230 : 400); + + const input: CableSizingInput = useMemo( + () => ({ + phase, + mode: "power", + powerKw: circuit.circuitTotalPower, + cosPhi, + voltage, + lengthM, + layingMethod, + conductorMaterial, + insulation, + ambientTemperatureC, + groupingCircuits, + maxVoltageDropPercent, + harmonicNeutralLoad, + existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA, + circuitCategory, + }), + [ + phase, + circuit.circuitTotalPower, + cosPhi, + voltage, + lengthM, + layingMethod, + conductorMaterial, + insulation, + ambientTemperatureC, + groupingCircuits, + maxVoltageDropPercent, + harmonicNeutralLoad, + circuit.protectionDevice?.ratedCurrentA, + circuitCategory, + ] + ); + + const manualCrossSectionWarning = useMemo(() => { + const trimmed = manualCrossSection.trim(); + if (!trimmed) return null; + const parsed = parseCrossSectionMm2(trimmed); + if (parsed == null) return null; + if (!(CROSS_SECTIONS_MM2 as readonly number[]).includes(parsed)) { + return `${parsed} mm² ist kein Standard-Querschnitt (${CROSS_SECTIONS_MM2.join(", ")}).`; + } + if ( + result?.dataVerified && + result.recommendedCrossSectionMm2 != null && + parsed < result.recommendedCrossSectionMm2 + ) { + return `${parsed} mm² ist kleiner als die zuletzt berechnete Empfehlung (${result.recommendedCrossSectionMm2} mm²) - passt nicht zu Last${circuit.protectionDevice ? "/Sicherung" : ""} und Verlegeart.`; + } + const practicalMinimum = circuitCategory + ? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory] + : undefined; + if (practicalMinimum != null && parsed < practicalMinimum) { + return `${parsed} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${practicalMinimum} mm²).`; + } + return null; + }, [manualCrossSection, result, circuit.protectionDevice, circuitCategory]); + + function handleLayingMethodChange(method: LayingMethod) { + setLayingMethod(method); + const options = temperatureOptions(method); + const reference = LAYING_METHOD_GROUP[method] === "air" ? 30 : 20; + setAmbientTemperatureC(options.includes(reference) ? reference : options[0]); + setResult(null); + setCalculationId(null); + } + + async function handleCalculate() { + setCalculating(true); + setError(null); + try { + const response = await calculateCableSizing(input, { + projectId, + circuitId: circuit.id, + equipmentIdentifier: circuit.equipmentIdentifier, + }); + setResult(response.result); + setAlerts(response.alerts); + setCalculationId(response.calculationId); + // Calculating fills the manual field as a suggestion, but that field + // stays the single source of truth for what gets applied - the user + // can still edit it by hand before submitting. + if (response.result.dataVerified && response.result.recommendedCrossSectionMm2) { + setManualCrossSection(`${response.result.recommendedCrossSectionMm2} mm²`); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen"); + } finally { + setCalculating(false); + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!manualCrossSection.trim()) return; + await onApply({ + cableCrossSection: manualCrossSection.trim(), + cableType: manualCableType.trim() || undefined, + cableLength: lengthM, + }); + } + + return ( + +
+
+ + setLengthM(Number(event.target.value))} + type="number" + value={lengthM} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {phase === 3 && ( +
+ + +
+ )} +
+ + setCosPhi(Number(event.target.value))} + step={0.05} + type="range" + value={cosPhi} + /> + {cosPhi.toFixed(2)} +
+
+ + setMaxVoltageDropPercent(Number(event.target.value))} + step={0.5} + type="range" + value={maxVoltageDropPercent} + /> + {maxVoltageDropPercent.toFixed(1)} % +
+
+ + + {error &&

{error}

} + + {result && !result.dataVerified && ( +
+ Für Verlegeart {layingMethod} mit Isolierstoff {insulation.toUpperCase()} liegen keine + geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich. Bitte + eine andere Verlegeart/Isolierstoff-Kombination wählen. +
+ )} + + {result && result.dataVerified && ( +
+
+
+
Betriebsstrom Ib
+
{result.operatingCurrentA.toFixed(1)} A
+
+
+
+ Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""} +
+
+ {result.designCurrentA.toFixed(1)} A +
+
+
+
Empfohlener Querschnitt
+
+ + {result.recommendedCrossSectionMm2 + ? `${result.recommendedCrossSectionMm2} mm²` + : "-"} + +
+
+
+
ΔU bei Empfehlung
+
+ {result.voltageDropAtRecommendedPercent != null + ? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %` + : "-"} +
+
+
+
Korrekturfaktor
+
{result.combinedDerationFactor.toFixed(2)}
+
+
+
Max. Länge (ΔU-Grenze)
+
+ {result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null + ? `${result.rows + .find((row) => row.recommended)! + .maxLengthForVoltageDropM!.toFixed(0)} m` + : "-"} +
+
+
+ {alerts.map((alert, index) => ( +
+ {alert.text} +
+ ))} + {circuit.protectionDevice && ( +

+ * Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung + ({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem + eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur + für die tatsächliche Last. +

+ )} +
+ )} + +
+

+ Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in + dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen. +

+
+
+ + setManualCableType(event.target.value)} + placeholder="z.B. NYM-J 3x1.5" + value={manualCableType} + /> +
+
+ + setManualCrossSection(event.target.value)} + placeholder="z.B. 4 mm²" + value={manualCrossSection} + /> +
+ {manualCrossSectionWarning && ( +
+
{manualCrossSectionWarning}
+
+ )} +
+
+ ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..d113b3b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -137,6 +137,7 @@ import type { import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; +import { CableSizingModal } from "./cable-sizing-modal"; import { circuitGroupCategoryLabels, type CircuitGroupCategory, @@ -278,6 +279,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str useState(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); + const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -1398,6 +1401,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + async function handleApplyCableSizing(patch: { + cableCrossSection: string; + cableType?: string; + cableLength?: number; + }) { + const circuit = cableSizingEditorCircuit; + if (!circuit) { + return; + } + await runCommand({ + label: "Kabel dimensionieren", + redo: async () => { + const result = await updateCircuitById( + projectId, + getExpectedProjectRevision(), + circuit.id, + patch + ); + applyProjectCommandResult(result); + setCableSizingEditorCircuit(null); + return null; + }, + }); + } + async function handleRedo() { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; @@ -3139,6 +3167,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitProtection} /> ) : null} + {cableSizingEditorCircuit ? ( + section.id === cableSizingEditorCircuit.sectionId + )?.category + } + isSaving={isSaving} + projectId={projectId} + onClose={() => setCableSizingEditorCircuit(null)} + onApply={handleApplyCableSizing} + /> + ) : null}