diff --git a/.gitignore b/.gitignore index 8d44fb4..ed5ffe6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ data/*.db data/backups/*.db .codex/*.log +dynamo/output/ diff --git a/Dockerfile b/Dockerfile index 41a08fd..31d28c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,17 @@ -FROM node:22 +FROM node:24 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 c333917..fc82fca 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,29 @@ docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher. 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 22 +- Node.js 24 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..86e42e2 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,87 @@ +# 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 071f0bb..503a4b2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,88 +1,73 @@ 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: - context: . + build: *build command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" - CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging ports: - - "3221:3000" + - "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: 5s + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s web: - build: - context: . + build: *build command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 + - node_modules/.bin/next + - start + - -p + - "3001" 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: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging depends_on: api: condition: service_healthy ports: - - "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 + - "3001:3001" healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s timeout: 3s - retries: 12 + retries: 5 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 deleted file mode 100644 index 80b7cc8..0000000 --- a/docs/cable-sizing-module.md +++ /dev/null @@ -1,243 +0,0 @@ -# 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 dfbb3f1..06289a7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,19 +2,27 @@ ## Aktueller Status -Es gibt derzeit kein unterstütztes Produktionsdeployment. +Es gibt zwei Compose-Stacks. -`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.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. -## Entwicklungs-Topologie +`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 | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /` | keine | +| Next.js Web | 3001 | `GET /web-health` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -24,15 +32,22 @@ 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` für lokale - Dateibeobachtung in Docker +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg - `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 `npm run db:migrate` und -`npm run db:verify:circuit-schema`. +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. ## Logging diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py new file mode 100644 index 0000000..a3fe009 --- /dev/null +++ b/dynamo/01_check_model_identity.py @@ -0,0 +1,385 @@ +"""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 new file mode 100644 index 0000000..440a222 --- /dev/null +++ b/dynamo/02_export_electrical_fixture_parameter_inventory.py @@ -0,0 +1,517 @@ +"""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 new file mode 100644 index 0000000..800eded --- /dev/null +++ b/dynamo/README.md @@ -0,0 +1,69 @@ +# 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 486b23f..e0610b9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,8 +5,7 @@ const nextConfig = { allowedDevOrigins: [ "192.168.3.13", "docker01.int.jappel.io", - "lb.jappel.io", - "192.168.0.133" + "lb.jappel.io" ], typescript: { diff --git a/package-lock.json b/package-lock.json index b0abb41..417421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@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": { @@ -1514,12 +1517,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -3688,10 +3692,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true + "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" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 9bc5ae3..414eb53 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "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", @@ -10,6 +13,9 @@ "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", @@ -40,7 +46,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@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 88e1c35..22da5df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1209,48 +1209,6 @@ 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 new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -0,0 +1,10 @@ +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 deleted file mode 100644 index 3f228f4..0000000 --- a/src/cable-sizing/domain/cable-sizing-calculation.ts +++ /dev/null @@ -1,547 +0,0 @@ -// 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 deleted file mode 100644 index 5648d8d..0000000 --- a/src/cable-sizing/domain/cable-sizing-contracts.ts +++ /dev/null @@ -1,49 +0,0 @@ -// 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 deleted file mode 100644 index a4f22bf..0000000 --- a/src/db/migrations/0007_watery_kingpin.sql +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 8a67832..0000000 --- a/src/db/migrations/meta/0007_snapshot.json +++ /dev/null @@ -1,2522 +0,0 @@ -{ - "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 0b056a8..eedfc7a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,13 +50,6 @@ "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 deleted file mode 100644 index 803965b..0000000 --- a/src/db/repositories/cable-sizing-calculation.repository.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 da17458..eb722d7 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,6 +14,7 @@ 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"; @@ -119,9 +120,32 @@ 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(assignment.target) + .set(values) .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 deleted file mode 100644 index 511fab3..0000000 --- a/src/db/schema/cable-sizing-calculations.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index ee52aef..0000000 --- a/src/frontend/components/cable-sizing-api.ts +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index 36b204c..0000000 --- a/src/frontend/components/cable-sizing-modal.tsx +++ /dev/null @@ -1,523 +0,0 @@ -"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 d113b3b..03aa38d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -137,7 +137,6 @@ 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, @@ -279,8 +278,6 @@ 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); @@ -1401,31 +1398,6 @@ 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; @@ -3167,20 +3139,6 @@ 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}