Proposal: cable-sizing module (on-demand DIN VDE 0298-4 cross-section calculator) #1

Open
Grovy311 wants to merge 10 commits from Grovy311/leistungsbilanz-ts:feature/cable-sizing-module into main
20 changed files with 4633 additions and 5 deletions

View file

@ -20,7 +20,7 @@ services:
max-size: "20m" max-size: "20m"
max-file: "10" max-file: "10"
ports: ports:
- "3000:3000" - "3221:3000"
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./scripts:/app/scripts - ./scripts:/app/scripts
@ -64,7 +64,7 @@ services:
api: api:
condition: service_healthy condition: service_healthy
ports: ports:
- "3001:3001" - "3220:3001"
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./next.config.mjs:/app/next.config.mjs:ro - ./next.config.mjs:/app/next.config.mjs:ro
@ -80,3 +80,9 @@ services:
timeout: 3s timeout: 3s
retries: 12 retries: 12
start_period: 20s start_period: 20s
networks:
default:
ipam:
config:
- subnet: 172.16.61.0/24

243
docs/cable-sizing-module.md Normal file
View file

@ -0,0 +1,243 @@
# Cable Sizing Module (Proposal)
**Status: proposal, not yet reviewed or merged by the project owner.** This
document and the accompanying `src/cable-sizing/`, `src/db/schema/cable-sizing-calculations.ts`,
`src/server/{controllers,routes}/cable-sizing.*` and `src/frontend/components/cable-sizing-*`
files were written by a third party (see git history/authorship) against the
project's own stated direction in `docs/spec/06-future-sizing-and-calculations.md`
("The app should later support rule-based protection and cable sizing") and
`AGENTS.md` ("... and later electrical sizing logic"). Nothing here has been
pushed to the project's own repository; it exists as a local branch for
review, testing and discussion.
## Why a separate module instead of extending core domain code
`AGENTS.md` is explicit that critical multi-write commands, the revision/undo
system and the Circuit-First domain model are the supported architecture, and
that changes should be small and reviewable. A cable-sizing calculation is
**not** a project mutation - it doesn't need `expectedRevision`, doesn't
belong in the undo/redo stack, and shouldn't grow the `circuit.update`
command's `switch` statement or the DTOs it doesn't already have.
So this module is built exactly like the existing `src/external-model/`
adapter (the Revit/CSV import foundation): a one-way dependency boundary.
```
src/cable-sizing/domain/ <- pure functions and types, zero imports from
db/, server/ or frontend/
src/db/schema/ <- one new, fully additive table
src/db/repositories/ <- one plain repository (no revision semantics)
src/server/{controllers,routes}/cable-sizing.* <- one new, isolated route
group, mounted with a single app.use() line
src/frontend/components/cable-sizing-* <- one new modal + one small
API client, following the existing
FormModal / CircuitProtectionModal pattern
```
Nothing outside these files imports from them except the two required
one-line hooks described below. If this module is rejected or needs to move
out again, removing it is a matter of deleting these files, the two
one-line hooks, and running a `DROP TABLE` migration - it never touches
`circuits`, `project_revisions` or any command/history table.
## The two required hooks into existing code
1. `src/server/index.ts`: one import + one `app.use("/api/cable-sizing", cableSizingRouter)`
line, next to the existing `app.use("/api/projects", ...)` etc.
2. `src/frontend/components/circuit-tree-editor.tsx`: one new `useState` for
the open modal, one new trigger condition (mirroring the existing
`isProtectionTrigger` / `protectionEditorCircuit` pattern almost exactly),
and one new conditionally-rendered `<CableSizingModal />` alongside the
existing `<CircuitProtectionModal />`. See the diff for the exact lines.
No changes to `circuit-project-command.model.ts`, `project-command.service.ts`,
any Zod command schema, any migration for `circuits`/`project_revisions`, or
any existing test.
## Coupling to the grid UI (the one fragile point)
Everything described above - the calculation, the audit table, applying a
result via `circuit.update` - depends only on stable, explicitly documented
domain fields (`cableType`/`cableCrossSection`/`cableLength`/
`circuitTotalPower`/`voltage`/`protectionDevice.ratedCurrentA`), the same
ones `AGENTS.md` already treats as protected ("Protection and cable data
belong to the circuit"). None of that breaks if the grid UI changes.
The one place that *is* coupled to a UI implementation detail: the click
trigger in `circuit-tree-editor.tsx` matches grid **column keys**
(`cableSummary` and `cableCrossSection`, defined in
`circuit-grid-model.ts`) to decide where to show the calculator icon.
Column visibility and order in this app are a per-browser user preference,
not fixed - so this is checked against both known cable-related columns to
reduce (not eliminate) the chance a user's personal column layout hides the
trigger entirely. If a future column rename removes both keys, the failure
mode is purely cosmetic: the calculator icon/click stops appearing on that
cell, nothing crashes, no data is affected, and the rest of the app
(including manual cable-field editing) is completely unaffected - the
module's actual logic and persistence never depend on this column key.
## Feature overview
- **Calculate a recommendation**: laying method, insulation, conductor
material, ambient temperature, grouping, cos phi and max voltage drop -
same VDE 0298-4 reference-method model as the sibling Kabelliste tool
(see Verification status below).
- **Breaker-aware sizing**: if the circuit already has a protection device
(`circuit.protectionDevice`), the cross-section is selected against
`designCurrentA = max(operatingCurrentA, protectionDevice.ratedCurrentA)`,
not the load current alone - the standard `In <= Iz` rule (a breaker only
trips at its own rated current, so a cable sized for the actual load
alone could overheat under sustained current below that threshold). If no
cross-section can cover an oversized breaker, that is reported as the
limiting factor by name, not a generic capacity error.
- **Maximum length for the voltage-drop limit**: each cross-section option
also reports the longest single run that still meets the requested
`maxVoltageDropPercent` at the given load - the inverse of the
voltage-drop check, shown for the recommended cross-section.
- **Practical minimum for socket circuits**: `single_phase`-category
circuits are raised to at least 2.5 mm² if the calculation alone would
recommend less - see the dedicated note under Verification status. Never
lowers a calculation that already needs more.
- **Manual entry, always available**: the modal has a plain text Kabeltyp/
Querschnitt field. Running a calculation pre-fills it as a suggestion,
but the field is the single source of truth for what gets applied, and
stays freely editable - satisfying
`docs/spec/06-future-sizing-and-calculations.md`'s explicit requirement
that "users must remain able to manually override suggestions" without
requiring a calculation to have been run first. A non-blocking warning
appears if the typed value is not a standard cross-section, is smaller
than the last calculation, or is below the practical minimum above -
informational only, never forced, matching the same spec doc's "shown as
a warning or status indicator, not as an automatic forced change."
## How a suggestion or manual entry is applied
The module never writes to `circuits` directly. The modal's "Übernehmen"
button calls the **existing, unmodified** frontend helper with whatever is
currently in the Kabeltyp/Querschnitt fields, calculated or hand-typed:
```ts
updateCircuitById(projectId, expectedRevision, circuitId, {
cableCrossSection: "4 mm²",
cableType: "NYM-J 3x2.5",
cableLength: 30,
});
```
which is the same `circuit.update` command the grid already uses for manual
cell edits. This means:
- optimistic concurrency (`expectedRevision`) is respected automatically
- the change appears in the project's revision history and is undoable/
redoable exactly like a manual edit
- nothing is ever written automatically - calculating only fills the modal's
own fields, applying is always an explicit, separate click
## API contract
`POST /api/cable-sizing/calculate`
```jsonc
{
"phase": 1, // 1 | 3
"mode": "power", // "power" | "current"
"powerKw": 4.2, // circuit.circuitTotalPower, when mode="power"
"cosPhi": 1,
"voltage": 230, // circuit.voltage (already project-derived, read-only)
"lengthM": 23.5, // circuit.cableLength
"layingMethod": "C", // DIN VDE 0298-4 reference method, A1|A2|B1|B2|C|D1|D2|E|F|G
"conductorMaterial": "copper", // "copper" | "aluminum"
"insulation": "pvc", // "pvc" | "xlpe"
"ambientTemperatureC": 30,
"groupingCircuits": 1,
"maxVoltageDropPercent": 3,
"harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only
"existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice
"circuitCategory": "single_phase", // optional, from the circuit's section.category -
// enables the practical-minimum convention below
"context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" }
}
```
Response: `{ calculationId, result: CableSizingResult, alerts: CableSizingAlert[] }`
(see `src/cable-sizing/domain/cable-sizing-calculation.ts` for the exact
shape). `GET /api/cable-sizing/laying-methods` and `.../insulation-materials`
expose the pick-list metadata (including which combinations are verified) so
a future non-modal UI could build its own form without hard-coding the enum.
## Verification status - important
The reference current-carrying-capacity tables are ported from a sibling
project's Kabelliste module, which was itself only verified against ~8
public sources for **six** of the ten DIN VDE 0298-4 reference laying
methods (A1, B2, C, E, D1, D2) and **PVC insulation only**. The other four
methods (A2, B1, F, G) and XLPE/VPE insulation could not be verified without
contradiction across sources during that earlier work, so
`calculateCableSizing` deliberately returns `dataVerified: false` and no
numeric recommendation for those combinations, rather than a guessed value.
The UI surfaces this as a plain critical alert. Anyone with access to the
actual norm text can extend `LAYING_METHOD_VERIFIED` and
`CURRENT_CAPACITY_A` in `src/cable-sizing/domain/cable-sizing-calculation.ts`
once the missing tables are confirmed.
The protection-coordination check (`existingProtectionRatedCurrentA` vs. the
recommended cross-section's corrected capacity) is a **simplified** `In <=
Iz` check only - it is not a full IEC 60364-4-43 overload (`I2 <= 1.45 x
Iz`) or short-circuit withstand check.
`PRACTICAL_MINIMUM_CROSS_SECTION_MM2` (2.5 mm² for `single_phase`) is
**not** a verified norm value at all - it is a named planning convention,
sourced directly from this project's own
`docs/spec/06-future-sizing-and-calculations.md` ("Standard Single-Phase
Circuits ... usually use ... cable cross-section: 2.5 mm²"), applied as a
floor on top of the calculated recommendation. It intentionally only
covers the one category and one convention that document already states;
it is not a general substitute for norm-compliant calculation.
## Relationship to the project's own future-sizing spec
`docs/spec/06-future-sizing-and-calculations.md` separately describes simple
category-based defaults (e.g. lighting circuits -> 10 A / 1.5 mm²) as
"common planning defaults, not a replacement for full norm-compliant
calculation". This module is the latter: an on-demand, norm-referenced
calculation for one circuit at a time, not a bulk default-filling tool. The
two are complementary and could later be wired together (e.g. the category
defaults pre-fill this module's laying-method/insulation fields), but that
integration is out of scope here.
The spec's `isPublicBuilding` halogen-free rule and the `Control Requirement`
(DALI/KNX/core-count) field are **not** implemented by this module - both
concern cable *type* selection, not cross-section sizing, and the latter
isn't in the current schema yet. They're natural follow-ups once this module
is reviewed.
## Maintenance plan
The `src/cable-sizing/domain/` folder has zero imports from the rest of the
app by design (see the dependency-direction note above), so it can be
extracted into an independently versioned/published package later (e.g. a
private npm package or a git subtree) without touching anything outside the
five files/folders listed above - "separately maintained but part of the
app for now", per the intent of this proposal.
## Local testing
Deployed and manually exercised end-to-end on an internal test host at
`http://192.168.0.133:3220` (own docker deployment, own subnet/ports to
avoid clashing with ~50 other containers on that host - see the two
commits marked "Local-only" in this branch, which are not part of this
proposal and should not be carried over if it is ever proposed upstream).
Exercised against seeded real project/circuit data created through the
actual command API (not direct DB writes), including a deliberately long
(85 m) circuit to trigger the voltage-drop-critical path.
`npm test`, `npm run build:api`, `npm run build:web` and
`npm run typecheck:scripts` all pass with this module included at every
commit in this branch.
This branch (`feature/cable-sizing-module`) is pushed to a mirror of this
repository under this homelab's own Forgejo instance, not to
`git.jappel.io` - no write access to the upstream repository was available
or used. See the branch's commit history for the incremental history of
this module, including fixes made during manual testing (trigger column,
icon rendering, breaker-aware sizing).

View file

@ -5,7 +5,8 @@ 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: {

View file

@ -1209,6 +1209,48 @@ 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;
} }

