diff --git a/AGENTS.md b/AGENTS.md index e9db70a..bdac707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -595,34 +595,9 @@ 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 automatic cable/protection sizing of all circuits -unless explicitly requested. The cable sizing module above only suggests -values for one circuit on demand. +Do not implement full cable/protection sizing unless explicitly requested. Keep the structure ready for it. diff --git a/docs/README.md b/docs/README.md index c04103a..5b4bc13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,8 +21,6 @@ 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 deleted file mode 100644 index cd8b08d..0000000 --- a/docs/cable-sizing-module.md +++ /dev/null @@ -1,109 +0,0 @@ -# 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 e528f6e..ecbbdd4 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -425,29 +425,6 @@ 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 78bb44e..a3840df 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -19,12 +19,8 @@ - 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. -- 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`. +- Final electrical sizing logic is not implemented yet. +- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. - 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 2887e8a..41fd725 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -504,20 +504,6 @@ 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 e7821b4..a9e902d 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -46,10 +46,7 @@ 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 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. + the planner supplies the calculation requirements. - 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 deleted file mode 100644 index 07dd052..0000000 --- a/src/cable-sizing/domain/cable-sizing-calculation.ts +++ /dev/null @@ -1,705 +0,0 @@ -// 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 deleted file mode 100644 index 2a2cc02..0000000 --- a/src/cable-sizing/domain/cable-sizing-contracts.ts +++ /dev/null @@ -1,47 +0,0 @@ -// 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 deleted file mode 100644 index 28530d2..0000000 --- a/src/frontend/components/cable-sizing-api.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index ae4c7a4..0000000 --- a/src/frontend/components/cable-sizing-defaults.ts +++ /dev/null @@ -1,54 +0,0 @@ -// 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 deleted file mode 100644 index b15de3b..0000000 --- a/src/frontend/components/cable-sizing-modal.tsx +++ /dev/null @@ -1,581 +0,0 @@ -"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 e8a2f23..eb7c052 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -5,7 +5,6 @@ import { ArrowDown, ArrowDown01, ArrowUp, - Calculator, Check, Columns3, FilterX, @@ -164,7 +163,6 @@ 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, @@ -326,8 +324,6 @@ 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); @@ -1448,31 +1444,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - async function handleApplyCableSizing(patch: { - cableCrossSection: string; - cableType?: string; - cableLength?: number; - }) { - const circuit = cableSizingEditorCircuit; - if (!circuit) { - return; - } - await runCommand({ - label: "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; @@ -3278,21 +3249,6 @@ 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 7cad7e3..7d4517d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,7 +3,6 @@ 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"; @@ -50,7 +49,6 @@ 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 deleted file mode 100644 index 485e9c1..0000000 --- a/src/server/routes/cable-sizing.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 39cfbb1..0000000 --- a/tests/cable-sizing-calculation.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -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); - }); -});