From 49488f4e1b3e1e242a0b8d28bf96f5945ed726b4 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 10:59:41 +0200 Subject: [PATCH 1/2] Add optional cable-sizing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 24 +- docs/README.md | 2 + docs/cable-sizing-module.md | 159 ++++ docs/circuit-list-editor-api.md | 22 + docs/circuit-list-editor-known-limitations.md | 8 +- docs/current-architecture.md | 12 + docs/spec/08-current-product-backlog.md | 4 +- .../domain/cable-sizing-calculation.ts | 689 ++++++++++++++++++ .../domain/cable-sizing-contracts.ts | 47 ++ src/frontend/components/cable-sizing-api.ts | 37 + .../components/cable-sizing-defaults.ts | 54 ++ .../components/cable-sizing-modal.tsx | 571 +++++++++++++++ .../components/circuit-tree-editor.tsx | 52 ++ .../controllers/cable-sizing.controller.ts | 37 + src/server/index.ts | 2 + src/server/routes/cable-sizing.routes.ts | 9 + tests/cable-sizing-calculation.test.ts | 274 +++++++ 17 files changed, 1999 insertions(+), 4 deletions(-) create mode 100644 docs/cable-sizing-module.md create mode 100644 src/cable-sizing/domain/cable-sizing-calculation.ts create mode 100644 src/cable-sizing/domain/cable-sizing-contracts.ts create mode 100644 src/frontend/components/cable-sizing-api.ts create mode 100644 src/frontend/components/cable-sizing-defaults.ts create mode 100644 src/frontend/components/cable-sizing-modal.tsx create mode 100644 src/server/controllers/cable-sizing.controller.ts create mode 100644 src/server/routes/cable-sizing.routes.ts create mode 100644 tests/cable-sizing-calculation.test.ts diff --git a/AGENTS.md b/AGENTS.md index ab8393e..d7c67ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -507,9 +507,31 @@ Before implementation, audit the current ProjectDevice, CircuitDeviceRow, command, snapshot, transfer and editor drag-and-drop paths. Propose concrete 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 -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. diff --git a/docs/README.md b/docs/README.md index 69a2115..5e37408 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen +- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul, + Rechenweg, Datenbasis und Prüfnachweis der Tabellen ## Entwicklung und Betrieb diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md new file mode 100644 index 0000000..374994e --- /dev/null +++ b/docs/cable-sizing-module.md @@ -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. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 9ed9e0a..0b24cc0 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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 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 - `GET /projects/:projectId/external-csv/configuration` diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 4f7f660..521a9dc 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -17,8 +17,12 @@ - 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 versioning, permissions or controlled synchronization model. -- Final electrical sizing logic is not implemented yet. -- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. +- There is no automatic sizing of all circuits. The optional cable sizing + 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. - Complete-circuit drag supports one circuit at a time between groups of the same category. Cross-category moves and multi-circuit moves across group diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 92efc98..7e78fac 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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. 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 - SQLite ist die aktuell unterstützte Datenbank. diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index c90dd24..519fd8d 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -46,7 +46,9 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase E: editor projection and editing. - [x] Phase F: documentation and full GUI verification. - [ ] 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: `docs/spec/09-distribution-board-components-and-protection-groups.md` diff --git a/src/cable-sizing/domain/cable-sizing-calculation.ts b/src/cable-sizing/domain/cable-sizing-calculation.ts new file mode 100644 index 0000000..3c881bd --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -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 = { + A1: "air", + A2: "air", + B1: "air", + B2: "air", + C: "air", + E: "air", + D1: "ground", + D2: "ground", +}; + +export const LAYING_METHOD_LABELS: Record = { + 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 = { + 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 = { + 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>; + +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 +> = { + 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> +> = { + 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> = { + 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, 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 = { 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> = { + 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; +} diff --git a/src/cable-sizing/domain/cable-sizing-contracts.ts b/src/cable-sizing/domain/cable-sizing-contracts.ts new file mode 100644 index 0000000..2a2cc02 --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-contracts.ts @@ -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; + +// The request schema must stay assignable to the domain input shape. +type AssertAssignable = T; +export type CableSizingRequestIsDomainInput = AssertAssignable; diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts new file mode 100644 index 0000000..28530d2 --- /dev/null +++ b/src/frontend/components/cable-sizing-api.ts @@ -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 { + 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; +} diff --git a/src/frontend/components/cable-sizing-defaults.ts b/src/frontend/components/cable-sizing-defaults.ts new file mode 100644 index 0000000..ae4c7a4 --- /dev/null +++ b/src/frontend/components/cable-sizing-defaults.ts @@ -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 }; +} diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx new file mode 100644 index 0000000..deb01b2 --- /dev/null +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -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; +} + +// 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("C"); + const [insulation, setInsulation] = useState("pvc"); + const [conductorMaterial, setConductorMaterial] = useState("copper"); + const [ambientTemperatureC, setAmbientTemperatureC] = useState(30); + const [groupingCircuits, setGroupingCircuits] = useState(1); + const [cosPhi, setCosPhi] = useState(derivedCosPhi); + const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3); + const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState("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(null); + const [alerts, setAlerts] = useState([]); + const [error, setError] = useState(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) { + 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 ( + +
+
+ + { + setLengthM(Number(event.target.value)); + resetResult(); + }} + type="number" + value={lengthM} + /> +
+
+ + + {phase !== derivedPhase ? ( +
+ Abweichend vom Stromkreis ({derivedPhase === 3 ? "3~" : "1~"}). +
+ ) : null} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + { + setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1))); + resetResult(); + }} + step={1} + type="number" + value={groupingCircuits} + /> +
+ Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses. +
+
+ {phase === 3 ? ( +
+ + +
+ ) : null} +
+ + { + setCosPhi(Math.min(1, Math.max(0.1, Number(event.target.value) || 1))); + resetResult(); + }} + step={0.01} + type="number" + value={cosPhi} + /> +
+ Aus den Gerätezeilen: {formatNumber(derivedCosPhi, 2)} +
+
+
+ + { + setMaxVoltageDropPercent(Number(event.target.value) || 3); + resetResult(); + }} + step={0.5} + type="number" + value={maxVoltageDropPercent} + /> +
+
+
+ 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"} +
+
+
+ + + {error ?

{error}

: null} + + {result ? ( +
+ {result.supported ? ( +
+
+
Betriebsstrom IB
+
{formatNumber(result.operatingCurrentA, 1)} A
+
+
+
Erforderliches Iz
+
+ {formatNumber(result.designCurrentA, 1)} A +
+
+
+
Empfohlener Querschnitt
+
+ + {result.recommendedCrossSectionMm2 != null + ? `${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²` + : "–"} + +
+
+
+
ΔU bei Empfehlung
+
+ {result.voltageDropAtRecommendedPercent != null + ? `${formatNumber(result.voltageDropAtRecommendedPercent, 2)} %` + : "–"} +
+
+
+
Korrekturfaktor gesamt
+
{formatNumber(result.combinedDerationFactor, 2)}
+
+
+
Temperatur / Häufung (gerechnet)
+
+ {result.temperatureUsedC} °C ({formatNumber(result.temperatureFactor ?? 1, 2)}) /{" "} + {result.groupingUsed} ({formatNumber(result.groupingFactor ?? 1, 2)}) +
+
+
+
Max. Länge für ΔU
+
+ {recommendedRow?.maxLengthForVoltageDropM != null + ? `${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m` + : "–"} +
+
+
+ ) : null} + {alerts.map((alert, index) => ( +
+ {alert.text} +
+ ))} +
+ ) : null} + +
+