View file

@ -0,0 +1,547 @@
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/ -
// mirrors the dependency direction of src/external-model/ (see
// docs/cable-sizing-module.md). Everything here is a stateless function on
// plain data; persistence and the versioned circuit.update command live
// outside this module.
//
// The numeric reference tables below (cross-section current-carrying
// capacity, temperature/grouping correction factors) are ported from a
// sibling project's already-verified Kabelliste module
// (elt-planung-suite/src/tools/kabel/index.ts), not re-derived. See
// docs/cable-sizing-module.md for the verification history and the explicit
// "not yet verified" methods, which intentionally return no numeric result
// instead of a guessed one (dataVerified: false).
// -- Laying methods (DIN VDE 0298-4 reference installation methods) --------
export const LAYING_METHODS = [
"A1",
"A2",
"B1",
"B2",
"C",
"D1",
"D2",
"E",
"F",
"G",
] as const;
export type LayingMethod = (typeof LAYING_METHODS)[number];
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
A1: "air",
A2: "air",
B1: "air",
B2: "air",
C: "air",
E: "air",
F: "air",
D1: "ground",
D2: "ground",
G: "ground",
};
// A1, B2, C, E, D1, D2: verified against elt-planung-suite's ported original
// tool. A2, B1, F, G: no verified capacity table available yet (see
// docs/cable-sizing-module.md) - calculateCableSizing returns
// dataVerified: false for these instead of a guessed number.
export const LAYING_METHOD_VERIFIED: Record<LayingMethod, boolean> = {
A1: true,
B2: true,
C: true,
E: true,
D1: true,
D2: true,
A2: false,
B1: false,
F: false,
G: false,
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
A1: "A1 - Rohr in wärmegedämmter Wand",
A2: "A2 - Mehradriges Kabel im Rohr in wärmegedämmter Wand (Werte nicht hinterlegt)",
B1: "B1 - Einzeladern im Rohr auf/unter Putz (Werte nicht hinterlegt)",
B2: "B2 - Rohr auf/unter Putz",
C: "C - Kabel direkt auf Wand verlegt",
E: "E - Kabelpritsche / frei in Luft",
F: "F - Kabel frei in Luft, einzeln mit Abstand (Werte nicht hinterlegt)",
D1: "D1 - Kabel direkt im Erdreich",
D2: "D2 - Kabel im Rohr im Erdreich",
G: "G - Erdverlegung, Sonderfall (Werte nicht hinterlegt)",
};
// -- Insulation / conductor material ----------------------------------------
export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const;
export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number];
export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
pvc: "PVC (Grenztemperatur 70 °C)",
xlpe: "VPE/XLPE (Grenztemperatur 90 °C) - Strombelastbarkeitswerte nicht hinterlegt",
};
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// Only PVC combined with one of the six verified laying methods returns a
// numeric result. XLPE has no verified capacity table for any method yet.
export function isCableSizingDataVerified(
layingMethod: LayingMethod,
insulation: InsulationMaterial
): boolean {
return insulation === "pvc" && LAYING_METHOD_VERIFIED[layingMethod];
}
// -- Harmonic neutral loading (IEC 60364-5-52, three-phase only) -----------
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
"none",
"15to33Percent",
"over33Percent",
] as const;
export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number];
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
none: "Keine nennenswerte 3. Harmonische (< 15 %) - Standardfall",
"15to33Percent":
"3. Harmonische 15-33 % - Reduktionsfaktor 0,86 (IEC 60364-5-52)",
over33Percent:
"3. Harmonische > 33 % - Neutralleiter wie Außenleiter dimensionieren",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
// -- Reference tables (ported, verified subset only) ------------------------
export const CROSS_SECTIONS_MM2 = [
1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240,
] as const;
type CoreCount = 2 | 3;
export const CURRENT_CAPACITY_A: Partial<
Record<LayingMethod, Record<CoreCount, (number | null)[]>>
> = {
A1: {
2: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, null, null, null],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, null, null, null],
},
B2: {
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 134, 171, 207, 239, null, null, null],
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, null, null, null],
},
C: {
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
},
E: {
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
},
D1: {
2: [26, 34, 44, 56, 74, 96, 123, 147, 174, 216, 256, 290, 328, 367, 424],
3: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 361],
},
D2: {
2: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 360],
3: [18.5, 24, 31, 39, 52, 67, 86, 103, 122, 151, 179, 203, 230, 258, 297],
},
};
export const TEMPERATURE_FACTOR_AIR: Record<number, number> = {
10: 1.29,
15: 1.22,
20: 1.15,
25: 1.08,
30: 1.0,
35: 0.91,
40: 0.82,
45: 0.71,
50: 0.58,
};
export const TEMPERATURE_FACTOR_GROUND: Record<number, number> = {
10: 1.1,
15: 1.05,
20: 1.0,
25: 0.95,
30: 0.89,
35: 0.84,
40: 0.77,
};
export const GROUPING_FACTOR_AIR: Record<number, number> = {
1: 1.0,
2: 0.8,
3: 0.7,
4: 0.65,
5: 0.6,
6: 0.57,
8: 0.54,
10: 0.5,
};
export const GROUPING_FACTOR_GROUND: Record<number, number> = {
1: 1.0,
2: 0.75,
3: 0.65,
4: 0.6,
5: 0.55,
6: 0.52,
8: 0.49,
10: 0.46,
};
export const ALUMINUM_CAPACITY_FACTOR = 0.78;
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const;
export const GROUPING_LABELS: Record<number, string> = {
1: "1 (keine Häufung)",
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
8: "7-9",
10: "≥10",
};
// -- Practical minimum cross-sections by circuit category ------------------
// Not a thermal/voltage-drop calculation result - a widely used planning
// convention for margin against future load growth, mechanical robustness
// and fault-current withstand, beyond what a pure Ib-based calculation
// would give. Matches this project's own docs/spec/06-future-sizing-and-
// calculations.md ("Standard Single-Phase Circuits ... usually use ...
// cable cross-section: 2.5 mm²"). Only applied for the "single_phase"
// circuit category (general sockets and similar loads); lighting and
// three-phase circuits are not covered by this specific convention.
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
single_phase: 2.5,
};
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
// -- Input / result -----------------------------------------------------
export interface CableSizingInput {
phase: 1 | 3;
mode: "power" | "current";
/** kW, only used when mode === "power" */
powerKw?: number;
/** A, only used when mode === "current" */
currentA?: number;
cosPhi: number;
/** Line voltage in V. The circuit-list app derives this from project
* settings (singlePhaseVoltageV / threePhaseVoltageV); pass it through
* rather than hard-coding 230/400 here. */
voltage: number;
/** Single-run cable length in meters (circuits.cableLength). */
lengthM: number;
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
ambientTemperatureC: number;
groupingCircuits: number;
maxVoltageDropPercent: number;
harmonicNeutralLoad: HarmonicNeutralLoad;
/** Optional: existing protection device rated current (LS/circuit
* breaker), for the simplified coordination check below. */
existingProtectionRatedCurrentA?: number;
/** Optional: enables the practical-minimum-cross-section convention for
* "single_phase" circuits, see PRACTICAL_MINIMUM_CROSS_SECTION_MM2. */
circuitCategory?: CircuitCategory;
}
export interface CrossSectionRow {
crossSectionMm2: number;
ratedCurrentA: number | null;
correctedCurrentA: number | null;
currentSufficient: boolean | null;
voltageDropPercent: number | null;
voltageDropSufficient: boolean | null;
/** Longest single-run length (m) at which this cross-section still meets
* the requested max voltage drop, at the given load/cosPhi/phase - the
* inverse of the voltage-drop formula used for voltageDropPercent above.
* null when the operating current is zero (division by zero). */
maxLengthForVoltageDropM: number | null;
recommended: boolean;
}
export interface ProtectionCoordinationResult {
ratedCurrentA: number;
/** Simplified In <= Iz check only - not full overload (I2 <= 1.45 x Iz)
* or short-circuit coordination. */
coordinated: boolean;
}
export interface CableSizingResult {
/** false: no verified capacity table for this layingMethod/insulation
* combination - every field below is null/empty, never a guess. */
dataVerified: boolean;
operatingCurrentA: number;
/** max(operatingCurrentA, existingProtectionRatedCurrentA). The cable
* capacity (Iz) must cover the protective device's rated current (In),
* not just the actual load current - a breaker only trips at In, so a
* cable sized for Ib alone could overheat under sustained load below the
* trip threshold. This is the value cross-section selection actually
* uses; operatingCurrentA is kept for display/voltage-drop only. */
designCurrentA: number;
crossSectionByCapacityMm2: number | null;
crossSectionByVoltageDropMm2: number | null;
recommendedCrossSectionMm2: number | null;
voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number;
harmonicReductionApplied: boolean;
/** true if the recommendation was raised to satisfy
* PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (a convention, not a thermal/
* voltage-drop requirement of this specific circuit). */
practicalMinimumApplied: boolean;
rows: CrossSectionRow[];
protectionCoordination: ProtectionCoordinationResult | null;
}
function operatingCurrentA(input: CableSizingInput): number {
if (input.mode === "current") {
return input.currentA ?? 0;
}
const powerW = (input.powerKw ?? 0) * 1000;
return input.phase === 1
? powerW / (input.voltage * input.cosPhi)
: powerW / (Math.sqrt(3) * input.voltage * input.cosPhi);
}
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
const ib = operatingCurrentA(input);
const designCurrentA =
input.existingProtectionRatedCurrentA != null
? Math.max(ib, input.existingProtectionRatedCurrentA)
: ib;
if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) {
return {
dataVerified: false,
operatingCurrentA: ib,
designCurrentA,
crossSectionByCapacityMm2: null,
crossSectionByVoltageDropMm2: null,
recommendedCrossSectionMm2: null,
voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1,
harmonicReductionApplied: false,
practicalMinimumApplied: false,
rows: [],
protectionCoordination: null,
};
}
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
const aluminumFactor =
input.conductorMaterial === "aluminum" ? ALUMINUM_CAPACITY_FACTOR : 1;
const kappa = KAPPA[input.conductorMaterial];
const group = LAYING_METHOD_GROUP[input.layingMethod];
const temperatureTable =
group === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
const groupingTable = group === "air" ? GROUPING_FACTOR_AIR : GROUPING_FACTOR_GROUND;
const temperatureFactor = temperatureTable[input.ambientTemperatureC] ?? 1;
const groupingFactor = groupingTable[input.groupingCircuits] ?? 1;
const harmonicReductionApplied =
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
const harmonicFactor = harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1;
const combinedDerationFactor = temperatureFactor * groupingFactor * harmonicFactor;
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
const ratedCurrents = CURRENT_CAPACITY_A[input.layingMethod]![coreCount];
const correctedCurrents = ratedCurrents.map((value) =>
value == null ? null : value * aluminumFactor * combinedDerationFactor
);
const voltageDrops = CROSS_SECTIONS_MM2.map(
(crossSection) =>
((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) /
(kappa * crossSection) /
input.voltage) *
100
);
const indexByCapacity = correctedCurrents.findIndex(
(value) => value != null && value >= designCurrentA
);
const indexByVoltageDrop = voltageDrops.findIndex(
(value) => value <= input.maxVoltageDropPercent
);
let indexRecommended = -1;
if (indexByCapacity >= 0 && indexByVoltageDrop >= 0) {
indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop);
}
// Practical-minimum convention (see PRACTICAL_MINIMUM_CROSS_SECTION_MM2):
// only ever raises the recommendation, never lowers what the thermal/
// voltage-drop calculation already required.
const practicalMinimumMm2 = input.circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory]
: undefined;
let practicalMinimumApplied = false;
if (
indexRecommended >= 0 &&
practicalMinimumMm2 != null &&
CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2
) {
const minimumIndex = CROSS_SECTIONS_MM2.indexOf(
practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number]
);
if (minimumIndex >= 0) {
indexRecommended = minimumIndex;
practicalMinimumApplied = true;
}
}
const voltageDropCurrentBasis = ib * input.cosPhi;
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
const rated = ratedCurrents[i];
const corrected = correctedCurrents[i];
const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
const maxLengthForVoltageDropM =
rated == null || voltageDropCurrentBasis <= 0
? null
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
(100 * voltageDropCoefficient * voltageDropCurrentBasis);
return {
crossSectionMm2: crossSection,
ratedCurrentA: rated,
correctedCurrentA: corrected,
currentSufficient,
voltageDropPercent: rated == null ? null : voltageDrops[i],
voltageDropSufficient: rated == null ? null : voltageDropSufficient,
maxLengthForVoltageDropM,
recommended: i === indexRecommended,
};
});
let protectionCoordination: ProtectionCoordinationResult | null = null;
if (input.existingProtectionRatedCurrentA != null && indexRecommended >= 0) {
const recommendedRow = rows[indexRecommended];
protectionCoordination = {
ratedCurrentA: input.existingProtectionRatedCurrentA,
coordinated:
recommendedRow.correctedCurrentA != null &&
input.existingProtectionRatedCurrentA <= recommendedRow.correctedCurrentA,
};
}
return {
dataVerified: true,
operatingCurrentA: ib,
designCurrentA,
crossSectionByCapacityMm2:
indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
crossSectionByVoltageDropMm2:
indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null,
recommendedCrossSectionMm2:
indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null,
voltageDropAtRecommendedPercent:
indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
combinedDerationFactor,
harmonicReductionApplied,
practicalMinimumApplied,
rows,
protectionCoordination,
};
}
export interface CableSizingAlert {
kind: "critical" | "warn" | "info" | "ok";
text: string;
}
export function buildCableSizingAlerts(
input: CableSizingInput,
result: CableSizingResult
): CableSizingAlert[] {
const alerts: CableSizingAlert[] = [];
if (!result.dataVerified) {
alerts.push({
kind: "critical",
text: `Für Verlegeart ${input.layingMethod} mit Isolierstoff ${input.insulation.toUpperCase()} liegen keine geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich.`,
});
return alerts;
}
const byCapacity = result.crossSectionByCapacityMm2;
const byDrop = result.crossSectionByVoltageDropMm2;
const recommended = result.recommendedCrossSectionMm2;
if (byCapacity == null) {
const dueToBreaker =
input.existingProtectionRatedCurrentA != null &&
input.existingProtectionRatedCurrentA > result.operatingCurrentA;
alerts.push({
kind: "critical",
text: dueToBreaker
? `Vorhandene Sicherung (${input.existingProtectionRatedCurrentA} A) übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod} - nicht der Betriebsstrom (${result.operatingCurrentA.toFixed(1)} A). Größere Verlegeart oder kleinere Sicherung prüfen.`
: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`,
});
} else if (byDrop != null && byDrop > byCapacity) {
alerts.push({
kind: "warn",
text: `Spannungsfall ist maßgeblich, nicht die Belastbarkeit: ${byCapacity} mm² würde thermisch reichen, ${byDrop} mm² ist wegen ΔU ≤ ${input.maxVoltageDropPercent}% nötig.`,
});
}
if (result.harmonicReductionApplied) {
alerts.push({
kind: "info",
text: "Reduktionsfaktor 0,86 wegen Oberschwingungsanteil 15-33 % im Neutralleiter angewendet (IEC 60364-5-52).",
});
}
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
alerts.push({
kind: "warn",
text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.",
});
}
if (result.practicalMinimumApplied) {
alerts.push({
kind: "info",
text: `Auf ${result.recommendedCrossSectionMm2} mm² angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine reine Berechnungsanforderung).`,
});
}
if (result.combinedDerationFactor < 1) {
alerts.push({
kind: "info",
text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`,
});
}
if (result.protectionCoordination) {
alerts.push(
result.protectionCoordination.coordinated
? {
kind: "info",
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) ist bei der Dimensionierung berücksichtigt (In ≤ Iz, vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`,
}
: {
kind: "warn",
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen.`,
}
);
}
if (alerts.length === 0 && recommended != null) {
alerts.push({
kind: "ok",
text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`,
});
}
const recommendedRow = result.rows.find((row) => row.recommended);
if (recommendedRow?.maxLengthForVoltageDropM != null) {
alerts.push({
kind: "info",
text: `Maximale Länge bei ${recommendedRow.crossSectionMm2} mm² und ΔU ≤ ${input.maxVoltageDropPercent}%: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
});
}
return alerts;
}

View file

@ -0,0 +1,49 @@
// Request/response validation for the cable-sizing HTTP API. Pure - depends
// only on zod and the sibling calculation module, never on db/server.
import { z } from "zod";
import {
CONDUCTOR_MATERIALS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
LAYING_METHODS,
type CableSizingInput,
} from "./cable-sizing-calculation.js";
export const cableSizingRequestSchema = z
.object({
phase: z.union([z.literal(1), z.literal(3)]),
mode: z.enum(["power", "current"]),
powerKw: z.number().min(0).optional(),
currentA: z.number().min(0).optional(),
cosPhi: z.number().min(0.6).max(1),
voltage: z.number().positive(),
lengthM: z.number().min(0),
layingMethod: z.enum(LAYING_METHODS),
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
insulation: z.enum(INSULATION_MATERIALS),
ambientTemperatureC: z.number(),
groupingCircuits: z.number().int().min(1),
maxVoltageDropPercent: z.number().min(1).max(5),
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
existingProtectionRatedCurrentA: z.number().positive().optional(),
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
// Optional context, persisted with the audit-log entry only - never
// used for the calculation itself.
context: z
.object({
projectId: z.string().min(1).optional(),
circuitId: z.string().min(1).optional(),
equipmentIdentifier: z.string().min(1).optional(),
})
.optional(),
})
.refine((v) => (v.mode === "power" ? v.powerKw != null : v.currentA != null), {
message: "powerKw is required for mode 'power', currentA for mode 'current'",
});
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
// Structural check that the request schema stays a superset of the domain
// input shape (minus context, which is API-only metadata).
type _AssertAssignable<T extends CableSizingInput> = T;
type _Check = _AssertAssignable<Omit<CableSizingRequest, "context">>;

