Compare commits

..

4 commits

Author SHA1 Message Date
204499d7e4 Add Revit Dynamo diagnostic scripts 2026-08-23 23:13:35 +02:00
906aa751c7 Run the Docker stack on built output instead of dev servers
compose.yaml overrode the image's own entrypoint to start "tsx watch" and
"next dev" with CHOKIDAR_USEPOLLING and WATCHPACK_POLLING enabled. The
built dist/ and .next from the image were never used, and the polling
watchers kept both containers busy around the clock whether or not
anyone used the application.

Split the two use cases: compose.yaml now runs node dist/server/index.js
and next start with no source mounts and no watchers, and the watching
setup moves to compose.dev.yaml.

next build writes the rewrite destinations from next.config.mjs into
.next/routes-manifest.json, so next start cannot pick up API_INTERNAL_URL
at runtime the way next dev does. Pass it as a build argument.

Give the web container its own probe. The healthcheck hit "/", which
redirects to "/projects", and fetch follows redirects, so every probe
rendered two pages every five seconds. "/health" could not serve as the
probe because next.config.mjs rewrites it to the API. Add "/web-health"
as a route handler, exclude it from the proxy matcher, and relax both
intervals to 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:25:06 +02:00
c6cdfc42d5 Keep manual quantity consistent when synchronizing device rows
Synchronizing a project device pushes its quantity onto every linked
device row, but manualQuantity is not part of the sync snapshot and was
left untouched. A device whose quantity is lower than the row's left the
row at manualQuantity > quantity, violating the snapshot invariant.

The violation surfaced far from its cause: the invariant is only checked
when a full state snapshot is read, and the automatic snapshot runs every
25 revisions. A project could therefore accumulate the broken row
silently and then reject every subsequent command, because the failing
validation rolls back the whole transaction including the revision bump.

Recompute manualQuantity as the synchronized quantity minus the total of
the linked external objects, matching the invariant that
assertCircuitDeviceRowQuantity enforces elsewhere, and reject a
synchronized quantity that falls below that total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
9504905c8f Upgrade project to Node 24
Node 22 leaves active LTS at the end of October. Move the Docker base
image and the documented local requirement to Node 24.

Pull @types/node from ^25 down to ^24 so the type definitions no longer
describe a newer runtime than the one in use. Types ahead of the runtime
let TypeScript accept APIs that are not there at execution time; the
opposite direction is harmless.

