diff --git a/AGENTS.md b/AGENTS.md index bdac707..e9db70a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -595,9 +595,34 @@ 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. +- 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. + ## 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 5b4bc13..c04103a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,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..cd8b08d --- /dev/null +++ b/docs/cable-sizing-module.md @@ -0,0 +1,109 @@ +# Leitungsberechnung + +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. + +Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil +des Moduls. + +## Aufbau + +```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 +src/frontend/components/cable-sizing-api.ts Fetch-Client +src/frontend/components/cable-sizing-defaults.ts Vorbelegung +src/frontend/components/cable-sizing-modal.tsx Dialog +tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle +``` + +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. + +## Eingaben + +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. + +Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf +hin, dass dann kein Spannungsfall angesetzt wird. + +## Normative Grundlage und Grenzen + +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 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 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 + +- 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` 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. + +`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 ecbbdd4..e528f6e 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -425,6 +425,29 @@ 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` | `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 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` + +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 a3840df..78bb44e 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -19,8 +19,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 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 41fd725..2887e8a 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -504,6 +504,20 @@ 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. 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, +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 a9e902d..e7821b4 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -46,7 +46,10 @@ 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 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 new file mode 100644 index 0000000..07dd052 --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -0,0 +1,705 @@ +// 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: 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 (DIN VDE 0298-4, Table 2) --------------- + +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 = { + A1: "air", + A2: "air", + B1: "air", + B2: "air", + C: "air", + E: "air", + D: "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", + D: "D – Mehradriges Kabel im Rohr oder Kanal 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 (DIN VDE 0298-4, Annex B, 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 % – ohne Neutralleiterstrom keine Empfehlung", +}; + +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: { + // DIN VDE 0298-4, tables 3 and 4. + A1: { + 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: [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: { + 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], + }, + 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], + }, + 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: { + // 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], + }, + 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], + }, + 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], + }, + 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], + }, + 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], + }, + 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], + }, + 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], + }, + 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], + }, + }, + }, +}; + +// 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 ------------------------------------------------------ + +/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */ +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 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<"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, + }, + D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 }, + E: { 1: 1.0 }, +}; + +function groupingTableFor(method: LayingMethod) { + 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)); +} + +/** + * 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 D. */ + 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" | "harmonics"; + +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 <= 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, + 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 (unsupportedReason || !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) { + 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.` + : 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; + } + + 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.lengthM === 0) { + alerts.push({ + kind: "info", + text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.", + }); + } + 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..b15de3b --- /dev/null +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -0,0 +1,581 @@ +"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, + PRACTICAL_MINIMUM_CROSS_SECTION_MM2, + REFERENCE_TEMPERATURE_C, + maxGroupingCircuits, + 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 groupingMaximum = maxGroupingCircuits(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} + /> + {lengthM === 0 ? ( +
+ Bei 0 m wird kein Spannungsfall angesetzt. +
+ ) : null} +
+
+ + + {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 + {layingMethod === "E" + ? "; ohne Angaben zur Kabelpritsche ist nur 1 zulässig." + : `; für diese Verlegeart sind bis ${groupingMaximum} tabelliert.`} +
+
+ {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 eb7c052..e8a2f23 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, @@ -163,6 +164,7 @@ import { DistributionBoardComponentModal } from "./distribution-board-component- import { GzfPickerModal } from "./gzf-picker-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; +import { CableSizingModal } from "./cable-sizing-modal"; import { circuitGroupCategoryLabels, type CircuitGroupCategory, @@ -324,6 +326,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [gzfPickerCell, setGzfPickerCell] = useState(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); + const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -1444,6 +1448,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; @@ -3249,6 +3278,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 7d4517d..7cad7e3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,6 +3,7 @@ import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.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"; @@ -49,6 +50,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..39cfbb1 --- /dev/null +++ b/tests/cable-sizing-calculation.test.ts @@ -0,0 +1,309 @@ +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 (DIN VDE 0298-4:2023-06)", () => { + it("contains the checked PVC copper values", () => { + 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); + // 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); + // 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", "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 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); + }); + + 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 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: 11 }, 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("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", () => { + 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, 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", () => { + 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("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, + 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("D", "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, 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); + }); +}); + +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); + }); +});