forked from jappel/leistungsbilanz-ts
Compare commits
4 commits
feature/ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 204499d7e4 | |||
| 906aa751c7 | |||
| c6cdfc42d5 | |||
| 9504905c8f |
34 changed files with 1389 additions and 4700 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ dist/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
dynamo/output/
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
FROM node:22
|
FROM node:24
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
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 npm run build:api && npm run build:web
|
||||||
|
|
||||||
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -62,15 +62,29 @@ docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
||||||
Produktionsdeployment. Details stehen in
|
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
[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
|
## Direkte lokale Entwicklung
|
||||||
|
|
||||||
Voraussetzungen:
|
Voraussetzungen:
|
||||||
|
|
||||||
- Node.js 22
|
- Node.js 24
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
87
compose.dev.yaml
Normal file
87
compose.dev.yaml
Normal 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
|
||||||
75
compose.yaml
75
compose.yaml
|
|
@ -1,88 +1,73 @@
|
||||||
name: leistungsbilanz
|
name: leistungsbilanz
|
||||||
|
|
||||||
|
x-build: &build
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
# Baked into .next/routes-manifest.json by next build; see Dockerfile.
|
||||||
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
|
||||||
|
x-logging: &logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
build:
|
build: *build
|
||||||
context: .
|
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging: *logging
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
ports:
|
ports:
|
||||||
- "3221:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
|
||||||
- ./scripts:/app/scripts
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build: *build
|
||||||
context: .
|
|
||||||
command:
|
command:
|
||||||
- npm
|
- node_modules/.bin/next
|
||||||
- run
|
- start
|
||||||
- dev:web
|
- -p
|
||||||
- --
|
- "3001"
|
||||||
- --hostname
|
|
||||||
- 0.0.0.0
|
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
WATCHPACK_POLLING: "true"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging: *logging
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3220:3001"
|
- "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:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.16.61.0/24
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -2,19 +2,27 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## 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
|
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
||||||
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
||||||
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
für einen Server.
|
||||||
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
|
||||||
öffentlich erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## 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 |
|
| 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` |
|
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
||||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
| 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
|
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||||
`http://api:3000`
|
`http://api:3000`
|
||||||
- `NEXT_TELEMETRY_DISABLED=1`
|
- `NEXT_TELEMETRY_DISABLED=1`
|
||||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
||||||
Dateibeobachtung in Docker
|
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
||||||
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
|
||||||
Beim API-Start laufen zuerst `npm run db:migrate` und
|
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
||||||
`npm run db:verify:circuit-schema`.
|
(`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
|
## Logging
|
||||||
|
|
||||||
|
|
|
||||||
385
dynamo/01_check_model_identity.py
Normal file
385
dynamo/01_check_model_identity.py
Normal 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(),
|
||||||
|
}
|
||||||
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal file
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal 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
69
dynamo/README.md
Normal 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.
|
||||||
|
|
@ -5,8 +5,7 @@ const nextConfig = {
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"192.168.3.13",
|
"192.168.3.13",
|
||||||
"docker01.int.jappel.io",
|
"docker01.int.jappel.io",
|
||||||
"lb.jappel.io",
|
"lb.jappel.io"
|
||||||
"192.168.0.133"
|
|
||||||
],
|
],
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|
|
||||||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -22,12 +22,15 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
|
|
@ -1514,12 +1517,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
|
|
@ -3688,10 +3692,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:api",
|
"dev": "npm run dev:api",
|
||||||
"dev:api": "tsx watch src/server/index.ts",
|
"dev:api": "tsx watch src/server/index.ts",
|
||||||
|
|
@ -10,6 +13,9 @@
|
||||||
"docker:up": "docker compose up --build --detach",
|
"docker:up": "docker compose up --build --detach",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs --follow",
|
"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": "npm run build:api",
|
||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
|
|
@ -40,7 +46,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
|
|
||||||
|
|
@ -1209,48 +1209,6 @@ a.kpi:hover {
|
||||||
box-shadow: inset 0 0 0 1px var(--color-signal);
|
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 {
|
.tree-grid .device-drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/app/web-health/route.ts
Normal file
10
src/app/web-health/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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">>;
|
|
||||||
|
|
@ -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
|
|
@ -50,13 +50,6 @@
|
||||||
"when": 1786043080323,
|
"when": 1786043080323,
|
||||||
"tag": "0006_damp_skrulls",
|
"tag": "0006_damp_skrulls",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 7,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1786114831610,
|
|
||||||
"tag": "0007_watery_kingpin",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const assignment of input.command.payload.rows) {
|
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
|
const updated = tx
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(assignment.target)
|
.set(values)
|
||||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -137,7 +137,6 @@ import type {
|
||||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||||
import { CableSizingModal } from "./cable-sizing-modal";
|
|
||||||
import {
|
import {
|
||||||
circuitGroupCategoryLabels,
|
circuitGroupCategoryLabels,
|
||||||
type CircuitGroupCategory,
|
type CircuitGroupCategory,
|
||||||
|
|
@ -279,8 +278,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
useState<CircuitGroupEditorIntent | null>(null);
|
useState<CircuitGroupEditorIntent | null>(null);
|
||||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
|
||||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||||
useState(false);
|
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() {
|
async function handleRedo() {
|
||||||
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3167,20 +3139,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
onSave={handleSaveCircuitProtection}
|
onSave={handleSaveCircuitProtection}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<div className="editor-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -4282,15 +4240,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
row.rowType !== "deviceRow"
|
row.rowType !== "deviceRow"
|
||||||
);
|
);
|
||||||
const isCableSizingTrigger = Boolean(
|
|
||||||
(column.key === "cableSummary" || column.key === "cableCrossSection") &&
|
|
||||||
row.circuit &&
|
|
||||||
row.rowType !== "deviceRow"
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={column.key}
|
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")
|
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||||
? "device-drag-handle"
|
? "device-drag-handle"
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -4310,9 +4263,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
title={
|
title={
|
||||||
isProtectionTrigger
|
isProtectionTrigger
|
||||||
? "Schutzgerät bearbeiten"
|
? "Schutzgerät bearbeiten"
|
||||||
: isCableSizingTrigger
|
: column.key === "equipmentIdentifier" &&
|
||||||
? "Kabel dimensionieren"
|
|
||||||
: column.key === "equipmentIdentifier" &&
|
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
(row.rowType === "circuitCompact" ||
|
(row.rowType === "circuitCompact" ||
|
||||||
row.rowType === "circuitSummary" ||
|
row.rowType === "circuitSummary" ||
|
||||||
|
|
@ -4391,10 +4342,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
setProtectionEditorCircuit(row.circuit!);
|
setProtectionEditorCircuit(row.circuit!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isCableSizingTrigger) {
|
|
||||||
setCableSizingEditorCircuit(row.circuit!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
handleRowSelectionClick(row, column.key, {
|
handleRowSelectionClick(row, column.key, {
|
||||||
ctrlKey: event.ctrlKey,
|
ctrlKey: event.ctrlKey,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger";
|
||||||
const logger = createLogger("web:navigation");
|
const logger = createLogger("web:navigation");
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
// The Docker healthcheck hits "/" every few seconds with no User-Agent
|
// Probes hit "/web-health" and are excluded by the matcher below. The
|
||||||
// header; skip it so real navigation isn't drowned out in the logs.
|
// 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")) {
|
if (request.headers.get("user-agent")) {
|
||||||
logger.info("page request", {
|
logger.info("page request", {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
|
@ -17,5 +18,7 @@ export function proxy(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.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 =
|
export const circuitDeviceRowRepository =
|
||||||
new CircuitDeviceRowRepository(db);
|
new CircuitDeviceRowRepository(db);
|
||||||
|
|
@ -34,4 +33,3 @@ export const roomRepository = new RoomRepository(db);
|
||||||
export const externalCsvConfigurationRepository =
|
export const externalCsvConfigurationRepository =
|
||||||
new ExternalCsvConfigurationRepository(db);
|
new ExternalCsvConfigurationRepository(db);
|
||||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||||
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-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 { projectRouter } from "./routes/project.routes.js";
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.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/projects", projectRouter);
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
app.use("/api/cable-sizing", cableSizingRouter);
|
|
||||||
|
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.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 { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
|
||||||
return row;
|
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(
|
function snapshot(
|
||||||
context: DatabaseContext,
|
context: DatabaseContext,
|
||||||
rowId: string
|
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", () => {
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue