Add optional cable-sizing module

Per-circuit cross-section recommendation as a separate module attached to
the circuit list. It never writes project data itself: "Übernehmen" applies
cable type, cross-section and length through the regular circuit.update
command, so revisions and Undo/Redo stay intact.

- Pure domain module (src/cable-sizing) with IEC 60364-5-52:2009 Annex B
  tables for PVC and XLPE, copper and aluminium, methods A1, A2, B1, B2, C,
  D1, D2 and E. Every value was cross-checked against public reproductions;
  where sources differ the lower value is used (docs/cable-sizing-module.md).
- Temperature and grouping between table values round to the safer value;
  values outside the tables return no result instead of a factor of 1.
  Ground methods use B.52.18 (D2) and B.52.19 A (D1).
- Overload protection: circuit-breakers In <= Iz; gG fuses additionally
  I2 <= 1.45 Iz with I2 = 1.6/1.9/2.1 In.
- Inputs prefilled from the circuit: power, phase (group or device rows),
  project voltage, power-weighted cos phi, length and protective device.
- Opened from a "Leitungsberechnung" button in the row actions, not from a
  grid cell, so cable cells keep the spreadsheet behavior.
- Stateless API under /api/cable-sizing; no database table or migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 10:59:41 +02:00
parent d95f0bf5c0
commit 49488f4e1b
17 changed files with 1999 additions and 4 deletions

View file

@ -507,9 +507,31 @@ Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
domain objects and commands before adding migrations. domain objects and commands before adding migrations.
## Cable Sizing Module
`src/cable-sizing` is an agreed, separate calculator module attached to the
circuit list (see `docs/cable-sizing-module.md`). Rules:
- The domain code stays pure: no imports from `db/`, `server/` or `frontend/`.
- It never writes project data. Results are applied only by an explicit
"Übernehmen", which runs the regular `circuit.update` command.
- It is opened from the circuit row action button "Leitungsberechnung", never
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
- It has no database table and no migration; `/api/cable-sizing/*` is
stateless.
- Reference data is IEC 60364-5-52:2009 Annex B. Change table values only with
a documented source in `docs/cable-sizing-module.md`; where sources differ,
use the lower value. Inputs outside the tables return no result instead of
assuming a factor of 1.
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
length, protective device) and remain editable; manual cable entry without
a calculation stays possible.
## Future Sizing ## Future Sizing
Do not implement full cable/protection sizing unless explicitly requested. Do not implement full automatic cable/protection sizing of all circuits
unless explicitly requested. The cable sizing module above only suggests
values for one circuit on demand.
Keep the structure ready for it. Keep the structure ready for it.

View file

@ -15,6 +15,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
bewusst noch nicht implementierte oder begrenzte Funktionen bewusst noch nicht implementierte oder begrenzte Funktionen
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
## Entwicklung und Betrieb ## Entwicklung und Betrieb

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

@ -0,0 +1,159 @@
# Leitungsberechnung (Cable-Sizing-Modul)
Optionales, abgegrenztes Rechenmodul für die Querschnittsempfehlung eines
Stromkreises. Es wurde als eigenständiges Modul vereinbart, das an die
Stromkreisliste angedockt ist. Es **schreibt keine Projektdaten selbst**: Ein
Ergebnis wird erst durch ausdrückliches „Übernehmen“ über den bestehenden
Befehl `circuit.update` in den Stromkreis geschrieben. Revisionen und
Undo/Redo bleiben damit unverändert gültig.
Die vollständige, automatische Bemessung aller Stromkreise (siehe
`docs/spec/06-future-sizing-and-calculations.md`) ist weiterhin **nicht**
Teil dieses Moduls.
## Aufbau und Andockpunkte
```
src/cable-sizing/domain/cable-sizing-calculation.ts reine Berechnung + Tabellen
src/cable-sizing/domain/cable-sizing-contracts.ts Zod-Schema der API
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
src/server/routes/cable-sizing.routes.ts Router unter /api/cable-sizing
src/frontend/components/cable-sizing-api.ts Fetch-Client
src/frontend/components/cable-sizing-defaults.ts Vorbelegung aus dem Stromkreis
src/frontend/components/cable-sizing-modal.tsx Dialog
tests/cable-sizing-calculation.test.ts Tabellen, Rechnung, Vorbelegung
```
Das Domänenmodul importiert nichts aus `db/`, `server/` oder `frontend/`.
Außerhalb dieser Dateien gibt es genau zwei Andockpunkte:
1. `src/server/index.ts`: `app.use("/api/cable-sizing", cableSizingRouter)`.
2. `src/frontend/components/circuit-tree-editor.tsx`: Zustand für den
geöffneten Dialog, ein Button **Leitungsberechnung** (Rechner-Icon) in der
Aktionsspalte jeder Stromkreiszeile, der Dialog selbst und
`handleApplyCableSizing`, das über `updateCircuitById` den regulären
`circuit.update`-Befehl ausführt.
Der Aufruf liegt bewusst **nicht** auf einer Tabellenzelle: Klick, Doppelklick,
Enter, F2 und Tippen in den Kabelspalten verhalten sich wie in jeder anderen
Zelle (Spreadsheet-Regel in `AGENTS.md`). Es gibt keine eigene Datenbanktabelle
und keine Migration.
## Eingaben und Vorbelegung
| Eingabe | Vorbelegung |
|---|---|
| Leistung | `circuitTotalPower` des Stromkreises (mit Geräte-GZF), nicht editierbar |
| Phasen | 3~, wenn die Stromkreisgruppe 3-phasig ist oder eine Gerätezeile `three_phase` hat, sonst 1~ |
| Spannung | `circuit.voltage` (aus den Projekteinstellungen abgeleitet); bei manuell abweichender Phasenzahl die jeweilige Projektspannung |
| cos φ | leistungsgewichtet aus den Gerätezeilen: P / Σ(P_Zeile / cos φ_Zeile), fehlendes cos φ = 1; änderbar |
| Länge | `cableLength` des Stromkreises |
| Schutzorgan | Schutzgerät des Stromkreises: LS, FI/LS, AFDD = Leitungsschutzschalter; D0/NH = Sicherung gG; FI ohne Überstromauslöser wird nicht berücksichtigt |
| Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung, max. ΔU, Oberschwingungen | Planereingaben |
Kabeltyp und Querschnitt lassen sich im Dialog auch ohne Berechnung direkt
eintragen. Weicht der Eintrag von der Empfehlung oder dem
Praxis-Mindestquerschnitt ab, erscheint nur ein Hinweis.
## Rechenweg
1. Betriebsstrom I_B aus Leistung, Spannung, cos φ und Phasenzahl.
2. Erforderliche Belastbarkeit: ohne Schutzorgan I_B; mit Schutzorgan
max(I_B, I_n, I₂/1,45) nach IEC 60364-4-43 (I_n ≤ I_z und I₂ ≤ 1,45 · I_z).
I₂/I_n: Leitungsschutzschalter 1,45; Sicherung gG 2,1 (I_n ≤ 4 A),
1,9 (4 A < I_n < 16 A), 1,6 (I_n ≥ 16 A).
3. Tabellenwert I_z je Querschnitt × Temperaturfaktor × Häufungsfaktor
(× 0,86 bei 15–33 % 3. Harmonischer im Drehstromkreis).
4. Spannungsfall ΔU = k · L · I_B · cos φ / (κ · A · U) mit k = 2 (1~) bzw.
√3 (3~), κ = 48 (Cu) bzw. 30 (Al) m/(Ω·mm²) bei Betriebstemperatur;
Reaktanz wird vernachlässigt.
5. Empfehlung = größerer Querschnitt aus Belastbarkeit und Spannungsfall;
1-phasige Stromkreise werden auf den Praxis-Mindestquerschnitt 2,5 mm²
angehoben (Planungskonvention aus `docs/spec/06-…`, keine Rechenanforderung).
6. Zusätzlich: maximale Länge für den zulässigen Spannungsfall.
Temperaturen und Häufungen **zwischen** Tabellenwerten werden auf den nächsten
ungünstigeren Tabellenwert gerundet (höhere Temperatur, mehr Stromkreise).
Werte **außerhalb** der Tabellen liefern kein Ergebnis und eine Meldung – es
wird nie stillschweigend mit Faktor 1 gerechnet.
## Datenbasis und Prüfnachweis
Grundlage ist **IEC 60364-5-52:2009, Anhang B**. Alle Werte wurden am
26.09.2026 zeilenweise mit öffentlich zugänglichen Wiedergaben abgeglichen:
TiSoft ElectricalDesign-Hilfe zu IEC 60364-5-52
(`ti-soft.com/en/support/help/electricaldesign/standards/iec-60364-5-52/…`)
und ECalPro (`ecalpro.com/standards/iec-60364/…`). Wo die Quellen voneinander
abweichen, ist der kleinere, sichere Wert eingetragen.
| Tabelle | Inhalt | Abgleich | Abweichungen |
|---|---|---|---|
| B.52.2 | PVC, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft; Cu A1–C zusätzlich ECalPro | Cu A1 240 mm²: 321 / 320 → 320 |
| B.52.3 | VPE, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.4 | PVC, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.5 | VPE, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.10 / .11 | PVC, Verlegeart E, mehradrig, Cu / Al | TiSoft; Cu 2 Adern zusätzlich ECalPro | Cu 3 Adern 70 / 120 mm²: 198 / 278 bzw. 196 / 276 → 196 / 276 |
| B.52.12 / .13 | VPE, Verlegeart E, mehradrig, Cu / Al | TiSoft | – |
| B.52.14 | Temperatur Luft, PVC / VPE | TiSoft, ECalPro | – |
| B.52.15 | Temperatur Erde, PVC / VPE | TiSoft | – |
| B.52.17 | Häufung Luft, Zeile 1 (gebündelt) | TiSoft, ECalPro | – |
| B.52.18 | Häufung Erde direkt, Kabel berührend | TiSoft | – |
| B.52.19 A | Häufung mehradrige Kabel in berührenden Rohren im Erdreich | TiSoft | – |
Anwendung der Tabellen:
- Verlegearten A1, A2, B1, B2, C, E in Luft (Bezug 30 °C), D1 und D2 im
Erdreich (Bezug 20 °C, Bodenwärmewiderstand 2,5 K·m/W). Nach Tabelle B.52.1
ist **D1 = Leitung im Rohr/Kabelschutzrohr im Erdreich** und **D2 = Kabel
direkt im Erdreich**.
- Häufung in Luft immer nach B.52.17 Zeile 1, auch für Verlegeart E; das ist
die ungünstigste Anordnung (einlagig auf Pritschen wären höhere Faktoren
zulässig). D1 nach B.52.19 A, D2 nach B.52.18 jeweils „berührend“.
- Aluminium nur mit den echten Aluminiumwerten (ab 2,5 mm², D2 ab 16 mm²);
Querschnitte ohne Tabellenwert werden nie empfohlen.
- Die Verlegearten F und G (Einzeladern mit Abstand) sind nicht enthalten.
**Vor produktivem Einsatz:** Die nationalen Tabellen der DIN VDE 0298-4
können in einzelnen Werten abweichen. Der Planer muss die Tabellen gegen den
Normtext prüfen. Die Prüfung ist in `tests/cable-sizing-calculation.test.ts`
über Stichwerte und eine Monotonieprüfung jeder Tabellenzeile abgesichert.
## Nicht enthalten
- Kurzschlussfestigkeit, Abschaltbedingungen (Schleifenimpedanz) und
Selektivität
- Bodenwärmewiderstand ≠ 2,5 K·m/W, Häufung mit Abstand, Verlegearten F/G
- Bemessung des Neutralleiters bei > 33 % 3. Harmonischer (nur Hinweis)
- automatische Bemessung oder Prüfung aller Stromkreise
## API
`POST /api/cable-sizing/calculate` – zustandslos, liest und schreibt keine
Projektdaten.
```jsonc
{
"phase": 1, // 1 | 3
"mode": "power", // "power" | "current"
"powerKw": 4.2, // bei mode "power"
"currentA": 18.3, // bei mode "current"
"cosPhi": 0.95, // 0,1 … 1
"voltage": 230,
"lengthM": 23.5,
"layingMethod": "C", // A1 | A2 | B1 | B2 | C | D1 | D2 | E
"conductorMaterial": "copper", // copper | aluminum
"insulation": "pvc", // pvc | xlpe
"ambientTemperatureC": 30, // Luft; bei D1/D2 Bodentemperatur
"groupingCircuits": 1, // 1 … 100, Tabellen bis 20
"maxVoltageDropPercent": 3,
"harmonicNeutralLoad": "none", // none | 15to33Percent | over33Percent
"existingProtection": { "ratedCurrentA": 16, "kind": "breaker" }, // optional; kind: breaker | fuse
"circuitCategory": "single_phase" // optional
}
```
Antwort: `{ "result": CableSizingResult, "alerts": CableSizingAlert[] }`.
`result.supported = false` mit `unsupportedReason` (`temperature` oder
`grouping`), wenn die Eingaben außerhalb der Tabellen liegen.
`GET /api/cable-sizing/laying-methods` und `GET /api/cable-sizing/insulation-materials`
liefern die Auswahllisten.

View file

@ -391,6 +391,28 @@ inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a `displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly. project device never triggers synchronization implicitly.
## Cable Sizing (optional module)
Stateless calculator endpoints mounted at `/api/cable-sizing`. They neither
read nor write project data; applying a result uses `circuit.update` through
`POST /projects/:projectId/commands`.
- `POST /cable-sizing/calculate`
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D1` |
`D2` | `E`), conductorMaterial, insulation (`pvc` | `xlpe`),
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
harmonicNeutralLoad, optional `existingProtection`
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
is `false` with `unsupportedReason` when temperature or grouping lie
outside the tables
- unknown fields are rejected (`400`)
- `GET /cable-sizing/laying-methods`
- `GET /cable-sizing/insulation-materials`
Full request example and calculation rules: `docs/cable-sizing-module.md`.
## Revit CSV Foundation ## Revit CSV Foundation
- `GET /projects/:projectId/external-csv/configuration` - `GET /projects/:projectId/external-csv/configuration`

View file

@ -17,8 +17,12 @@
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no - The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model. versioning, permissions or controlled synchronization model.
- Final electrical sizing logic is not implemented yet. - There is no automatic sizing of all circuits. The optional cable sizing
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. module suggests a cross-section for one circuit on demand (IEC 60364-5-52
Annex B tables; no short-circuit, disconnection-time or selectivity checks,
no methods F/G, soil resistivity fixed at 2.5 K·m/W) and does not validate
manually entered cable values. National tables (DIN VDE 0298-4) must be
checked by the planner; see `docs/cable-sizing-module.md`.
- Sorting is view-only until users explicitly apply sorted order. - Sorting is view-only until users explicitly apply sorted order.
- Complete-circuit drag supports one circuit at a time between groups of the - Complete-circuit drag supports one circuit at a time between groups of the
same category. Cross-category moves and multi-circuit moves across group same category. Cross-category moves and multi-circuit moves across group

View file

@ -452,6 +452,18 @@ werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand. Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
## Leitungsberechnung
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
Querschnittsempfehlung für einen Stromkreis (IEC 60364-5-52 Anhang B:
Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan). Die
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
Querschnitt und Länge über den regulären `circuit.update`-Befehl, also mit
Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
`docs/cable-sizing-module.md`.
## Persistenz und Migration ## Persistenz und Migration
- SQLite ist die aktuell unterstützte Datenbank. - SQLite ist die aktuell unterstützte Datenbank.

View file

@ -46,7 +46,9 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E: editor projection and editing. - [x] Phase E: editor projection and editing.
- [x] Phase F: documentation and full GUI verification. - [x] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until - [ ] Keep full electrical sizing and cable-dimensioning rules separate until
the planner supplies the calculation requirements. the planner supplies the calculation requirements. The optional per-circuit
cable sizing module (`docs/cable-sizing-module.md`) exists; its tables must
be confirmed by the planner against DIN VDE 0298-4.
- Detailed specification: - Detailed specification:
`docs/spec/09-distribution-board-components-and-protection-groups.md` `docs/spec/09-distribution-board-components-and-protection-groups.md`

View file

@ -0,0 +1,689 @@
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/;
// the module is an optional calculator attached to the circuit list and never
// writes project data itself (see docs/cable-sizing-module.md).
//
// Reference data: IEC 60364-5-52:2009, Annex B (tables B.52.2 to B.52.5,
// B.52.10 to B.52.19). Every value was cross-checked against publicly
// available reproductions of these tables; where sources disagreed the lower,
// i.e. safer, value is used. The table-by-table verification record is in
// docs/cable-sizing-module.md. National tables (DIN VDE 0298-4) may differ in
// individual values and must be checked by the planner against the norm text.
// -- Reference installation methods (IEC 60364-5-52, Table B.52.1) ----------
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D1", "D2", "E"] 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",
D1: "ground",
D2: "ground",
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
A1: "A1 – Einzeladern im Rohr in wärmegedämmter Wand",
A2: "A2 – Mehradrige Leitung im Rohr in wärmegedämmter Wand",
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
D1: "D1 – Leitung im Rohr oder Kabelschutzrohr im Erdreich",
D2: "D2 – Kabel direkt im Erdreich",
E: "E – Mehradrige Leitung frei in Luft",
};
// -- 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 (70 °C), z. B. NYM, NYY",
xlpe: "VPE/XLPE (90 °C), z. B. N2XH, NHXH",
};
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// -- Harmonic neutral loading (IEC 60364-5-52, Annex E, 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",
over33Percent: "3. Harmonische > 33 % – Neutralleiter wie Außenleiter dimensionieren",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
// -- Current-carrying capacity tables ----------------------------------------
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;
type CapacityRow = readonly (number | null)[];
type CapacityTable = Record<LayingMethod, Record<CoreCount, CapacityRow>>;
const n = null;
/** Iz in A at 30 °C air / 20 °C ground, soil resistivity 2.5 K·m/W. */
export const CURRENT_CAPACITY_A: Record<
InsulationMaterial,
Record<ConductorMaterial, CapacityTable>
> = {
pvc: {
copper: {
// B.52.2 / B.52.4; E: B.52.10 multi-core. A1 2L 240 mm²: sources 320/321 → 320.
// E 3L 70/120 mm²: sources 196/198 and 276/278 → 196 and 276.
A1: {
2: [14.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 320],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
},
A2: {
2: [14, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
},
B1: {
2: [17.5, 24, 32, 41, 57, 76, 101, 125, 151, 192, 232, 269, 300, 341, 400],
3: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, 262, 296, 346],
},
B2: {
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 133, 168, 201, 232, 258, 294, 344],
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, 225, 255, 297],
},
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],
},
D1: {
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
},
D2: {
2: [22, 28, 38, 48, 64, 83, 110, 132, 156, 192, 230, 261, 293, 331, 382],
3: [19, 24, 33, 41, 54, 70, 92, 110, 130, 162, 193, 220, 246, 278, 320],
},
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],
},
},
aluminum: {
// B.52.2 / B.52.4 aluminium; E: B.52.11. No values below 2.5 mm²
// (D2 below 16 mm²).
A1: {
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
},
A2: {
2: [n, 14.5, 19.5, 25, 33, 44, 58, 71, 86, 108, 130, 150, 172, 195, 229],
3: [n, 13.5, 17.5, 23, 31, 41, 53, 65, 78, 98, 118, 135, 155, 176, 207],
},
B1: {
2: [n, 18.5, 25, 32, 44, 60, 79, 97, 118, 150, 181, 210, 234, 266, 312],
3: [n, 16.5, 22, 28, 39, 53, 70, 86, 104, 133, 161, 186, 204, 230, 269],
},
B2: {
2: [n, 17.5, 24, 30, 41, 54, 71, 86, 104, 131, 157, 181, 201, 230, 269],
3: [n, 15.5, 21, 27, 36, 48, 62, 77, 92, 116, 139, 160, 176, 199, 232],
},
C: {
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
},
D1: {
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
},
D2: {
2: [n, n, n, n, n, 63, 82, 98, 117, 145, 173, 200, 224, 255, 298],
3: [n, n, n, n, n, 53, 69, 83, 99, 122, 148, 169, 189, 214, 250],
},
E: {
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
},
},
},
xlpe: {
copper: {
// B.52.3 / B.52.5; E: B.52.12 multi-core.
A1: {
2: [19, 26, 35, 45, 61, 81, 106, 131, 158, 200, 241, 278, 318, 362, 424],
3: [17, 23, 31, 40, 54, 73, 95, 117, 141, 179, 216, 249, 285, 324, 380],
},
A2: {
2: [18.5, 25, 33, 42, 57, 76, 99, 121, 145, 183, 220, 253, 290, 329, 386],
3: [16.5, 22, 30, 38, 51, 68, 89, 109, 130, 164, 197, 227, 259, 295, 346],
},
B1: {
2: [23, 31, 42, 54, 75, 100, 133, 164, 198, 253, 306, 354, 393, 449, 528],
3: [20, 28, 37, 48, 66, 88, 117, 144, 175, 222, 269, 312, 342, 384, 450],
},
B2: {
2: [22, 30, 40, 51, 69, 91, 119, 146, 175, 221, 265, 305, 334, 384, 459],
3: [19.5, 26, 35, 44, 60, 80, 105, 128, 154, 194, 233, 268, 300, 340, 398],
},
C: {
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
},
D1: {
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
},
D2: {
2: [27, 35, 46, 58, 77, 100, 129, 155, 183, 225, 270, 306, 343, 387, 448],
3: [23, 30, 39, 49, 65, 84, 107, 129, 153, 188, 226, 257, 287, 324, 375],
},
E: {
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
},
},
aluminum: {
// B.52.3 / B.52.5 aluminium; E: B.52.13.
A1: {
2: [n, 20, 27, 35, 48, 64, 84, 103, 125, 158, 191, 220, 253, 288, 338],
3: [n, 19, 25, 32, 44, 58, 76, 94, 113, 142, 171, 197, 226, 256, 300],
},
A2: {
2: [n, 19.5, 26, 33, 45, 60, 78, 96, 115, 145, 175, 201, 230, 262, 307],
3: [n, 18, 24, 31, 41, 55, 71, 87, 104, 131, 157, 180, 206, 233, 273],
},
B1: {
2: [n, 25, 33, 43, 59, 79, 105, 130, 157, 200, 242, 281, 307, 351, 412],
3: [n, 22, 29, 38, 52, 71, 93, 116, 140, 179, 217, 251, 267, 300, 351],
},
B2: {
2: [n, 23, 31, 40, 54, 72, 94, 115, 138, 175, 210, 242, 261, 300, 358],
3: [n, 21, 28, 35, 48, 64, 84, 103, 124, 156, 188, 216, 240, 272, 318],
},
C: {
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
},
D1: {
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
},
D2: {
2: [n, n, n, n, n, 76, 98, 117, 139, 170, 204, 233, 261, 296, 343],
3: [n, n, n, n, n, 64, 82, 98, 117, 144, 172, 197, 220, 250, 290],
},
E: {
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
},
},
},
};
// -- Correction factors ------------------------------------------------------
/** B.52.14 (air, reference 30 °C) and B.52.15 (ground, reference 20 °C). */
export const TEMPERATURE_FACTORS: Record<
"air" | "ground",
Record<InsulationMaterial, Record<number, number>>
> = {
air: {
pvc: { 10: 1.22, 15: 1.17, 20: 1.12, 25: 1.06, 30: 1.0, 35: 0.94, 40: 0.87, 45: 0.79, 50: 0.71, 55: 0.61, 60: 0.5 },
xlpe: {
10: 1.15, 15: 1.12, 20: 1.08, 25: 1.04, 30: 1.0, 35: 0.96, 40: 0.91, 45: 0.87,
50: 0.82, 55: 0.76, 60: 0.71, 65: 0.65, 70: 0.58, 75: 0.5, 80: 0.41,
},
},
ground: {
pvc: { 10: 1.1, 15: 1.05, 20: 1.0, 25: 0.95, 30: 0.89, 35: 0.84, 40: 0.77, 45: 0.71, 50: 0.63, 55: 0.55, 60: 0.45 },
xlpe: {
10: 1.07, 15: 1.04, 20: 1.0, 25: 0.96, 30: 0.93, 35: 0.89, 40: 0.85, 45: 0.8,
50: 0.76, 55: 0.71, 60: 0.65, 65: 0.6, 70: 0.53, 75: 0.46, 80: 0.38,
},
},
};
export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
air: 30,
ground: 20,
};
/**
* Grouping reduction factors for touching circuits/cables:
* air (A1–C, E): B.52.17 item 1 "bunched in air, on a surface, embedded or
* enclosed" – the most restrictive arrangement, also used for E (single
* layers on trays would allow higher factors);
* D1: B.52.19 A (multi-core cables in touching ducts);
* D2: B.52.18 (cables direct in ground, touching).
*/
export const GROUPING_FACTORS: Record<"air" | "D1" | "D2", Record<number, number>> = {
air: { 1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52, 9: 0.5, 12: 0.45, 16: 0.41, 20: 0.38 },
D1: {
1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6, 7: 0.57, 8: 0.54, 9: 0.52, 10: 0.49,
11: 0.47, 12: 0.45, 13: 0.44, 14: 0.42, 15: 0.41, 16: 0.39, 17: 0.38, 18: 0.37, 19: 0.35, 20: 0.34,
},
D2: { 1: 1.0, 2: 0.75, 3: 0.65, 4: 0.6, 5: 0.55, 6: 0.5, 7: 0.45, 8: 0.43, 9: 0.41, 12: 0.36, 16: 0.32, 20: 0.29 },
};
export const MAX_GROUPING_CIRCUITS = 20;
function groupingTableFor(method: LayingMethod) {
return method === "D1" ? GROUPING_FACTORS.D1 : method === "D2" ? GROUPING_FACTORS.D2 : GROUPING_FACTORS.air;
}
/**
* Looks up a tabulated factor. Values between tabulated keys use the next
* higher key (the lower, safer factor); values above the table are not
* supported (null) and values below the smallest key use the smallest key
* (whose factor is lower than the true one, i.e. safe).
*/
function lookupConservative(table: Record<number, number>, value: number): { key: number; factor: number } | null {
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
const key = keys.find((candidate) => candidate >= value);
if (key === undefined) {
return null;
}
return { key, factor: table[key] };
}
/** Tabulated temperatures offered by the UI for a laying method and insulation. */
export function temperatureOptions(method: LayingMethod, insulation: InsulationMaterial): number[] {
return Object.keys(TEMPERATURE_FACTORS[LAYING_METHOD_GROUP[method]][insulation])
.map(Number)
.sort((a, b) => a - b);
}
// -- Conductor resistivity for the voltage drop -------------------------------
/** Conductivity in m/(Ω·mm²) at the maximum operating temperature (conservative). */
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
// -- Practical minimum cross-sections by circuit category --------------------
// A planning convention from docs/spec/06-future-sizing-and-calculations.md
// ("Standard Single-Phase Circuits ... cable cross-section: 2.5 mm²"), not a
// calculation result. It only ever raises a recommendation.
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
single_phase: 2.5,
};
// -- Protective device --------------------------------------------------------
export const PROTECTION_KINDS = ["breaker", "fuse"] as const;
export type ProtectionKind = (typeof PROTECTION_KINDS)[number];
export interface ExistingProtection {
ratedCurrentA: number;
/** breaker: circuit-breaker (I2 ≤ 1.45 In); fuse: gG fuse (I2 up to 2.1 In). */
kind: ProtectionKind;
}
/**
* Conventional tripping/fusing current I2 as multiple of In.
* Circuit-breakers (IEC 60898-1): 1.45. gG fuses (IEC 60269-2):
* In ≤ 4 A: 2.1, 4 A < In < 16 A: 1.9, In ≥ 16 A: 1.6.
*/
export function conventionalTrippingFactor(protection: ExistingProtection): number {
if (protection.kind === "breaker") {
return 1.45;
}
if (protection.ratedCurrentA <= 4) {
return 2.1;
}
return protection.ratedCurrentA < 16 ? 1.9 : 1.6;
}
/**
* Minimum Iz required by overload protection (IEC 60364-4-43, 433.1):
* In ≤ Iz and I2 ≤ 1.45 · Iz.
*/
export function minimumIzForProtection(protection: ExistingProtection): number {
return Math.max(
protection.ratedCurrentA,
(conventionalTrippingFactor(protection) * protection.ratedCurrentA) / 1.45
);
}
// -- 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 (project single-/three-phase voltage). */
voltage: number;
/** Single-run cable length in m. */
lengthM: number;
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
/** Ambient air temperature, or ground temperature for D1/D2. */
ambientTemperatureC: number;
/** Number of grouped circuits or multi-core cables, including this one. */
groupingCircuits: number;
maxVoltageDropPercent: number;
harmonicNeutralLoad: HarmonicNeutralLoad;
existingProtection?: ExistingProtection;
circuitCategory?: CircuitCategory;
}
export interface CrossSectionRow {
crossSectionMm2: number;
/** Tabulated Iz; null if the table has no value for this size. */
ratedCurrentA: number | null;
correctedCurrentA: number | null;
currentSufficient: boolean | null;
voltageDropPercent: number | null;
voltageDropSufficient: boolean | null;
/** Longest single run meeting maxVoltageDropPercent at this load. */
maxLengthForVoltageDropM: number | null;
recommended: boolean;
}
export interface ProtectionCoordinationResult {
ratedCurrentA: number;
kind: ProtectionKind;
/** I2 / In used for the check. */
conventionalTrippingFactor: number;
/** In ≤ Iz and I2 ≤ 1.45 · Iz for the recommended cross-section. */
coordinated: boolean;
}
export type UnsupportedReason = "temperature" | "grouping";
export interface CableSizingResult {
/** false: the inputs are outside the tabulated range – no numeric result. */
supported: boolean;
unsupportedReason: UnsupportedReason | null;
operatingCurrentA: number;
/**
* Current the cross-section must carry after correction:
* max(Ib, In, I2/1.45) with an existing protective device, otherwise Ib.
*/
designCurrentA: number;
/** Tabulated temperature/grouping actually used (rounded to the safe side). */
temperatureUsedC: number | null;
groupingUsed: number | null;
temperatureFactor: number | null;
groupingFactor: number | null;
crossSectionByCapacityMm2: number | null;
crossSectionByVoltageDropMm2: number | null;
recommendedCrossSectionMm2: number | null;
voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number;
harmonicReductionApplied: boolean;
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.existingProtection
? Math.max(ib, minimumIzForProtection(input.existingProtection))
: ib;
const group = LAYING_METHOD_GROUP[input.layingMethod];
const temperature = lookupConservative(
TEMPERATURE_FACTORS[group][input.insulation],
input.ambientTemperatureC
);
const grouping =
input.groupingCircuits <= MAX_GROUPING_CIRCUITS
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
: null;
const empty: CableSizingResult = {
supported: false,
unsupportedReason: !temperature ? "temperature" : "grouping",
operatingCurrentA: ib,
designCurrentA,
temperatureUsedC: temperature?.key ?? null,
groupingUsed: grouping?.key ?? null,
temperatureFactor: temperature?.factor ?? null,
groupingFactor: grouping?.factor ?? null,
crossSectionByCapacityMm2: null,
crossSectionByVoltageDropMm2: null,
recommendedCrossSectionMm2: null,
voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1,
harmonicReductionApplied: false,
practicalMinimumApplied: false,
rows: [],
protectionCoordination: null,
};
if (!temperature || !grouping) {
return empty;
}
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
const harmonicReductionApplied =
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
const combinedDerationFactor =
temperature.factor * grouping.factor * (harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1);
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
const kappa = KAPPA[input.conductorMaterial];
const ratedCurrents =
CURRENT_CAPACITY_A[input.insulation][input.conductorMaterial][input.layingMethod][coreCount];
const correctedCurrents = ratedCurrents.map((value) =>
value == null ? null : value * 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, index) => ratedCurrents[index] != null && value <= input.maxVoltageDropPercent
);
let indexRecommended =
indexByCapacity >= 0 && indexByVoltageDrop >= 0 ? Math.max(indexByCapacity, indexByVoltageDrop) : -1;
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 && ratedCurrents[minimumIndex] != null) {
indexRecommended = minimumIndex;
practicalMinimumApplied = true;
}
}
const voltageDropCurrentBasis = ib * input.cosPhi;
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, index) => {
const rated = ratedCurrents[index];
const corrected = correctedCurrents[index];
return {
crossSectionMm2: crossSection,
ratedCurrentA: rated,
correctedCurrentA: corrected,
currentSufficient: corrected == null ? null : corrected >= designCurrentA,
voltageDropPercent: rated == null ? null : voltageDrops[index],
voltageDropSufficient: rated == null ? null : voltageDrops[index] <= input.maxVoltageDropPercent,
maxLengthForVoltageDropM:
rated == null || voltageDropCurrentBasis <= 0
? null
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
(100 * voltageDropCoefficient * voltageDropCurrentBasis),
recommended: index === indexRecommended,
};
});
let protectionCoordination: ProtectionCoordinationResult | null = null;
if (input.existingProtection && indexRecommended >= 0) {
const iz = rows[indexRecommended].correctedCurrentA;
const factor = conventionalTrippingFactor(input.existingProtection);
const inA = input.existingProtection.ratedCurrentA;
protectionCoordination = {
ratedCurrentA: inA,
kind: input.existingProtection.kind,
conventionalTrippingFactor: factor,
coordinated: iz != null && inA <= iz && factor * inA <= 1.45 * iz,
};
}
return {
...empty,
supported: true,
unsupportedReason: null,
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,
};
}
// -- Alerts -------------------------------------------------------------------
export interface CableSizingAlert {
kind: "critical" | "warn" | "info" | "ok";
text: string;
}
const numberFormat = (value: number, digits = 1) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
const crossSectionFormat = (value: number) =>
`${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} mm²`;
export function buildCableSizingAlerts(input: CableSizingInput, result: CableSizingResult): CableSizingAlert[] {
const alerts: CableSizingAlert[] = [];
const group = LAYING_METHOD_GROUP[input.layingMethod];
if (!result.supported) {
alerts.push({
kind: "critical",
text:
result.unsupportedReason === "temperature"
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt außerhalb der Tabelle (max. ${MAX_GROUPING_CIRCUITS}) – keine Berechnung möglich.`,
});
return alerts;
}
if (result.temperatureUsedC !== input.ambientTemperatureC) {
alerts.push({
kind: "info",
text: `Temperatur auf den nächsten Tabellenwert ${result.temperatureUsedC} °C gerundet (sichere Seite).`,
});
}
if (result.groupingUsed !== input.groupingCircuits) {
alerts.push({
kind: "info",
text: `Häufung auf den nächsten Tabellenwert ${result.groupingUsed} Stromkreise gerundet (sichere Seite).`,
});
}
const byCapacity = result.crossSectionByCapacityMm2;
const byDrop = result.crossSectionByVoltageDropMm2;
if (byCapacity == null) {
const dueToProtection =
input.existingProtection != null && result.designCurrentA > result.operatingCurrentA;
alerts.push({
kind: "critical",
text: dueToProtection
? `Das vorhandene Schutzorgan (${input.existingProtection!.ratedCurrentA} A) verlangt Iz ≥ ${numberFormat(result.designCurrentA)} A – das erreicht bei Verlegeart ${input.layingMethod} kein Querschnitt bis 240 mm². Verlegeart oder Schutzorgan prüfen.`
: `Der Betriebsstrom ${numberFormat(result.operatingCurrentA)} A übersteigt bei Verlegeart ${input.layingMethod} die Belastbarkeit aller Querschnitte bis 240 mm².`,
});
} else if (byDrop == null) {
alerts.push({
kind: "critical",
text: `Der Spannungsfall von ${numberFormat(input.maxVoltageDropPercent)} % wird bei ${input.lengthM} m mit keinem Querschnitt bis 240 mm² eingehalten.`,
});
} else if (byDrop > byCapacity) {
alerts.push({
kind: "warn",
text: `Der Spannungsfall ist maßgeblich: ${crossSectionFormat(byCapacity)} reichen thermisch, für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} % sind ${crossSectionFormat(byDrop)} nötig.`,
});
}
if (result.harmonicReductionApplied) {
alerts.push({
kind: "info",
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
});
}
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
alerts.push({
kind: "warn",
text: "3. Harmonische > 33 %: Der Neutralleiter ist nach dem Neutralleiterstrom zu bemessen – hier nicht berücksichtigt.",
});
}
if (group === "ground") {
alerts.push({
kind: "info",
text: "Erdverlegung: Bodenwärmewiderstand 2,5 K·m/W angenommen (Tabellenreferenz).",
});
}
if (result.practicalMinimumApplied) {
alerts.push({
kind: "info",
text: `Auf ${crossSectionFormat(result.recommendedCrossSectionMm2!)} angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine Rechenanforderung).`,
});
}
if (result.protectionCoordination) {
const coordination = result.protectionCoordination;
const kindLabel = coordination.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG";
alerts.push(
coordination.coordinated
? {
kind: "info",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A berücksichtigt: In ≤ Iz und I₂ = ${numberFormat(coordination.conventionalTrippingFactor, 2)} · In ≤ 1,45 · Iz. Kurzschlussschutz und Abschaltbedingungen sind nicht Teil dieser Berechnung.`,
}
: {
kind: "warn",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A ist mit dem empfohlenen Querschnitt nicht koordiniert (In ≤ Iz, I₂ ≤ 1,45 · Iz).`,
}
);
}
const recommendedRow = result.rows.find((row) => row.recommended);
if (recommendedRow && byCapacity != null && byDrop != null) {
if (!alerts.some((alert) => alert.kind === "warn" || alert.kind === "critical")) {
alerts.push({
kind: "ok",
text: `${crossSectionFormat(recommendedRow.crossSectionMm2)} ${input.conductorMaterial === "aluminum" ? "Al" : "Cu"} erfüllt bei ${input.lengthM} m Belastbarkeit (${numberFormat(result.designCurrentA)} A) und Spannungsfall (ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %).`,
});
}
if (recommendedRow.maxLengthForVoltageDropM != null) {
alerts.push({
kind: "info",
text: `Maximale Länge bei ${crossSectionFormat(recommendedRow.crossSectionMm2)} für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
});
}
}
return alerts;
}