+ Kabeltyp und Querschnitt direkt eingeben oder die Empfehlung übernehmen. + Beides landet in denselben Feldern und kann vor dem Übernehmen angepasst + werden. +

+
+
+ + setManualCableType(event.target.value)} + placeholder="z. B. NYM-J 3x2,5" + value={manualCableType} + /> +
+
+ + setManualCrossSection(event.target.value)} + placeholder="z. B. 4 mm²" + value={manualCrossSection} + /> +
+ {manualCrossSectionWarning ? ( +
+
{manualCrossSectionWarning}
+
+ ) : null} +
+
+ ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index e559303..6851122 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowDown01, ArrowUp, + Calculator, Check, Columns3, FilterX, @@ -160,6 +161,7 @@ import type { import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; +import { CableSizingModal } from "./cable-sizing-modal"; import { circuitGroupCategoryLabels, type CircuitGroupCategory, @@ -319,6 +321,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str useState(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); + const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -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() { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; @@ -3185,6 +3214,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitProtection} /> ) : null} + {cableSizingEditorCircuit ? ( + section.id === cableSizingEditorCircuit.sectionId + )?.category + } + isSaving={isSaving} + onApply={handleApplyCableSizing} + onClose={() => setCableSizingEditorCircuit(null)} + singlePhaseVoltageV={data.singlePhaseVoltageV} + threePhaseVoltageV={data.threePhaseVoltageV} + /> + ) : null}
{row.circuit && row.rowType !== "deviceRow" ? ( <> + setCableSizingEditorCircuit(row.circuit!)} + tabIndex={-1} + title="Leitungsberechnung: Querschnitt ermitteln oder Kabel eintragen" + /> ({ 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], + })) + ); +} diff --git a/src/server/index.ts b/src/server/index.ts index 1f5c9b2..02c5cdc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ import express from "express"; import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.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 { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { closeDefaultDatabase } from "../db/client.js"; @@ -48,6 +49,7 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/projects", projectRouter); app.use("/api/global-devices", globalDeviceRouter); app.use("/api/project-devices", projectDeviceRouter); diff --git a/src/server/routes/cable-sizing.routes.ts b/src/server/routes/cable-sizing.routes.ts new file mode 100644 index 0000000..485e9c1 --- /dev/null +++ b/src/server/routes/cable-sizing.routes.ts @@ -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); diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts new file mode 100644 index 0000000..05e4f3d --- /dev/null +++ b/tests/cable-sizing-calculation.test.ts @@ -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); + }); +}); From 5cbbd3a84aaf56e9ef998448b85d2012bac20765 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 27 Sep 2026 17:01:23 +0200 Subject: [PATCH 2/2] Align cable sizing with DIN VDE 0298-4 --- AGENTS.md | 11 +- docs/cable-sizing-module.md | 218 +++++++----------- docs/circuit-list-editor-api.md | 7 +- docs/circuit-list-editor-known-limitations.md | 10 +- docs/current-architecture.md | 6 +- docs/spec/08-current-product-backlog.md | 5 +- .../domain/cable-sizing-calculation.ts | 146 ++++++------ .../components/cable-sizing-modal.tsx | 18 +- tests/cable-sizing-calculation.test.ts | 85 +++++-- 9 files changed, 262 insertions(+), 244 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8f97c3..e9db70a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -607,10 +607,13 @@ circuit list (see `docs/cable-sizing-module.md`). Rules: 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. +- Current-carrying capacities and correction factors use + DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the + separate basis of voltage-drop and protection checks are documented in + `docs/cable-sizing-module.md`. Inputs outside the supported tables return no + result instead of assuming a factor of 1. Direct burial, grouped method E, + aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic + remain unsupported until their required inputs or normative basis exist. - 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. diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md index 374994e..cd8b08d 100644 --- a/docs/cable-sizing-module.md +++ b/docs/cable-sizing-module.md @@ -1,159 +1,109 @@ -# Leitungsberechnung (Cable-Sizing-Modul) +# Leitungsberechnung -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. +Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare +Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die +ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`, +`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`. +Revisionen sowie Undo/Redo bleiben dadurch erhalten. -Die vollständige, automatische Bemessung aller Stromkreise (siehe -`docs/spec/06-future-sizing-and-calculations.md`) ist weiterhin **nicht** -Teil dieses Moduls. +Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil +des Moduls. -## Aufbau und Andockpunkte +## Aufbau -``` -src/cable-sizing/domain/cable-sizing-calculation.ts reine Berechnung + Tabellen -src/cable-sizing/domain/cable-sizing-contracts.ts Zod-Schema der API +```text +src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen +src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte -src/server/routes/cable-sizing.routes.ts Router unter /api/cable-sizing +src/server/routes/cable-sizing.routes.ts Router 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-defaults.ts Vorbelegung src/frontend/components/cable-sizing-modal.tsx Dialog -tests/cable-sizing-calculation.test.ts Tabellen, Rechnung, Vorbelegung +tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle ``` -Das Domänenmodul importiert nichts aus `db/`, `server/` oder `frontend/`. -Außerhalb dieser Dateien gibt es genau zwei Andockpunkte: +Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist +zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die +Kabelzellen behalten ihr übliches Spreadsheet-Verhalten. -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. +## Eingaben -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. +Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes +cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der +Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung, +zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen. -## Eingaben und Vorbelegung +Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf +hin, dass dann kein Spannungsfall angesetzt wird. -| 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 | +## Normative Grundlage und Grenzen -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. +Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen +**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich +**Berichtigung 1:2023-10** geprüft: + +- Strombelastbarkeit: Tabellen 3 bis 6. +- Temperaturfaktoren: Tabellen 17 und 20. +- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung. +- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich. +- Oberschwingungen: Anhang B, insbesondere Tabelle B.1. + +Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich +mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im +Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür +auf die Kabel-Produktnorm DIN VDE 0276-603 verweist. + +Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der +Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher +ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens +sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt. + +Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine +kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter +Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser +nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine +Empfehlung. + +Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit +Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre +Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination +mit Leitungsschutzschaltern und gG-Sicherungen gehört zur +Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu +beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig +normative Leitungsbemessung. ## 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. +1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl. +2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen + Schutzorgan. +3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls + Oberschwingungsfaktor. +4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt. +5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall. +6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen + Mindestquerschnitt von 2,5 mm². -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. +Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten +ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs +wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin +manuell eingetragen und bewusst übernommen werden. ## 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 +- direkte Erdverlegung +- detaillierte Anordnung mehrerer Kabel bei Verlegeart E +- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer +- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität +- abweichender Bodenwärmewiderstand sowie Verlegearten F und G +- automatische Berechnung oder Änderung mehrerer Stromkreise ## API -`POST /api/cable-sizing/calculate` – zustandslos, liest und schreibt keine -Projektdaten. +`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist +`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält +`supported: false` und einen der Gründe `temperature`, `grouping` oder +`harmonics`, wenn keine belastbare Empfehlung möglich ist. -```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. +`GET /api/cable-sizing/laying-methods` und +`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index aea5130..e528f6e 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -433,14 +433,15 @@ read nor write project data; applying a result uses `circuit.update` through - `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`), + voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` | + `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 + outside the tables or the neutral current needed above 33 % third + harmonic is unavailable - unknown fields are rejected (`400`) - `GET /cable-sizing/laying-methods` - `GET /cable-sizing/insulation-materials` diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 22a6297..78bb44e 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -20,11 +20,11 @@ - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. - There is no automatic sizing of all circuits. The optional cable sizing - 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`. + module suggests a cross-section for one circuit on demand using + DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct + burial, grouped method E and neutral-current sizing above 33 % third + harmonic, and it does not check short-circuit withstand, disconnection time + or selectivity. See `docs/cable-sizing-module.md`. - Sorting is view-only until users explicitly apply sorted order. - Complete-circuit drag supports one circuit at a time between groups of the same category. Cross-category moves and multi-circuit moves across group diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 2b4e38c..2887e8a 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -507,8 +507,10 @@ 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 +Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur +und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich +Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit +den in der Moduldokumentation beschriebenen Grenzen geprüft. 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, diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 9663e33..e7821b4 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -47,8 +47,9 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until 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. + cable sizing module (`docs/cable-sizing-module.md`) exists; its supported + tables were confirmed against DIN VDE 0298-4:2023-06 with + Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked. - Detailed specification: `docs/spec/09-distribution-board-components-and-protection-groups.md` diff --git a/src/cable-sizing/domain/cable-sizing-calculation.ts b/src/cable-sizing/domain/cable-sizing-calculation.ts index 3c881bd..07dd052 100644 --- a/src/cable-sizing/domain/cable-sizing-calculation.ts +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -2,16 +2,14 @@ // 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 data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with +// Berichtigung 1:2023-10. The table-by-table scope and the separate sources +// required for voltage drop and protection coordination are documented in +// docs/cable-sizing-module.md. -// -- Reference installation methods (IEC 60364-5-52, Table B.52.1) ---------- +// -- Reference installation methods (DIN VDE 0298-4, Table 2) --------------- -export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D1", "D2", "E"] as const; +export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const; export type LayingMethod = (typeof LAYING_METHODS)[number]; export const LAYING_METHOD_GROUP: Record = { @@ -21,8 +19,7 @@ export const LAYING_METHOD_GROUP: Record = { B2: "air", C: "air", E: "air", - D1: "ground", - D2: "ground", + D: "ground", }; export const LAYING_METHOD_LABELS: Record = { @@ -31,8 +28,7 @@ export const LAYING_METHOD_LABELS: Record = { 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", + D: "D – Mehradriges Kabel im Rohr oder Kanal im Erdreich", E: "E – Mehradrige Leitung frei in Luft", }; @@ -49,7 +45,7 @@ export const INSULATION_MATERIAL_LABELS: Record = { 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) --- +// -- Harmonic neutral loading (DIN VDE 0298-4, Annex B, three-phase only) --- export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [ "none", @@ -61,7 +57,7 @@ export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number] export const HARMONIC_NEUTRAL_LOAD_LABELS: Record = { none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall", "15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86", - over33Percent: "3. Harmonische > 33 % – Neutralleiter wie Außenleiter dimensionieren", + over33Percent: "3. Harmonische > 33 % – ohne Neutralleiterstrom keine Empfehlung", }; export const HARMONIC_REDUCTION_FACTOR = 0.86; @@ -85,14 +81,13 @@ export const CURRENT_CAPACITY_A: Record< > = { 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. + // DIN VDE 0298-4, tables 3 and 4. A1: { - 2: [14.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 320], + 2: [15.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 321], 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], + 2: [15.5, 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: { @@ -107,22 +102,18 @@ export const CURRENT_CAPACITY_A: Record< 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: { + D: { 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²). + // DIN VDE 0298-4 aluminium values; the normalization below removes all + // entries below the first tabulated aluminium size of 25 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], @@ -143,14 +134,10 @@ export const CURRENT_CAPACITY_A: Record< 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: { + D: { 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], @@ -180,14 +167,10 @@ export const CURRENT_CAPACITY_A: Record< 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: { + D: { 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], @@ -215,14 +198,10 @@ export const CURRENT_CAPACITY_A: Record< 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: { + D: { 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], @@ -231,9 +210,25 @@ export const CURRENT_CAPACITY_A: Record< }, }; +// DIN VDE 0298-4 only supplies aluminium current-carrying capacities from +// 25 mm² onward. Smaller values from the former IEC data set must therefore +// never participate in a recommendation. +for (const insulation of INSULATION_MATERIALS) { + for (const method of LAYING_METHODS) { + for (const coreCount of [2, 3] as const) { + const row = CURRENT_CAPACITY_A[insulation].aluminum[method][coreCount] as (number | null)[]; + CROSS_SECTIONS_MM2.forEach((crossSection, index) => { + if (crossSection < 25) { + row[index] = null; + } + }); + } + } +} + // -- Correction factors ------------------------------------------------------ -/** B.52.14 (air, reference 30 °C) and B.52.15 (ground, reference 20 °C). */ +/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */ export const TEMPERATURE_FACTORS: Record< "air" | "ground", Record> @@ -260,26 +255,32 @@ export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = { }; /** - * 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). + * Grouping reduction factors according to the corrected references in + * DIN VDE 0298-4 Berichtigung 1:2023-10: + * A1–C use table 22, row 1 (bundled/touching), D uses table 26 for touching + * conduits/ducts in earth. Method E needs details about support, spacing and + * number of trays from table 23, which the current input model does not carry; + * it is therefore supported only for one circuit. */ -export const GROUPING_FACTORS: Record<"air" | "D1" | "D2", Record> = { - 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, +export const GROUPING_FACTORS: Record<"A1_TO_C" | "D" | "E", Record> = { + A1_TO_C: { + 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, 10: 0.48, 12: 0.45, 14: 0.43, 16: 0.41, 18: 0.39, 20: 0.38, }, - 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 }, + D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 }, + E: { 1: 1.0 }, }; -export const MAX_GROUPING_CIRCUITS = 20; - function groupingTableFor(method: LayingMethod) { - return method === "D1" ? GROUPING_FACTORS.D1 : method === "D2" ? GROUPING_FACTORS.D2 : GROUPING_FACTORS.air; + return method === "D" + ? GROUPING_FACTORS.D + : method === "E" + ? GROUPING_FACTORS.E + : GROUPING_FACTORS.A1_TO_C; +} + +export function maxGroupingCircuits(method: LayingMethod): number { + return Math.max(...Object.keys(groupingTableFor(method)).map(Number)); } /** @@ -373,7 +374,7 @@ export interface CableSizingInput { layingMethod: LayingMethod; conductorMaterial: ConductorMaterial; insulation: InsulationMaterial; - /** Ambient air temperature, or ground temperature for D1/D2. */ + /** Ambient air temperature, or ground temperature for D. */ ambientTemperatureC: number; /** Number of grouped circuits or multi-core cables, including this one. */ groupingCircuits: number; @@ -405,7 +406,7 @@ export interface ProtectionCoordinationResult { coordinated: boolean; } -export type UnsupportedReason = "temperature" | "grouping"; +export type UnsupportedReason = "temperature" | "grouping" | "harmonics"; export interface CableSizingResult { /** false: the inputs are outside the tabulated range – no numeric result. */ @@ -454,13 +455,23 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult input.ambientTemperatureC ); const grouping = - input.groupingCircuits <= MAX_GROUPING_CIRCUITS + input.groupingCircuits <= maxGroupingCircuits(input.layingMethod) ? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits)) : null; + const harmonicsSupported = !( + input.phase === 3 && input.harmonicNeutralLoad === "over33Percent" + ); + const unsupportedReason: UnsupportedReason | null = !temperature + ? "temperature" + : !grouping + ? "grouping" + : !harmonicsSupported + ? "harmonics" + : null; const empty: CableSizingResult = { supported: false, - unsupportedReason: !temperature ? "temperature" : "grouping", + unsupportedReason, operatingCurrentA: ib, designCurrentA, temperatureUsedC: temperature?.key ?? null, @@ -477,7 +488,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult rows: [], protectionCoordination: null, }; - if (!temperature || !grouping) { + if (unsupportedReason || !temperature || !grouping) { return empty; } @@ -586,12 +597,17 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz const group = LAYING_METHOD_GROUP[input.layingMethod]; if (!result.supported) { + const groupingMaximum = maxGroupingCircuits(input.layingMethod); 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.`, + : result.unsupportedReason === "harmonics" + ? "Bei mehr als 33 % dritter Harmonischer muss nach dem Neutralleiterstrom bemessen werden. Ohne diesen Wert wird keine Querschnittsempfehlung ausgegeben." + : input.layingMethod === "E" + ? "Für Verlegeart E benötigt die Norm bei Häufung Angaben zu Trägerart, Abstand und Anzahl der Kabelpritschen. Ohne diese Angaben ist nur ein einzelner Stromkreis berechenbar." + : `Häufung mit ${input.groupingCircuits} Stromkreisen liegt für Verlegeart ${input.layingMethod} außerhalb der verwendeten Tabelle (max. ${groupingMaximum}) – keine Berechnung möglich.`, }); return alerts; } @@ -637,10 +653,10 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.", }); } - if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") { + if (input.lengthM === 0) { alerts.push({ - kind: "warn", - text: "3. Harmonische > 33 %: Der Neutralleiter ist nach dem Neutralleiterstrom zu bemessen – hier nicht berücksichtigt.", + kind: "info", + text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.", }); } if (group === "ground") { diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx index deb01b2..b15de3b 100644 --- a/src/frontend/components/cable-sizing-modal.tsx +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -11,9 +11,9 @@ import { LAYING_METHODS, LAYING_METHOD_GROUP, LAYING_METHOD_LABELS, - MAX_GROUPING_CIRCUITS, PRACTICAL_MINIMUM_CROSS_SECTION_MM2, REFERENCE_TEMPERATURE_C, + maxGroupingCircuits, temperatureOptions, type CableSizingAlert, type CableSizingInput, @@ -100,6 +100,7 @@ export function CableSizingModal({ ? threePhaseVoltageV : singlePhaseVoltageV; const group = LAYING_METHOD_GROUP[layingMethod]; + const groupingMaximum = maxGroupingCircuits(layingMethod); const availableTemperatures = temperatureOptions(layingMethod, insulation); const input: CableSizingInput = useMemo( @@ -208,7 +209,7 @@ export function CableSizingModal({ return ( + {lengthM === 0 ? ( +
+ Bei 0 m wird kein Spannungsfall angesetzt. +
+ ) : null}
{phase === 3 ? ( diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts index 05e4f3d..39cfbb1 100644 --- a/tests/cable-sizing-calculation.test.ts +++ b/tests/cable-sizing-calculation.test.ts @@ -45,28 +45,34 @@ const capacity = ( CROSS_SECTIONS_MM2.indexOf(crossSection as (typeof CROSS_SECTIONS_MM2)[number]) ]; -describe("cable sizing reference tables (IEC 60364-5-52 Annex B)", () => { +describe("cable sizing reference tables (DIN VDE 0298-4:2023-06)", () => { 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, 1.5), 15.5); + assert.equal(capacity("pvc", "copper", "A2", 2, 1.5), 15.5); 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) + // DIN table 3: 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 + // Tables 3 and 4, including reference method D in conduits/ducts. 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", "D", 2, 2.5), 29); + assert.equal(capacity("pvc", "copper", "A1", 2, 240), 321); 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); + it("uses aluminium only from the first DIN-tabulated size of 25 mm²", () => { + for (const insulation of ["pvc", "xlpe"] as const) { + for (const method of ["A1", "A2", "B1", "B2", "C", "D", "E"] as const) { + for (const cores of [2, 3] as const) { + for (const crossSection of CROSS_SECTIONS_MM2.filter((value) => value < 25)) { + assert.equal(capacity(insulation, "aluminum", method, cores, crossSection), null); + } + } + } + } + assert.equal(capacity("pvc", "aluminum", "C", 3, 25), 73); assert.equal(capacity("xlpe", "copper", "C", 2, 2.5), 33); assert.equal(capacity("xlpe", "aluminum", "E", 3, 25), 97); }); @@ -113,10 +119,15 @@ describe("calculateCableSizing", () => { 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 }); + const exact = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 10 }); + assert.equal(exact.groupingUsed, 10); + assert.equal(exact.groupingFactor, 0.48); + assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 14 }).groupingFactor, 0.43); + assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 18 }).groupingFactor, 0.39); + const grouped = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 11 }); assert.equal(grouped.groupingUsed, 12); assert.equal(grouped.groupingFactor, 0.45); - const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 10 }, grouped); + const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 11 }, grouped); assert.ok(alerts.some((alert) => alert.text.includes("12 Stromkreise gerundet"))); }); @@ -129,13 +140,15 @@ describe("calculateCableSizing", () => { ); }); - 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("limits grouping to the corrected DIN tables for D and E", () => { + const d = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 6 }); + assert.equal(d.groupingFactor, 0.6); + assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 7 }).supported, false); + const e = calculateCableSizing({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 }); + assert.equal(e.supported, false); + assert.equal(e.unsupportedReason, "grouping"); + assert.ok(buildCableSizingAlerts({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 }, e)[0]?.text.includes("Kabelpritsche")); + assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 30 }).temperatureFactor, 0.89); }); it("refuses inputs outside the tables instead of assuming a factor of 1", () => { @@ -156,9 +169,11 @@ describe("calculateCableSizing", () => { lengthM: 5, conductorMaterial: "aluminum", }); - assert.equal(result.recommendedCrossSectionMm2, 2.5); - assert.equal(result.rows[0].ratedCurrentA, null); - assert.equal(result.rows[0].recommended, false); + assert.equal(result.recommendedCrossSectionMm2, 25); + for (const row of result.rows.filter((row) => row.crossSectionMm2 < 25)) { + assert.equal(row.ratedCurrentA, null); + assert.equal(row.recommended, false); + } }); it("checks gG fuses with I2 = 1.6 In and circuit-breakers with In ≤ Iz", () => { @@ -186,6 +201,23 @@ describe("calculateCableSizing", () => { assert.equal(single.harmonicReductionApplied, false); }); + it("does not recommend a cross-section above 33 % third harmonic without neutral current", () => { + const input = { ...BASE_INPUT, phase: 3 as const, voltage: 400, harmonicNeutralLoad: "over33Percent" as const }; + const result = calculateCableSizing(input); + assert.equal(result.supported, false); + assert.equal(result.unsupportedReason, "harmonics"); + assert.equal(result.recommendedCrossSectionMm2, null); + assert.equal(buildCableSizingAlerts(input, result)[0]?.kind, "critical"); + }); + + it("allows zero length and states that no voltage drop is applied", () => { + const input = { ...BASE_INPUT, lengthM: 0 }; + const result = calculateCableSizing(input); + assert.equal(result.supported, true); + assert.equal(result.voltageDropAtRecommendedPercent, 0); + assert.ok(buildCableSizingAlerts(input, result).some((alert) => alert.text.includes("kein Spannungsfall"))); + }); + it("raises single-phase circuits to the 2.5 mm² practical minimum", () => { const result = calculateCableSizing({ ...BASE_INPUT, @@ -203,7 +235,7 @@ describe("calculateCableSizing", () => { 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); + assert.deepEqual(temperatureOptions("D", "pvc")[2], 20); }); }); @@ -219,6 +251,9 @@ describe("cable sizing request schema", () => { false ); assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "F" }).success, false); + assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D1" }).success, false); + assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D2" }).success, false); + assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D" }).success, true); assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, mode: "current" }).success, false); }); });