View file

@ -0,0 +1,14 @@
CREATE TABLE `cable_sizing_calculations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text,
`circuit_id` text,
`equipment_identifier` text,
`input` text NOT NULL,
`result` text NOT NULL,
`applied_to_circuit` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `cable_sizing_calculations_circuit_id_idx` ON `cable_sizing_calculations` (`circuit_id`);

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,13 @@
"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
} }
] ]
} }

View file

@ -0,0 +1,37 @@
import { desc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
cableSizingCalculations,
type CableSizingCalculation,
type NewCableSizingCalculation,
} from "../schema/cable-sizing-calculations.js";
// Plain repository, no revision/command semantics - mirrors
// application-repositories.ts's flat repository pattern, not the
// project-command-transaction boundary. A calculation is an audit record,
// not a project mutation.
export class CableSizingCalculationRepository {
constructor(private readonly db: AppDatabase) {}
create(input: NewCableSizingCalculation): CableSizingCalculation {
return this.db.insert(cableSizingCalculations).values(input).returning().get();
}
listByCircuit(circuitId: string): CableSizingCalculation[] {
return this.db
.select()
.from(cableSizingCalculations)
.where(eq(cableSizingCalculations.circuitId, circuitId))
.orderBy(desc(cableSizingCalculations.createdAt))
.all();
}
markApplied(id: string): CableSizingCalculation | undefined {
return this.db
.update(cableSizingCalculations)
.set({ appliedToCircuit: 1 })
.where(eq(cableSizingCalculations.id, id))
.returning()
.get();
}
}

View file

@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js";
import { projects } from "./projects.js";
// Audit log for the cable-sizing module (see docs/cable-sizing-module.md).
// Deliberately separate from the revision/command system: a calculation is
// not itself a project mutation, only applying its result via the existing
// circuit.update command is. circuitId/projectId are nullable and
// onDelete "set null" so a deleted circuit or project never blocks or
// cascades into losing the audit trail.
export const cableSizingCalculations = sqliteTable(
"cable_sizing_calculations",
{
id: text("id").primaryKey(),
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
circuitId: text("circuit_id").references(() => circuits.id, { onDelete: "set null" }),
equipmentIdentifier: text("equipment_identifier"),
input: text("input", { mode: "json" }).notNull(),
result: text("result", { mode: "json" }).notNull(),
appliedToCircuit: integer("applied_to_circuit").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [index("cable_sizing_calculations_circuit_id_idx").on(table.circuitId)]
);
export type CableSizingCalculation = typeof cableSizingCalculations.$inferSelect;
export type NewCableSizingCalculation = typeof cableSizingCalculations.$inferInsert;

View file

@ -0,0 +1,71 @@
// Thin, self-contained fetch client for the cable-sizing module. Deliberately
// not merged into ../utils/api.ts to keep this module's frontend footprint a
// single new file rather than growing the existing shared API file - see
// docs/cable-sizing-module.md.
import type {
CableSizingAlert,
CableSizingInput,
CableSizingResult,
InsulationMaterial,
LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
cache: "no-store",
});
if (!response.ok) {
let details = await response.text();
try {
const parsed = JSON.parse(details) as { error?: unknown };
if (typeof parsed.error === "string") details = parsed.error;
} catch {
// keep raw text
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
}
return response.json() as Promise<T>;
}
export interface CalculateCableSizingResponse {
calculationId: string;
result: CableSizingResult;
alerts: CableSizingAlert[];
}
export function calculateCableSizing(
input: CableSizingInput,
context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string }
): Promise<CalculateCableSizingResponse> {
return request("/api/cable-sizing/calculate", {
method: "POST",
body: JSON.stringify({ ...input, context }),
});
}
export function markCableSizingCalculationApplied(calculationId: string): Promise<unknown> {
return request(`/api/cable-sizing/calculations/${calculationId}/applied`, {
method: "POST",
});
}
export interface LayingMethodOption {
method: LayingMethod;
label: string;
dataVerified: boolean;
}
export function listLayingMethods(): Promise<LayingMethodOption[]> {
return request("/api/cable-sizing/laying-methods");
}
export interface InsulationMaterialOption {
insulation: InsulationMaterial;
label: string;
}
export function listInsulationMaterials(): Promise<InsulationMaterialOption[]> {
return request("/api/cable-sizing/insulation-materials");
}

View file

@ -0,0 +1,523 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
GROUPING_LABELS,
GROUPING_OPTIONS,
HARMONIC_NEUTRAL_LOAD_LABELS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
TEMPERATURE_FACTOR_AIR,
TEMPERATURE_FACTOR_GROUND,
type CableSizingAlert,
type CableSizingInput,
type CableSizingResult,
type CircuitCategory,
type ConductorMaterial,
type HarmonicNeutralLoad,
type InsulationMaterial,
type LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import { calculateCableSizing } from "./cable-sizing-api";
import type { CircuitTreeCircuitDto } from "../types";
import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
isSaving: boolean;
projectId: string;
onClose: () => void;
onApply: (patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) => Promise<void>;
}
function temperatureOptions(method: LayingMethod) {
const table =
LAYING_METHOD_GROUP[method] === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
return Object.keys(table).map(Number).sort((a, b) => a - b);
}
// Extracts the first decimal number from a free-text cross-section entry
// like "2.5 mm²" or "2,5". Returns null if nothing parseable is found -
// used only for the manual-entry sanity check below, never persisted.
function parseCrossSectionMm2(text: string): number | null {
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return null;
const value = Number(match[1].replace(",", "."));
return Number.isFinite(value) ? value : null;
}
export function CableSizingModal({
circuit,
circuitCategory,
isSaving,
projectId,
onClose,
onApply,
}: CableSizingModalProps) {
const [layingMethod, setLayingMethod] = useState<LayingMethod>("C");
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("copper");
const [ambientTemperatureC, setAmbientTemperatureC] = useState(30);
const [groupingCircuits, setGroupingCircuits] = useState(1);
const [cosPhi, setCosPhi] = useState(1);
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1);
// Manual override: applying this never requires a calculation. Prefilled
// from the circuit's current values so opening the modal on an already
// specified cable doesn't lose that data.
const [manualCableType, setManualCableType] = useState(circuit.cableType ?? "");
const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? "");
const [calculating, setCalculating] = useState(false);
const [calculationId, setCalculationId] = useState<string | null>(null);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(null);
const voltage = circuit.voltage ?? (phase === 1 ? 230 : 400);
const input: CableSizingInput = useMemo(
() => ({
phase,
mode: "power",
powerKw: circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
circuitCategory,
}),
[
phase,
circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
circuit.protectionDevice?.ratedCurrentA,
circuitCategory,
]
);
const manualCrossSectionWarning = useMemo(() => {
const trimmed = manualCrossSection.trim();
if (!trimmed) return null;
const parsed = parseCrossSectionMm2(trimmed);
if (parsed == null) return null;
if (!(CROSS_SECTIONS_MM2 as readonly number[]).includes(parsed)) {
return `${parsed} mm² ist kein Standard-Querschnitt (${CROSS_SECTIONS_MM2.join(", ")}).`;
}
if (
result?.dataVerified &&
result.recommendedCrossSectionMm2 != null &&
parsed < result.recommendedCrossSectionMm2
) {
return `${parsed} mm² ist kleiner als die zuletzt berechnete Empfehlung (${result.recommendedCrossSectionMm2} mm²) - passt nicht zu Last${circuit.protectionDevice ? "/Sicherung" : ""} und Verlegeart.`;
}
const practicalMinimum = circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory]
: undefined;
if (practicalMinimum != null && parsed < practicalMinimum) {
return `${parsed} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${practicalMinimum} mm²).`;
}
return null;
}, [manualCrossSection, result, circuit.protectionDevice, circuitCategory]);
function handleLayingMethodChange(method: LayingMethod) {
setLayingMethod(method);
const options = temperatureOptions(method);
const reference = LAYING_METHOD_GROUP[method] === "air" ? 30 : 20;
setAmbientTemperatureC(options.includes(reference) ? reference : options[0]);
setResult(null);
setCalculationId(null);
}
async function handleCalculate() {
setCalculating(true);
setError(null);
try {
const response = await calculateCableSizing(input, {
projectId,
circuitId: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
});
setResult(response.result);
setAlerts(response.alerts);
setCalculationId(response.calculationId);
// Calculating fills the manual field as a suggestion, but that field
// stays the single source of truth for what gets applied - the user
// can still edit it by hand before submitting.
if (response.result.dataVerified && response.result.recommendedCrossSectionMm2) {
setManualCrossSection(`${response.result.recommendedCrossSectionMm2} mm²`);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
} finally {
setCalculating(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!manualCrossSection.trim()) return;
await onApply({
cableCrossSection: manualCrossSection.trim(),
cableType: manualCableType.trim() || undefined,
cableLength: lengthM,
});
}
return (
<FormModal
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4, oder Kabeltyp/Querschnitt direkt manuell eintragen. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-length">
Leitungslänge (m)
</label>
<input
className="form-control"
id="cable-sizing-length"
min={0}
onChange={(event) => setLengthM(Number(event.target.value))}
type="number"
value={lengthM}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
Phasen
</label>
<select
className="form-select"
id="cable-sizing-phase"
onChange={(event) => setPhase(Number(event.target.value) as 1 | 3)}
value={phase}
>
<option value={1}>1~ ({circuit.voltage ?? 230} V)</option>
<option value={3}>3~ ({circuit.voltage ?? 400} V)</option>
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Leistung (aus Stromkreis)</label>
<input
className="form-control"
disabled
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart (DIN VDE 0298-4)
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => handleLayingMethodChange(event.target.value as LayingMethod)}
value={layingMethod}
>
{LAYING_METHODS.map((method) => (
<option key={method} value={method}>
{LAYING_METHOD_LABELS[method]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-insulation">
Isolierstoff
</label>
<select
className="form-select"
id="cable-sizing-insulation"
onChange={(event) => {
setInsulation(event.target.value as InsulationMaterial);
setResult(null);
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-material">
Leitermaterial
</label>
<select
className="form-select"
id="cable-sizing-material"
onChange={(event) => setConductorMaterial(event.target.value as ConductorMaterial)}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-temperature">
{LAYING_METHOD_GROUP[layingMethod] === "air"
? "Umgebungstemperatur"
: "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => setAmbientTemperatureC(Number(event.target.value))}
value={ambientTemperatureC}
>
{temperatureOptions(layingMethod).map((temp) => (
<option key={temp} value={temp}>
{temp}°C
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise nebeneinander)
</label>
<select
className="form-select"
id="cable-sizing-grouping"
onChange={(event) => setGroupingCircuits(Number(event.target.value))}
value={groupingCircuits}
>
{GROUPING_OPTIONS.map((grouping) => (
<option key={grouping} value={grouping}>
{GROUPING_LABELS[grouping]}
</option>
))}
</select>
</div>
{phase === 3 && (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungsanteil Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) =>
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad)
}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
)}
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-range"
id="cable-sizing-cosphi"
max={1}
min={0.6}
onChange={(event) => setCosPhi(Number(event.target.value))}
step={0.05}
type="range"
value={cosPhi}
/>
<span>{cosPhi.toFixed(2)}</span>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. zulässiger Spannungsfall
</label>
<input
className="form-range"
id="cable-sizing-voltage-drop"
max={5}
min={1}
onChange={(event) => setMaxVoltageDropPercent(Number(event.target.value))}
step={0.5}
type="range"
value={maxVoltageDropPercent}
/>
<span>{maxVoltageDropPercent.toFixed(1)} %</span>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet…" : "Berechnen"}
</button>
{error && <p className="text-danger mt-2">{error}</p>}
{result && !result.dataVerified && (
<div className="alert alert-danger mt-3">
Für Verlegeart {layingMethod} mit Isolierstoff {insulation.toUpperCase()} liegen keine
geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich. Bitte
eine andere Verlegeart/Isolierstoff-Kombination wählen.
</div>
)}
{result && result.dataVerified && (
<div className="mt-3">
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom Ib</div>
<div>{result.operatingCurrentA.toFixed(1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
</div>
<div>
<strong>{result.designCurrentA.toFixed(1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2
? `${result.recommendedCrossSectionMm2} mm²`
: "-"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %`
: "-"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor</div>
<div>{result.combinedDerationFactor.toFixed(2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge (ΔU-Grenze)</div>
<div>
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null
? `${result.rows
.find((row) => row.recommended)!
.maxLengthForVoltageDropM!.toFixed(0)} m`
: "-"}
</div>
</div>
</div>
{alerts.map((alert, index) => (
<div
className={`alert mt-2 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
{circuit.protectionDevice && (
<p className="text-muted small mt-2 mb-0">
* Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung
({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem
eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur
für die tatsächliche Last.
</p>
)}
</div>
)}
<hr className="my-3" />
<p className="text-muted small mb-2">
Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in
dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.
</p>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-type">
Kabeltyp
</label>
<input
className="form-control"
id="cable-sizing-manual-type"
onChange={(event) => setManualCableType(event.target.value)}
placeholder="z.B. NYM-J 3x1.5"
value={manualCableType}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
Querschnitt
</label>
<input
className="form-control"
id="cable-sizing-manual-cross-section"
onChange={(event) => setManualCrossSection(event.target.value)}
placeholder="z.B. 4 mm²"
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning && (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
)}
</div>
</FormModal>
);
}

View file

@ -137,6 +137,7 @@ 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,
@ -278,6 +279,8 @@ 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);
@ -1398,6 +1401,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
async function handleApplyCableSizing(patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) {
const circuit = cableSizingEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Kabel dimensionieren",
redo: async () => {
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuit.id,
patch
);
applyProjectCommandResult(result);
setCableSizingEditorCircuit(null);
return null;
},
});
}
async function handleRedo() { async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return; return;
@ -3139,6 +3167,20 @@ 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"
@ -4240,10 +4282,15 @@ 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" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${ className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isCableSizingTrigger ? "cell-cable-sizing-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"
: "" : ""
@ -4263,6 +4310,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
title={ title={
isProtectionTrigger isProtectionTrigger
? "Schutzgerät bearbeiten" ? "Schutzgerät bearbeiten"
: isCableSizingTrigger
? "Kabel dimensionieren"
: column.key === "equipmentIdentifier" && : column.key === "equipmentIdentifier" &&
row.circuit && row.circuit &&
(row.rowType === "circuitCompact" || (row.rowType === "circuitCompact" ||
@ -4342,6 +4391,10 @@ 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,

View file

@ -13,6 +13,7 @@ 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);
@ -33,3 +34,4 @@ 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);

View file

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

View file

@ -1,6 +1,7 @@
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";
@ -49,6 +50,7 @@ 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);

View file

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

View file

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

View file

@ -0,0 +1,209 @@
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
);
});
});