Add an engines field to record the supported version. No .nvmrc, since
the project is not used with a Node version manager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
34 changed files with 1389 additions and 4700 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ dist/
data/*.db
data/backups/*.db
.codex/*.log
dynamo/output/

View file

@ -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

View file

@ -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

87
compose.dev.yaml Normal file
View file

@ -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

View file

@ -1,88 +1,73 @@
name: leistungsbilanz
services:
api:
build:
x-build: &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:-info}"
init: true
restart: unless-stopped
logging:
args:
# Baked into .next/routes-manifest.json by next build; see Dockerfile.
API_INTERNAL_URL: http://api:3000
x-logging: &logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
services:
api:
build: *build
command:
- sh
- -c
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
environment:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
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

View file

@ -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 `<CableSizingModal />` alongside the
existing `<CircuitProtectionModal />`. 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).

View file

@ -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

View file

@ -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(),
}

View file

@ -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(),
}

69
dynamo/README.md Normal file
View file

@ -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.

View file

@ -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: {

23
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;
}

View file

@ -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 });
}

View file

@ -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<LayingMethod, "air" | "ground"> = {
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<LayingMethod, boolean> = {
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<LayingMethod, string> = {
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<InsulationMaterial, string> = {
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<HarmonicNeutralLoad, string> = {
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<LayingMethod, Record<CoreCount, (number | null)[]>>
> = {
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<number, number> = {
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<number, number> = {
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<number, number> = {
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<number, number> = {
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<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const;
export const GROUPING_LABELS: Record<number, string> = {
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<Record<CircuitCategory, number>> = {
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;
}

View file

@ -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<typeof cableSizingRequestSchema>;
// Structural check that the request schema stays a superset of the domain
// input shape (minus context, which is API-only metadata).
type _AssertAssignable<T extends CableSizingInput> = T;
type _Check = _AssertAssignable<Omit<CableSizingRequest, "context">>;

View file

@ -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`);

File diff suppressed because it is too large Load diff

View file

@ -50,13 +50,6 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1786114831610,
"tag": "0007_watery_kingpin",
"breakpoints": true
}
]
}

View file

@ -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();
}
}

View file

@ -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) {

View file

@ -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;

View file

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
export interface CalculateCableSizingResponse {
calculationId: string;
result: CableSizingResult;
alerts: CableSizingAlert[];
}
export function calculateCableSizing(
input: CableSizingInput,
context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string }
): Promise<CalculateCableSizingResponse> {
return request("/api/cable-sizing/calculate", {
method: "POST",
body: JSON.stringify({ ...input, context }),
});
}
export function markCableSizingCalculationApplied(calculationId: string): Promise<unknown> {
return request(`/api/cable-sizing/calculations/${calculationId}/applied`, {
method: "POST",
});
}
export interface LayingMethodOption {
method: LayingMethod;
label: string;
dataVerified: boolean;
}
export function listLayingMethods(): Promise<LayingMethodOption[]> {
return request("/api/cable-sizing/laying-methods");
}
export interface InsulationMaterialOption {
insulation: InsulationMaterial;
label: string;
}
export function listInsulationMaterials(): Promise<InsulationMaterialOption[]> {
return request("/api/cable-sizing/insulation-materials");
}

View file

@ -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<void>;
}
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<LayingMethod>("C");
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("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<HarmonicNeutralLoad>("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<string | null>(null);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
event.preventDefault();
if (!manualCrossSection.trim()) return;
await onApply({
cableCrossSection: manualCrossSection.trim(),
cableType: manualCableType.trim() || undefined,
cableLength: lengthM,
});
}
return (
<FormModal
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4, oder Kabeltyp/Querschnitt direkt manuell eintragen. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-length">
Leitungslänge (m)
</label>
<input
className="form-control"
id="cable-sizing-length"
min={0}
onChange={(event) => setLengthM(Number(event.target.value))}
type="number"
value={lengthM}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
Phasen
</label>
<select
className="form-select"
id="cable-sizing-phase"
onChange={(event) => setPhase(Number(event.target.value) as 1 | 3)}
value={phase}
>
<option value={1}>1~ ({circuit.voltage ?? 230} V)</option>
<option value={3}>3~ ({circuit.voltage ?? 400} V)</option>
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Leistung (aus Stromkreis)</label>
<input
className="form-control"
disabled
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart (DIN VDE 0298-4)
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => handleLayingMethodChange(event.target.value as LayingMethod)}
value={layingMethod}
>
{LAYING_METHODS.map((method) => (
<option key={method} value={method}>
{LAYING_METHOD_LABELS[method]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-insulation">
Isolierstoff
</label>
<select
className="form-select"
id="cable-sizing-insulation"
onChange={(event) => {
setInsulation(event.target.value as InsulationMaterial);
setResult(null);
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-material">
Leitermaterial
</label>
<select
className="form-select"
id="cable-sizing-material"
onChange={(event) => setConductorMaterial(event.target.value as ConductorMaterial)}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-temperature">
{LAYING_METHOD_GROUP[layingMethod] === "air"
? "Umgebungstemperatur"
: "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => setAmbientTemperatureC(Number(event.target.value))}
value={ambientTemperatureC}
>
{temperatureOptions(layingMethod).map((temp) => (
<option key={temp} value={temp}>
{temp}°C
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise nebeneinander)
</label>
<select
className="form-select"
id="cable-sizing-grouping"
onChange={(event) => setGroupingCircuits(Number(event.target.value))}
value={groupingCircuits}
>
{GROUPING_OPTIONS.map((grouping) => (
<option key={grouping} value={grouping}>
{GROUPING_LABELS[grouping]}
</option>
))}
</select>
</div>
{phase === 3 && (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungsanteil Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) =>
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad)
}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
)}
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-range"
id="cable-sizing-cosphi"
max={1}
min={0.6}
onChange={(event) => setCosPhi(Number(event.target.value))}
step={0.05}
type="range"
value={cosPhi}
/>
<span>{cosPhi.toFixed(2)}</span>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. zulässiger Spannungsfall
</label>
<input
className="form-range"
id="cable-sizing-voltage-drop"
max={5}
min={1}
onChange={(event) => setMaxVoltageDropPercent(Number(event.target.value))}
step={0.5}
type="range"
value={maxVoltageDropPercent}
/>
<span>{maxVoltageDropPercent.toFixed(1)} %</span>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet…" : "Berechnen"}
</button>
{error && <p className="text-danger mt-2">{error}</p>}
{result && !result.dataVerified && (
<div className="alert alert-danger mt-3">
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.
</div>
)}
{result && result.dataVerified && (
<div className="mt-3">
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom Ib</div>
<div>{result.operatingCurrentA.toFixed(1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
</div>
<div>
<strong>{result.designCurrentA.toFixed(1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2
? `${result.recommendedCrossSectionMm2} mm²`
: "-"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %`
: "-"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor</div>
<div>{result.combinedDerationFactor.toFixed(2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge (ΔU-Grenze)</div>
<div>
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null
? `${result.rows
.find((row) => row.recommended)!
.maxLengthForVoltageDropM!.toFixed(0)} m`
: "-"}
</div>
</div>
</div>
{alerts.map((alert, index) => (
<div
className={`alert mt-2 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
{circuit.protectionDevice && (
<p className="text-muted small mt-2 mb-0">
* 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.
</p>
)}
</div>
)}
<hr className="my-3" />
<p className="text-muted small mb-2">
Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in
dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.
</p>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-type">
Kabeltyp
</label>
<input
className="form-control"
id="cable-sizing-manual-type"
onChange={(event) => setManualCableType(event.target.value)}
placeholder="z.B. NYM-J 3x1.5"
value={manualCableType}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
Querschnitt
</label>
<input
className="form-control"
id="cable-sizing-manual-cross-section"
onChange={(event) => setManualCrossSection(event.target.value)}
placeholder="z.B. 4 mm²"
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning && (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
)}
</div>
</FormModal>
);
}

View file

@ -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<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
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 ? (
<CableSizingModal
circuit={cableSizingEditorCircuit}
circuitCategory={
data.sections.find(
(section) => section.id === cableSizingEditorCircuit.sectionId
)?.category
}
isSaving={isSaving}
projectId={projectId}
onClose={() => setCableSizingEditorCircuit(null)}
onApply={handleApplyCableSizing}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
@ -4282,15 +4240,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.circuit &&
row.rowType !== "deviceRow"
);
const isCableSizingTrigger = Boolean(
(column.key === "cableSummary" || column.key === "cableCrossSection") &&
row.circuit &&
row.rowType !== "deviceRow"
);
return (
<td
key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isCableSizingTrigger ? "cell-cable-sizing-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
: ""
@ -4310,8 +4263,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
title={
isProtectionTrigger
? "Schutzgerät bearbeiten"
: isCableSizingTrigger
? "Kabel dimensionieren"
: column.key === "equipmentIdentifier" &&
row.circuit &&
(row.rowType === "circuitCompact" ||
@ -4391,10 +4342,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setProtectionEditorCircuit(row.circuit!);
return;
}
if (isCableSizingTrigger) {
setCableSizingEditorCircuit(row.circuit!);
return;
}
if (cell.editable) {
handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey,

View file

@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent
// header; skip it so real navigation isn't drowned out in the logs.
// Probes hit "/web-health" and are excluded by the matcher below. The
// User-Agent guard stays as a fallback for anything else that polls
// without one, so real navigation isn't drowned out in the logs.
if (request.headers.get("user-agent")) {
logger.info("page request", {
method: request.method,
@ -17,5 +18,7 @@ export function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
],
};

View file

@ -13,7 +13,6 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { CableSizingCalculationRepository } from "../../db/repositories/cable-sizing-calculation.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -34,4 +33,3 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);

View file

@ -1,99 +0,0 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
import {
calculateCableSizing,
buildCableSizingAlerts,
LAYING_METHODS,
LAYING_METHOD_LABELS,
LAYING_METHOD_VERIFIED,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
import { cableSizingCalculationRepository } from "../composition/application-repositories.js";
// Stateless calculation, not a project command: no expectedRevision, no
// circuit mutation here. The caller applies the result via the existing
// circuit.update command (POST /api/projects/:projectId/commands) if the
// user confirms it. See docs/cable-sizing-module.md.
export async function calculateCableSizingHandler(req: Request, res: Response) {
const parsed = cableSizingRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { context, ...input } = parsed.data;
const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result);
let entry;
try {
entry = await cableSizingCalculationRepository.create({
id: randomUUID(),
projectId: context?.projectId ?? null,
circuitId: context?.circuitId ?? null,
equipmentIdentifier: context?.equipmentIdentifier ?? null,
input,
result,
appliedToCircuit: 0,
});
} catch (error) {
// context.circuitId/projectId are caller-supplied and only used for the
// audit-log entry, not the calculation itself - a stale or unknown id
// (e.g. a circuit deleted between page load and this request) should be
// a normal 400, not a raw 500 from the foreign-key constraint.
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: string }).code === "SQLITE_CONSTRAINT_FOREIGNKEY"
) {
return res
.status(400)
.json({ error: "Unknown context.projectId or context.circuitId" });
}
throw error;
}
return res.status(201).json({ calculationId: entry.id, result, alerts });
}
export async function listLayingMethodsHandler(_req: Request, res: Response) {
return res.json(
LAYING_METHODS.map((method) => ({
method,
label: LAYING_METHOD_LABELS[method],
dataVerified: LAYING_METHOD_VERIFIED[method],
}))
);
}
export async function listInsulationMaterialsHandler(_req: Request, res: Response) {
return res.json(
INSULATION_MATERIALS.map((insulation) => ({
insulation,
label: INSULATION_MATERIAL_LABELS[insulation],
}))
);
}
export async function markCalculationAppliedHandler(req: Request, res: Response) {
const { calculationId } = req.params;
if (typeof calculationId !== "string") {
return res.status(400).json({ error: "Invalid calculationId" });
}
const updated = await cableSizingCalculationRepository.markApplied(calculationId);
if (!updated) {
return res.status(404).json({ error: "Calculation not found" });
}
return res.json(updated);
}
export async function listCalculationsForCircuitHandler(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const rows = await cableSizingCalculationRepository.listByCircuit(circuitId);
return res.json(rows);
}

View file

@ -1,7 +1,6 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -50,7 +49,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/cable-sizing", cableSizingRouter);
app.use(errorMiddleware);

View file

@ -1,19 +0,0 @@
import express from "express";
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
export const cableSizingRouter = express.Router();
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
cableSizingRouter.get(
"/insulation-materials",
cableSizingController.listInsulationMaterialsHandler
);
cableSizingRouter.post(
"/calculations/:calculationId/applied",
cableSizingController.markCalculationAppliedHandler
);
cableSizingRouter.get(
"/circuits/:circuitId/calculations",
cableSizingController.listCalculationsForCircuitHandler
);

View file

@ -1,152 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
import { CableSizingCalculationRepository } from "../src/db/repositories/cable-sizing-calculation.repository.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projects } from "../src/db/schema/projects.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
function createRepository() {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
return new CableSizingCalculationRepository(context.db);
}
function createRepositoryWithCircuits(): {
repository: CableSizingCalculationRepository;
context: DatabaseContext;
circuitOneId: string;
circuitTwoId: string;
} {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
const board = new DistributionBoardFixtureRepository(context.db).createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const circuitList = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.distributionBoardId, board.id))
.get();
if (!circuitList) {
throw new Error("fixture did not create a circuit list");
}
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitList.id))
.get();
if (!section) {
throw new Error("fixture did not create a section");
}
const circuitOneId = "circuit-1";
const circuitTwoId = "circuit-2";
context.db
.insert(circuits)
.values([
{
id: circuitOneId,
circuitListId: section.circuitListId,
sectionId: section.id,
equipmentIdentifier: "-1F1.1",
},
{
id: circuitTwoId,
circuitListId: section.circuitListId,
sectionId: section.id,
equipmentIdentifier: "-1F1.2",
},
])
.run();
return {
repository: new CableSizingCalculationRepository(context.db),
context,
circuitOneId,
circuitTwoId,
};
}
describe("CableSizingCalculationRepository", () => {
it("creates an entry with input/result JSON and defaults appliedToCircuit to false", () => {
const repository = createRepository();
const entry = repository.create({
id: "calc-1",
projectId: null,
circuitId: null,
equipmentIdentifier: "-1F1.1",
input: { layingMethod: "C" },
result: { recommendedCrossSectionMm2: 4 },
appliedToCircuit: 0,
});
assert.equal(entry.id, "calc-1");
assert.equal(entry.appliedToCircuit, 0);
assert.deepEqual(entry.input, { layingMethod: "C" });
assert.deepEqual(entry.result, { recommendedCrossSectionMm2: 4 });
});
it("lists entries by circuitId, most recent first", () => {
// createdAt defaults to SQLite's unixepoch() (second resolution), so two
// inserts in the same test can tie - pass explicit, distinct timestamps
// instead of racing the clock. circuitId has a real foreign key into
// circuits (foreign_keys = ON), so this needs actual circuit rows, not
// arbitrary strings.
const { repository, circuitOneId, circuitTwoId } = createRepositoryWithCircuits();
repository.create({
id: "calc-a",
projectId: null,
circuitId: circuitOneId,
equipmentIdentifier: null,
input: {},
result: {},
appliedToCircuit: 0,
createdAt: new Date(1000),
});
repository.create({
id: "calc-b",
projectId: null,
circuitId: circuitOneId,
equipmentIdentifier: null,
input: {},
result: {},
appliedToCircuit: 0,
createdAt: new Date(2000),
});
repository.create({
id: "calc-other-circuit",
projectId: null,
circuitId: circuitTwoId,
equipmentIdentifier: null,
input: {},
result: {},
appliedToCircuit: 0,
createdAt: new Date(3000),
});
const rows = repository.listByCircuit(circuitOneId);
assert.equal(rows.length, 2);
assert.equal(rows[0].id, "calc-b");
assert.equal(rows[1].id, "calc-a");
});
it("marks an entry as applied", () => {
const repository = createRepository();
repository.create({
id: "calc-1",
projectId: null,
circuitId: null,
equipmentIdentifier: null,
input: {},
result: {},
appliedToCircuit: 0,
});
const updated = repository.markApplied("calc-1");
assert.equal(updated?.appliedToCircuit, 1);
});
});

View file

@ -1,209 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCableSizingAlerts,
calculateCableSizing,
isCableSizingDataVerified,
type CableSizingInput,
} from "../src/cable-sizing/domain/cable-sizing-calculation.js";
const BASE_INPUT: CableSizingInput = {
phase: 1,
mode: "power",
powerKw: 5,
cosPhi: 1,
voltage: 230,
lengthM: 30,
layingMethod: "C",
conductorMaterial: "copper",
insulation: "pvc",
ambientTemperatureC: 30,
groupingCircuits: 1,
maxVoltageDropPercent: 3,
harmonicNeutralLoad: "none",
};
describe("calculateCableSizing", () => {
it("matches the verified reference case (1~, 5 kW, 230 V, 30 m, method C, copper)", () => {
const result = calculateCableSizing(BASE_INPUT);
assert.equal(result.dataVerified, true);
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
assert.equal(result.crossSectionByCapacityMm2, 2.5);
assert.equal(result.crossSectionByVoltageDropMm2, 4);
assert.equal(result.recommendedCrossSectionMm2, 4);
});
it("returns dataVerified: false and no numeric result for an unverified laying method", () => {
const result = calculateCableSizing({ ...BASE_INPUT, layingMethod: "A2" });
assert.equal(result.dataVerified, false);
assert.equal(result.recommendedCrossSectionMm2, null);
assert.equal(result.rows.length, 0);
// The operating current itself does not depend on the capacity table
// and is still reported so the UI can show at least that much.
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
});
it("returns dataVerified: false for xlpe regardless of method", () => {
const result = calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe" });
assert.equal(result.dataVerified, false);
});
it("isCableSizingDataVerified matches the six ported, verified methods only", () => {
for (const method of ["A1", "B2", "C", "E", "D1", "D2"] as const) {
assert.equal(isCableSizingDataVerified(method, "pvc"), true, method);
}
for (const method of ["A2", "B1", "F", "G"] as const) {
assert.equal(isCableSizingDataVerified(method, "pvc"), false, method);
}
});
it("applies the 0.86 harmonic reduction factor only for three-phase + 15to33Percent", () => {
const threePhase: CableSizingInput = {
...BASE_INPUT,
phase: 3,
mode: "current",
currentA: 10,
powerKw: undefined,
voltage: 400,
};
const base = calculateCableSizing({ ...threePhase, harmonicNeutralLoad: "none" });
const derated = calculateCableSizing({
...threePhase,
harmonicNeutralLoad: "15to33Percent",
});
assert.equal(base.harmonicReductionApplied, false);
assert.equal(derated.harmonicReductionApplied, true);
assert.ok(
Math.abs(derated.combinedDerationFactor - base.combinedDerationFactor * 0.86) < 1e-9
);
const singlePhaseWithHarmonics = calculateCableSizing({
...BASE_INPUT,
harmonicNeutralLoad: "15to33Percent",
});
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
});
it("uses max(operatingCurrentA, existingProtectionRatedCurrentA) as the design current for cross-section selection", () => {
// Load alone (21.7 A) would recommend 4 mm² (see the reference case
// above); a 32 A breaker on the same circuit must still be covered by
// the cable (In <= Iz), so the recommendation should grow accordingly.
const withoutBreaker = calculateCableSizing(BASE_INPUT);
const withBreaker = calculateCableSizing({
...BASE_INPUT,
existingProtectionRatedCurrentA: 32,
});
assert.equal(withoutBreaker.designCurrentA, withoutBreaker.operatingCurrentA);
assert.equal(withBreaker.designCurrentA, 32);
assert.ok(
(withBreaker.recommendedCrossSectionMm2 ?? 0) >=
(withoutBreaker.recommendedCrossSectionMm2 ?? 0)
);
assert.ok(withBreaker.protectionCoordination?.coordinated);
});
it("reports an oversized breaker as the limiting factor when no cross-section can cover it", () => {
const result = calculateCableSizing({
...BASE_INPUT,
existingProtectionRatedCurrentA: 1000,
});
assert.equal(result.designCurrentA, 1000);
assert.equal(result.recommendedCrossSectionMm2, null);
// No cross-section satisfies the design current at all, so there is no
// "recommended but under-protected" case to flag - protectionCoordination
// is only meaningful once a recommendation exists.
assert.equal(result.protectionCoordination, null);
const alerts = buildCableSizingAlerts(
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
result
);
assert.equal(alerts.length, 1);
assert.equal(alerts[0].kind, "critical");
assert.ok(alerts[0].text.includes("Vorhandene Sicherung"));
});
});
describe("buildCableSizingAlerts", () => {
it("returns a single critical alert for an unverified combination, no numeric claims", () => {
const input: CableSizingInput = { ...BASE_INPUT, layingMethod: "G" };
const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result);
assert.equal(alerts.length, 1);
assert.equal(alerts[0].kind, "critical");
assert.ok(alerts[0].text.includes("keine geprüften"));
});
it("returns an ok alert plus a max-length info alert for a clean, unremarkable case", () => {
const input: CableSizingInput = {
...BASE_INPUT,
mode: "current",
currentA: 15,
powerKw: undefined,
lengthM: 3,
};
const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result);
assert.equal(alerts.length, 2);
assert.equal(alerts[0].kind, "ok");
assert.equal(alerts[1].kind, "info");
assert.ok(alerts[1].text.includes("Maximale Länge"));
});
});
describe("maxLengthForVoltageDropM", () => {
it("is the inverse of the voltage-drop formula: recalculating at that length gives back the limit", () => {
const result = calculateCableSizing(BASE_INPUT);
const recommendedRow = result.rows.find((row) => row.recommended);
assert.ok(recommendedRow?.maxLengthForVoltageDropM != null);
const atMaxLength = calculateCableSizing({
...BASE_INPUT,
lengthM: recommendedRow!.maxLengthForVoltageDropM!,
});
const rowAtSameCrossSection = atMaxLength.rows.find(
(row) => row.crossSectionMm2 === recommendedRow!.crossSectionMm2
);
assert.ok(
Math.abs(rowAtSameCrossSection!.voltageDropPercent! - BASE_INPUT.maxVoltageDropPercent) <
0.01
);
});
it("is null when there is no current flowing (division by zero guard)", () => {
const result = calculateCableSizing({ ...BASE_INPUT, mode: "current", currentA: 0, powerKw: undefined });
assert.ok(result.rows.every((row) => row.maxLengthForVoltageDropM === null));
});
});
describe("practical minimum cross-section for single_phase circuits", () => {
it("raises a smaller calculated recommendation to 2.5 mm² for single_phase circuits", () => {
// 1 A load at 30 m would normally recommend 1.5 mm² by calculation alone.
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
const withoutCategory = calculateCableSizing(smallLoad);
const withCategory = calculateCableSizing({ ...smallLoad, circuitCategory: "single_phase" });
assert.equal(withoutCategory.recommendedCrossSectionMm2, 1.5);
assert.equal(withoutCategory.practicalMinimumApplied, false);
assert.equal(withCategory.recommendedCrossSectionMm2, 2.5);
assert.equal(withCategory.practicalMinimumApplied, true);
});
it("never lowers a recommendation that already needs more than the practical minimum", () => {
const result = calculateCableSizing({ ...BASE_INPUT, circuitCategory: "single_phase" });
assert.equal(result.recommendedCrossSectionMm2, 4);
assert.equal(result.practicalMinimumApplied, false);
});
it("does not apply to lighting or three_phase categories", () => {
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
assert.equal(
calculateCableSizing({ ...smallLoad, circuitCategory: "lighting" }).recommendedCrossSectionMm2,
1.5
);
assert.equal(
calculateCableSizing({ ...smallLoad, circuitCategory: "three_phase" }).recommendedCrossSectionMm2,
1.5
);
});
});

View file

@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
return row;
}
function linkExternalObject(
context: DatabaseContext,
rowId: string,
effectiveQuantity: number
) {
context.db
.insert(externalModelSources)
.values({
id: "source-1",
projectId: "project-1",
name: "Revit",
sourceType: "revit_csv",
})
.run();
context.db
.insert(externalImportBatches)
.values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
})
.run();
context.db
.insert(externalModelObjects)
.values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: "ifc-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: "101",
roomName: "Büro",
familyAndType: "Leuchte: Standard",
selectionMarker: "Leuchte",
circuitIdentifier: "-1F1",
power: "30",
quantity: String(effectiveQuantity),
additionalSourceValues: {},
},
planningValues: {
displayName: "Leuchte",
internalDeviceType: "luminaire",
category: "single_phase",
connectionKind: "fixed",
effectiveQuantity,
powerPerUnitW: 30,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: null,
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: rowId,
presenceStatus: "present",
})
.run();
}
function snapshot(
context: DatabaseContext,
rowId: string
@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => {
}
});
it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 5 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected,
target: { ...expected, quantity: 2 },
},
]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
} finally {
fixture.context.close();
}
});
it("subtracts linked external objects when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 4);
assert.equal(row.manualQuantity, 1);
} finally {
fixture.context.close();
}
});
it("rejects a synced quantity below the linked external total", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }]
),
}),
/below the total quantity of linked external objects/
);
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 5);
assert.equal(row.manualQuantity, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase();
try {