View file

@ -0,0 +1,47 @@
// Request 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,
PROTECTION_KINDS,
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().finite().min(0).optional(),
currentA: z.number().finite().min(0).optional(),
cosPhi: z.number().finite().min(0.1).max(1),
voltage: z.number().finite().positive(),
lengthM: z.number().finite().min(0).max(10000),
layingMethod: z.enum(LAYING_METHODS),
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
insulation: z.enum(INSULATION_MATERIALS),
ambientTemperatureC: z.number().finite().min(-40).max(120),
groupingCircuits: z.number().int().min(1).max(100),
maxVoltageDropPercent: z.number().finite().min(0.5).max(10),
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
existingProtection: z
.object({
ratedCurrentA: z.number().finite().positive(),
kind: z.enum(PROTECTION_KINDS),
})
.strict()
.optional(),
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
})
.strict()
.refine((value) => (value.mode === "power" ? value.powerKw != null : value.currentA != null), {
message: "powerKw is required for mode 'power', currentA for mode 'current'",
});
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
// The request schema must stay assignable to the domain input shape.
type AssertAssignable<T extends CableSizingInput> = T;
export type CableSizingRequestIsDomainInput = AssertAssignable<CableSizingRequest>;

View file

@ -0,0 +1,37 @@
// Thin fetch client for the cable-sizing module, kept separate from
// ../utils/api.ts so the module stays self-contained (see
// docs/cable-sizing-module.md).
import type {
CableSizingAlert,
CableSizingInput,
CableSizingResult,
} from "../../cable-sizing/domain/cable-sizing-calculation";
export interface CalculateCableSizingResponse {
result: CableSizingResult;
alerts: CableSizingAlert[];
}
export async function calculateCableSizing(
input: CableSizingInput
): Promise<CalculateCableSizingResponse> {
const response = await fetch("/api/cable-sizing/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
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<CalculateCableSizingResponse>;
}

View file

@ -0,0 +1,54 @@
// Derives the cable-sizing inputs that follow from the circuit itself, so the
// planner starts from the circuit's real values instead of fixed defaults.
import type {
CircuitCategory,
ExistingProtection,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto } from "../types";
/** Three-phase if the circuit group or any device row is three-phase. */
export function deriveCircuitPhase(
circuit: CircuitTreeCircuitDto,
circuitCategory?: CircuitCategory
): 1 | 3 {
return circuitCategory === "three_phase" ||
circuit.deviceRows.some((row) => row.phaseType === "three_phase")
? 3
: 1;
}
/**
* Power-weighted cos φ of the circuit: P / S with S = Σ P_row / cos φ_row.
* Rows without cos φ count as 1; a circuit without load yields 1.
*/
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
let activePowerKw = 0;
let apparentPowerKva = 0;
for (const row of circuit.deviceRows) {
activePowerKw += row.rowTotalPower;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
if (activePowerKw <= 0 || apparentPowerKva <= 0) {
return 1;
}
return Math.round((activePowerKw / apparentPowerKva) * 100) / 100;
}
/**
* Maps the circuit's protective device to the overload-protection kind of the
* calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH
* fuse links "fuse"; a residual-current device without overcurrent release
* (FI) provides no overload protection and is ignored.
*/
export function toExistingProtection(
protection: CircuitTreeProtectionDeviceDto | undefined
): ExistingProtection | undefined {
if (!protection || protection.type === "FI") {
return undefined;
}
const kind =
protection.type === "LS" || protection.type === "FI_LS" || protection.type === "AFDD"
? "breaker"
: "fuse";
return { ratedCurrentA: protection.ratedCurrentA, kind };
}

View file

@ -0,0 +1,571 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
HARMONIC_NEUTRAL_LOAD_LABELS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
MAX_GROUPING_CIRCUITS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
REFERENCE_TEMPERATURE_C,
temperatureOptions,
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 {
deriveCircuitCosPhi,
deriveCircuitPhase,
toExistingProtection,
} from "./cable-sizing-defaults";
import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
isSaving: boolean;
onClose: () => void;
onApply: (patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) => Promise<void>;
}
// Extracts the first decimal number from a free-text cross-section entry
// like "2.5 mm²" or "2,5"; used only for the manual-entry sanity check.
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;
}
const formatNumber = (value: number, digits: number) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
export function CableSizingModal({
circuit,
circuitCategory,
singlePhaseVoltageV,
threePhaseVoltageV,
isSaving,
onClose,
onApply,
}: CableSizingModalProps) {
const derivedPhase = deriveCircuitPhase(circuit, circuitCategory);
const derivedCosPhi = deriveCircuitCosPhi(circuit);
const existingProtection = toExistingProtection(circuit.protectionDevice);
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(derivedCosPhi);
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
const [phase, setPhase] = useState<1 | 3>(derivedPhase);
// Manual entry: applying never requires a calculation. Prefilled from the
// circuit so an existing cable specification is not lost.
const [manualCableType, setManualCableType] = useState(circuit.cableType ?? "");
const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? "");
const [calculating, setCalculating] = useState(false);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(null);
const voltage =
phase === derivedPhase && circuit.voltage
? circuit.voltage
: phase === 3
? threePhaseVoltageV
: singlePhaseVoltageV;
const group = LAYING_METHOD_GROUP[layingMethod];
const availableTemperatures = temperatureOptions(layingMethod, insulation);
const input: CableSizingInput = useMemo(
() => ({
phase,
mode: "power",
powerKw: circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtection,
circuitCategory,
}),
[
phase,
circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtection?.ratedCurrentA,
existingProtection?.kind,
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 `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² ist kein Normquerschnitt (${CROSS_SECTIONS_MM2.map((value) => formatNumber(value, value % 1 ? 1 : 0)).join(", ")}).`;
}
if (
result?.supported &&
result.recommendedCrossSectionMm2 != null &&
parsed < result.recommendedCrossSectionMm2
) {
return `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² ist kleiner als die zuletzt berechnete Empfehlung (${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²).`;
}
const practicalMinimum = circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory]
: undefined;
if (practicalMinimum != null && parsed < practicalMinimum) {
return `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${formatNumber(practicalMinimum, 1)} mm²).`;
}
return null;
}, [manualCrossSection, result, circuitCategory]);
function resetResult() {
setResult(null);
setAlerts([]);
}
function applyReferenceTemperature(method: LayingMethod, material: InsulationMaterial) {
const options = temperatureOptions(method, material);
const reference = REFERENCE_TEMPERATURE_C[LAYING_METHOD_GROUP[method]];
setAmbientTemperatureC(options.includes(reference) ? reference : options[0]);
}
async function handleCalculate() {
setCalculating(true);
setError(null);
try {
const response = await calculateCableSizing(input);
setResult(response.result);
setAlerts(response.alerts);
// The suggestion fills the manual field, which stays the single source
// of truth for what gets applied.
if (response.result.supported && response.result.recommendedCrossSectionMm2) {
const size = response.result.recommendedCrossSectionMm2;
setManualCrossSection(`${formatNumber(size, size % 1 ? 1 : 0)} 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,
});
}
const recommendedRow = result?.rows.find((row) => row.recommended);
return (
<FormModal
description="Querschnittsempfehlung nach IEC 60364-5-52 Anhang B (Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan) oder direkte Eingabe. Die Empfehlung vor der Übernahme fachlich prüfen; nationale Tabellen (DIN VDE 0298-4) können abweichen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Leitungsberechnung – ${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));
resetResult();
}}
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);
resetResult();
}}
value={phase}
>
<option value={1}>1~ ({derivedPhase === 1 && circuit.voltage ? circuit.voltage : singlePhaseVoltageV} V)</option>
<option value={3}>3~ ({derivedPhase === 3 && circuit.voltage ? circuit.voltage : threePhaseVoltageV} V)</option>
</select>
{phase !== derivedPhase ? (
<div className="form-text text-warning">
Abweichend vom Stromkreis ({derivedPhase === 3 ? "3~" : "1~"}).
</div>
) : null}
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-power">
Leistung (aus Stromkreis)
</label>
<input
className="form-control"
disabled
id="cable-sizing-power"
value={`${formatNumber(circuit.circuitTotalPower, 2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => {
const method = event.target.value as LayingMethod;
setLayingMethod(method);
applyReferenceTemperature(method, insulation);
resetResult();
}}
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) => {
const material = event.target.value as InsulationMaterial;
setInsulation(material);
if (!temperatureOptions(layingMethod, material).includes(ambientTemperatureC)) {
applyReferenceTemperature(layingMethod, material);
}
resetResult();
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<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);
resetResult();
}}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-temperature">
{group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => {
setAmbientTemperatureC(Number(event.target.value));
resetResult();
}}
value={ambientTemperatureC}
>
{availableTemperatures.map((temperature) => (
<option key={temperature} value={temperature}>
{temperature} °C
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise)
</label>
<input
className="form-control"
id="cable-sizing-grouping"
max={MAX_GROUPING_CIRCUITS}
min={1}
onChange={(event) => {
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
resetResult();
}}
step={1}
type="number"
value={groupingCircuits}
/>
<div className="form-text">
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses.
</div>
</div>
{phase === 3 ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungen im Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) => {
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad);
resetResult();
}}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-control"
id="cable-sizing-cosphi"
max={1}
min={0.1}
onChange={(event) => {
setCosPhi(Math.min(1, Math.max(0.1, Number(event.target.value) || 1)));
resetResult();
}}
step={0.01}
type="number"
value={cosPhi}
/>
<div className="form-text">
Aus den Gerätezeilen: {formatNumber(derivedCosPhi, 2)}
</div>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. ΔU [%]
</label>
<input
className="form-control"
id="cable-sizing-voltage-drop"
max={10}
min={0.5}
onChange={(event) => {
setMaxVoltageDropPercent(Number(event.target.value) || 3);
resetResult();
}}
step={0.5}
type="number"
value={maxVoltageDropPercent}
/>
</div>
<div className="col-12">
<div className="form-text">
Schutzorgan:{" "}
{existingProtection
? `${existingProtection.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG"} ${existingProtection.ratedCurrentA} A (aus dem Stromkreis, In ≤ Iz und I₂ ≤ 1,45 · Iz)`
: "keines mit Überstromschutz hinterlegt – Bemessung nur nach Betriebsstrom"}
</div>
</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> : null}
{result ? (
<div className="mt-3">
{result.supported ? (
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom I<sub>B</sub></div>
<div>{formatNumber(result.operatingCurrentA, 1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Erforderliches I<sub>z</sub></div>
<div>
<strong>{formatNumber(result.designCurrentA, 1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2 != null
? `${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²`
: "–"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${formatNumber(result.voltageDropAtRecommendedPercent, 2)} %`
: "–"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor gesamt</div>
<div>{formatNumber(result.combinedDerationFactor, 2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Temperatur / Häufung (gerechnet)</div>
<div>
{result.temperatureUsedC} °C ({formatNumber(result.temperatureFactor ?? 1, 2)}) /{" "}
{result.groupingUsed} ({formatNumber(result.groupingFactor ?? 1, 2)})
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge für ΔU</div>
<div>
{recommendedRow?.maxLengthForVoltageDropM != null
? `${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m`
: "–"}
</div>
</div>
</div>
) : null}
{alerts.map((alert, index) => (
<div
className={`alert mt-2 mb-0 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
</div>
) : null}
<hr className="my-3" />
<p className="text-muted small mb-2">
Kabeltyp und Querschnitt direkt eingeben oder die Empfehlung übernehmen.
Beides landet in denselben Feldern und kann vor dem Übernehmen angepasst
werden.
</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 3x2,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>
) : null}
</div>
</FormModal>
);
}

View file

@ -5,6 +5,7 @@ import {
ArrowDown, ArrowDown,
ArrowDown01, ArrowDown01,
ArrowUp, ArrowUp,
Calculator,
Check, Check,
Columns3, Columns3,
FilterX, FilterX,
@ -160,6 +161,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,
@ -319,6 +321,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);
@ -1439,6 +1443,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: "Leitungsberechnung übernehmen",
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;
@ -3185,6 +3214,21 @@ 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}
onApply={handleApplyCableSizing}
onClose={() => setCableSizingEditorCircuit(null)}
singlePhaseVoltageV={data.singlePhaseVoltageV}
threePhaseVoltageV={data.threePhaseVoltageV}
/>
) : null}
<div className="editor-toolbar"> <div className="editor-toolbar">
<IconButton <IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4530,6 +4574,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
<> <>
<IconButton
disabled={isSaving}
icon={Calculator}
label="Leitungsberechnung"
onClick={() => setCableSizingEditorCircuit(row.circuit!)}
tabIndex={-1}
title="Leitungsberechnung: Querschnitt ermitteln oder Kabel eintragen"
/>
<IconButton <IconButton
disabled={isSaving} disabled={isSaving}
icon={ListPlus} icon={ListPlus}

View file

@ -0,0 +1,37 @@
import type { Request, Response } from "express";
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
import {
buildCableSizingAlerts,
calculateCableSizing,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_LABELS,
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
// Stateless calculation: it neither reads nor writes project data. The
// planner applies a result through the existing circuit.update command, which
// keeps revisions and Undo/Redo intact. See docs/cable-sizing-module.md.
export 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 result = calculateCableSizing(parsed.data);
return res.json({ result, alerts: buildCableSizingAlerts(parsed.data, result) });
}
export function listLayingMethodsHandler(_req: Request, res: Response) {
return res.json(
LAYING_METHODS.map((method) => ({ method, label: LAYING_METHOD_LABELS[method] }))
);
}
export function listInsulationMaterialsHandler(_req: Request, res: Response) {
return res.json(
INSULATION_MATERIALS.map((insulation) => ({
insulation,
label: INSULATION_MATERIAL_LABELS[insulation],
}))
);
}

View file

@ -2,6 +2,7 @@ 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 { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.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";
import { closeDefaultDatabase } from "../db/client.js"; import { closeDefaultDatabase } from "../db/client.js";
@ -48,6 +49,7 @@ app.get("/health", (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.use("/api/cable-sizing", cableSizingRouter);
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);

View file

@ -0,0 +1,9 @@
import express from "express";
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
// Stateless calculator endpoints; no project data is read or written here.
export const cableSizingRouter = express.Router();
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
cableSizingRouter.get("/insulation-materials", cableSizingController.listInsulationMaterialsHandler);

View file

@ -0,0 +1,274 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCableSizingAlerts,
calculateCableSizing,
conventionalTrippingFactor,
CROSS_SECTIONS_MM2,
CURRENT_CAPACITY_A,
minimumIzForProtection,
temperatureOptions,
type CableSizingInput,
} from "../src/cable-sizing/domain/cable-sizing-calculation.js";
import { cableSizingRequestSchema } from "../src/cable-sizing/domain/cable-sizing-contracts.js";
import {
deriveCircuitCosPhi,
deriveCircuitPhase,
toExistingProtection,
} from "../src/frontend/components/cable-sizing-defaults.js";
import type { CircuitTreeCircuitDto } from "../src/frontend/types.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",
};
const capacity = (
insulation: "pvc" | "xlpe",
material: "copper" | "aluminum",
method: keyof (typeof CURRENT_CAPACITY_A)["pvc"]["copper"],
cores: 2 | 3,
crossSection: number
) =>
CURRENT_CAPACITY_A[insulation][material][method][cores][
CROSS_SECTIONS_MM2.indexOf(crossSection as (typeof CROSS_SECTIONS_MM2)[number])
];
describe("cable sizing reference tables (IEC 60364-5-52 Annex B)", () => {
it("contains the checked PVC copper values", () => {
// B.52.2: A1 two loaded conductors (the previously wrong row)
assert.equal(capacity("pvc", "copper", "A1", 2, 2.5), 19.5);
assert.equal(capacity("pvc", "copper", "A1", 2, 16), 61);
assert.equal(capacity("pvc", "copper", "A1", 2, 120), 210);
// B.52.2: B2 from 50 mm² (previously copied from B1)
assert.equal(capacity("pvc", "copper", "B2", 2, 50), 133);
assert.equal(capacity("pvc", "copper", "B2", 2, 120), 232);
// B.52.4 and D1/D2
assert.equal(capacity("pvc", "copper", "C", 3, 6), 41);
assert.equal(capacity("pvc", "copper", "D1", 2, 2.5), 29);
assert.equal(capacity("pvc", "copper", "D2", 3, 16), 70);
// Conflicting sources resolved to the lower value
assert.equal(capacity("pvc", "copper", "A1", 2, 240), 320);
assert.equal(capacity("pvc", "copper", "E", 3, 70), 196);
});
it("uses the aluminium and XLPE tables instead of flat factors", () => {
assert.equal(capacity("pvc", "aluminum", "A1", 2, 1.5), null);
assert.equal(capacity("pvc", "aluminum", "C", 3, 16), 59);
assert.equal(capacity("pvc", "aluminum", "D2", 2, 10), null);
assert.equal(capacity("xlpe", "copper", "C", 2, 2.5), 33);
assert.equal(capacity("xlpe", "aluminum", "E", 3, 25), 97);
});
it("has a complete, increasing row for every combination", () => {
for (const insulation of ["pvc", "xlpe"] as const) {
for (const material of ["copper", "aluminum"] as const) {
for (const [method, rows] of Object.entries(CURRENT_CAPACITY_A[insulation][material])) {
for (const cores of [2, 3] as const) {
const row = rows[cores];
assert.equal(row.length, CROSS_SECTIONS_MM2.length, `${insulation}/${material}/${method}/${cores}`);
const values = row.filter((value): value is number => value != null);
for (let index = 1; index < values.length; index += 1) {
assert.ok(values[index] > values[index - 1], `${insulation}/${material}/${method}/${cores}`);
}
}
}
}
}
});
});
describe("calculateCableSizing", () => {
it("sizes the reference case (1~, 5 kW, 230 V, 30 m, method C, Cu/PVC)", () => {
const result = calculateCableSizing(BASE_INPUT);
assert.equal(result.supported, 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);
const recommended = result.rows.find((row) => row.recommended)!;
// The maximum length is the inverse of the voltage-drop formula.
const atMaximum = calculateCableSizing({
...BASE_INPUT,
lengthM: recommended.maxLengthForVoltageDropM!,
});
assert.ok(Math.abs(atMaximum.rows.find((row) => row.crossSectionMm2 === 4)!.voltageDropPercent! - 3) < 1e-9);
});
it("rounds temperature and grouping to the next tabulated, safer value", () => {
const warm = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 32 });
assert.equal(warm.temperatureUsedC, 35);
assert.equal(warm.temperatureFactor, 0.94);
const cold = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 5 });
assert.equal(cold.temperatureUsedC, 10);
assert.equal(cold.temperatureFactor, 1.22);
const grouped = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 10 });
assert.equal(grouped.groupingUsed, 12);
assert.equal(grouped.groupingFactor, 0.45);
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 10 }, grouped);
assert.ok(alerts.some((alert) => alert.text.includes("12 Stromkreise gerundet")));
});
it("uses the PVC 70 °C air factors, not the 60 °C column", () => {
assert.equal(calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 20 }).temperatureFactor, 1.12);
assert.equal(calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 50 }).temperatureFactor, 0.71);
assert.equal(
calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe", ambientTemperatureC: 65 }).temperatureFactor,
0.65
);
});
it("uses the ground tables for D1 (ducts) and D2 (direct)", () => {
const d1 = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D1", ambientTemperatureC: 20, groupingCircuits: 10 });
assert.equal(d1.groupingFactor, 0.49);
const d2 = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D2", ambientTemperatureC: 20, groupingCircuits: 10 });
assert.equal(d2.groupingUsed, 12);
assert.equal(d2.groupingFactor, 0.36);
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D2", ambientTemperatureC: 30 }).temperatureFactor, 0.89);
});
it("refuses inputs outside the tables instead of assuming a factor of 1", () => {
const hot = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 65 });
assert.equal(hot.supported, false);
assert.equal(hot.unsupportedReason, "temperature");
assert.equal(hot.recommendedCrossSectionMm2, null);
assert.equal(buildCableSizingAlerts({ ...BASE_INPUT, ambientTemperatureC: 65 }, hot)[0]?.kind, "critical");
const crowded = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 21 });
assert.equal(crowded.supported, false);
assert.equal(crowded.unsupportedReason, "grouping");
});
it("never recommends a cross-section without tabulated capacity", () => {
const result = calculateCableSizing({
...BASE_INPUT,
powerKw: 0.5,
lengthM: 5,
conductorMaterial: "aluminum",
});
assert.equal(result.recommendedCrossSectionMm2, 2.5);
assert.equal(result.rows[0].ratedCurrentA, null);
assert.equal(result.rows[0].recommended, false);
});
it("checks gG fuses with I2 = 1.6 In and circuit-breakers with In ≤ Iz", () => {
assert.equal(conventionalTrippingFactor({ kind: "breaker", ratedCurrentA: 16 }), 1.45);
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 16 }), 1.6);
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 10 }), 1.9);
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 4 }), 2.1);
assert.ok(Math.abs(minimumIzForProtection({ kind: "fuse", ratedCurrentA: 16 }) - 17.655) < 0.01);
// At 40 °C: 1.5 mm² (C, 2 loaded) carries 19.5 × 0.87 = 16.97 A.
const input = { ...BASE_INPUT, powerKw: 1, lengthM: 5, ambientTemperatureC: 40 };
const breaker = calculateCableSizing({ ...input, existingProtection: { kind: "breaker", ratedCurrentA: 16 } });
assert.equal(breaker.recommendedCrossSectionMm2, 1.5);
assert.equal(breaker.protectionCoordination?.coordinated, true);
const fuse = calculateCableSizing({ ...input, existingProtection: { kind: "fuse", ratedCurrentA: 16 } });
assert.equal(fuse.recommendedCrossSectionMm2, 2.5);
assert.equal(fuse.protectionCoordination?.coordinated, true);
});
it("applies the 0.86 harmonic factor only for three-phase circuits with 15–33 %", () => {
const three = calculateCableSizing({ ...BASE_INPUT, phase: 3, voltage: 400, harmonicNeutralLoad: "15to33Percent" });
assert.equal(three.harmonicReductionApplied, true);
assert.ok(Math.abs(three.combinedDerationFactor - 0.86) < 1e-9);
const single = calculateCableSizing({ ...BASE_INPUT, harmonicNeutralLoad: "15to33Percent" });
assert.equal(single.harmonicReductionApplied, false);
});
it("raises single-phase circuits to the 2.5 mm² practical minimum", () => {
const result = calculateCableSizing({
...BASE_INPUT,
powerKw: 1,
lengthM: 5,
circuitCategory: "single_phase",
});
assert.equal(result.recommendedCrossSectionMm2, 2.5);
assert.equal(result.practicalMinimumApplied, true);
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, powerKw: 1, lengthM: 5, circuitCategory: "single_phase" }, result);
assert.ok(alerts.some((alert) => alert.text.includes("Auf 2,5 mm² angehoben")));
assert.ok(!alerts.some((alert) => /d.d mm²/.test(alert.text)));
});
it("offers only tabulated temperatures", () => {
assert.deepEqual(temperatureOptions("C", "pvc").at(-1), 60);
assert.deepEqual(temperatureOptions("C", "xlpe").at(-1), 80);
assert.deepEqual(temperatureOptions("D1", "pvc")[2], 20);
});
});
describe("cable sizing request schema", () => {
it("accepts a complete request and rejects unknown or legacy fields", () => {
assert.equal(cableSizingRequestSchema.safeParse(BASE_INPUT).success, true);
assert.equal(
cableSizingRequestSchema.safeParse({ ...BASE_INPUT, existingProtection: { kind: "fuse", ratedCurrentA: 25 } }).success,
true
);
assert.equal(
cableSizingRequestSchema.safeParse({ ...BASE_INPUT, existingProtectionRatedCurrentA: 16 }).success,
false
);
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "F" }).success, false);
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, mode: "current" }).success, false);
});
});
describe("cable sizing defaults from the circuit", () => {
const circuit = (rows: Array<{ power: number; cosPhi?: number; phaseType?: string }>): CircuitTreeCircuitDto => ({
id: "circuit",
circuitListId: "list",
sectionId: "section",
equipmentIdentifier: "-2F1.1",
sortOrder: 1,
isReserve: rows.length === 0,
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0),
deviceRows: rows.map((row, index) => ({
id: `row-${index}`,
sortOrder: index,
name: "Gerät",
displayName: "Gerät",
quantity: 1,
manualQuantity: 1,
powerPerUnit: row.power,
simultaneityFactor: 1,
rowTotalPower: row.power,
cosPhi: row.cosPhi,
phaseType: row.phaseType,
})),
});
it("derives a power-weighted cos φ", () => {
// P = 3 kW, S = 2 / 0.8 + 1 / 1 = 3.5 kVA → 0.857
assert.equal(deriveCircuitCosPhi(circuit([{ power: 2, cosPhi: 0.8 }, { power: 1 }])), 0.86);
assert.equal(deriveCircuitCosPhi(circuit([])), 1);
});
it("derives the phase from the group category or three-phase rows", () => {
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1);
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);
assert.equal(deriveCircuitPhase(circuit([]), "three_phase"), 3);
});
it("maps protective devices to the overload-protection kind", () => {
assert.deepEqual(toExistingProtection({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }), {
kind: "breaker",
ratedCurrentA: 16,
});
assert.deepEqual(toExistingProtection({ type: "NH00", ratedCurrentA: 63, fuseUtilizationCategory: "gG" }), {
kind: "fuse",
ratedCurrentA: 63,
});
assert.equal(toExistingProtection({ type: "FI", ratedCurrentA: 40, rcdType: "A", ratedResidualCurrentMa: 30 }), undefined);
assert.equal(toExistingProtection(undefined), undefined);
});
});