Compare commits

..

No commits in common. "8b1923e8ec3a7a613c8bad5b558e73a60555cc41" and "060a203425362babb844ce884eea987d814a9cf6" have entirely different histories.

17 changed files with 4 additions and 2017 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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`

View file

@ -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

View file

@ -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.

View file

@ -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`

View file

@ -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<LayingMethod, "air" | "ground"> = {
A1: "air",
A2: "air",
B1: "air",
B2: "air",
C: "air",
E: "air",
D: "ground",
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
A1: "A1 – Einzeladern im Rohr in wärmegedämmter Wand",
A2: "A2 – Mehradrige Leitung im Rohr in wärmegedämmter Wand",
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
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<InsulationMaterial, string> = {
pvc: "PVC (70 °C), z. B. NYM, NYY",
xlpe: "VPE/XLPE (90 °C), z. B. N2XH, NHXH",
};
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// -- Harmonic neutral loading (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<HarmonicNeutralLoad, string> = {
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<LayingMethod, Record<CoreCount, CapacityRow>>;
const n = null;
/** Iz in A at 30 °C air / 20 °C ground, soil resistivity 2.5 K·m/W. */
export const CURRENT_CAPACITY_A: Record<
InsulationMaterial,
Record<ConductorMaterial, CapacityTable>
> = {
pvc: {
copper: {
// 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<InsulationMaterial, Record<number, number>>
> = {
air: {
pvc: { 10: 1.22, 15: 1.17, 20: 1.12, 25: 1.06, 30: 1.0, 35: 0.94, 40: 0.87, 45: 0.79, 50: 0.71, 55: 0.61, 60: 0.5 },
xlpe: {
10: 1.15, 15: 1.12, 20: 1.08, 25: 1.04, 30: 1.0, 35: 0.96, 40: 0.91, 45: 0.87,
50: 0.82, 55: 0.76, 60: 0.71, 65: 0.65, 70: 0.58, 75: 0.5, 80: 0.41,
},
},
ground: {
pvc: { 10: 1.1, 15: 1.05, 20: 1.0, 25: 0.95, 30: 0.89, 35: 0.84, 40: 0.77, 45: 0.71, 50: 0.63, 55: 0.55, 60: 0.45 },
xlpe: {
10: 1.07, 15: 1.04, 20: 1.0, 25: 0.96, 30: 0.93, 35: 0.89, 40: 0.85, 45: 0.8,
50: 0.76, 55: 0.71, 60: 0.65, 65: 0.6, 70: 0.53, 75: 0.46, 80: 0.38,
},
},
};
export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
air: 30,
ground: 20,
};
/**
* Grouping reduction factors 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<number, number>> = {
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<number, number>, value: number): { key: number; factor: number } | null {
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
const key = keys.find((candidate) => candidate >= value);
if (key === undefined) {
return null;
}
return { key, factor: table[key] };
}
/** Tabulated temperatures offered by the UI for a laying method and insulation. */
export function temperatureOptions(method: LayingMethod, insulation: InsulationMaterial): number[] {
return Object.keys(TEMPERATURE_FACTORS[LAYING_METHOD_GROUP[method]][insulation])
.map(Number)
.sort((a, b) => a - b);
}
// -- Conductor resistivity for the voltage drop -------------------------------
/** Conductivity in m/(Ω·mm²) at the maximum operating temperature (conservative). */
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
// -- Practical minimum cross-sections by circuit category --------------------
// A planning convention from docs/spec/06-future-sizing-and-calculations.md
// ("Standard Single-Phase Circuits ... cable cross-section: 2.5 mm²"), not a
// calculation result. It only ever raises a recommendation.
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
single_phase: 2.5,
};
// -- Protective device --------------------------------------------------------
export const PROTECTION_KINDS = ["breaker", "fuse"] as const;
export type ProtectionKind = (typeof PROTECTION_KINDS)[number];
export interface ExistingProtection {
ratedCurrentA: number;
/** breaker: circuit-breaker (I2 ≤ 1.45 In); fuse: gG fuse (I2 up to 2.1 In). */
kind: ProtectionKind;
}
/**
* Conventional tripping/fusing current I2 as multiple of In.
* Circuit-breakers (IEC 60898-1): 1.45. gG fuses (IEC 60269-2):
* In ≤ 4 A: 2.1, 4 A < In < 16 A: 1.9, In ≥ 16 A: 1.6.
*/
export function conventionalTrippingFactor(protection: ExistingProtection): number {
if (protection.kind === "breaker") {
return 1.45;
}
if (protection.ratedCurrentA <= 4) {
return 2.1;
}
return protection.ratedCurrentA < 16 ? 1.9 : 1.6;
}
/**
* Minimum Iz required by overload protection (IEC 60364-4-43, 433.1):
* In ≤ Iz and I2 ≤ 1.45 · Iz.
*/
export function minimumIzForProtection(protection: ExistingProtection): number {
return Math.max(
protection.ratedCurrentA,
(conventionalTrippingFactor(protection) * protection.ratedCurrentA) / 1.45
);
}
// -- Input / result -----------------------------------------------------------
export interface CableSizingInput {
phase: 1 | 3;
mode: "power" | "current";
/** kW, only used when mode === "power" */
powerKw?: number;
/** A, only used when mode === "current" */
currentA?: number;
cosPhi: number;
/** Line voltage in V (project single-/three-phase voltage). */
voltage: number;
/** Single-run cable length in m. */
lengthM: number;
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
/** Ambient air temperature, or ground temperature for 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;
}

View file

@ -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<typeof cableSizingRequestSchema>;
// The request schema must stay assignable to the domain input shape.
type AssertAssignable<T extends CableSizingInput> = T;
export type CableSizingRequestIsDomainInput = AssertAssignable<CableSizingRequest>;

View file

@ -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<CalculateCableSizingResponse> {
const response = await fetch("/api/cable-sizing/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
cache: "no-store",
});
if (!response.ok) {
let details = await response.text();
try {
const parsed = JSON.parse(details) as { error?: unknown };
if (typeof parsed.error === "string") {
details = parsed.error;
}
} catch {
// keep raw text
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
}
return response.json() as Promise<CalculateCableSizingResponse>;
}

View file

@ -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 };
}

View file

@ -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<void>;
}
// Extracts the first decimal number from a free-text cross-section entry
// like "2.5 mm²" or "2,5"; used only for the manual-entry sanity check.
function parseCrossSectionMm2(text: string): number | null {
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return null;
const value = Number(match[1].replace(",", "."));
return Number.isFinite(value) ? value : null;
}
const formatNumber = (value: number, digits: number) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
export function CableSizingModal({
circuit,
circuitCategory,
singlePhaseVoltageV,
threePhaseVoltageV,
isSaving,
onClose,
onApply,
}: CableSizingModalProps) {
const derivedPhase = deriveCircuitPhase(circuit, circuitCategory);
const derivedCosPhi = deriveCircuitCosPhi(circuit);
const existingProtection = toExistingProtection(circuit.protectionDevice);
const [layingMethod, setLayingMethod] = useState<LayingMethod>("C");
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("copper");
const [ambientTemperatureC, setAmbientTemperatureC] = useState(30);
const [groupingCircuits, setGroupingCircuits] = useState(1);
const [cosPhi, setCosPhi] = useState(derivedCosPhi);
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
const [phase, setPhase] = useState<1 | 3>(derivedPhase);
// Manual entry: applying never requires a calculation. Prefilled from the
// circuit so an existing cable specification is not lost.
const [manualCableType, setManualCableType] = useState(circuit.cableType ?? "");
const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? "");
const [calculating, setCalculating] = useState(false);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(null);
const voltage =
phase === derivedPhase && circuit.voltage
? circuit.voltage
: phase === 3
? threePhaseVoltageV
: singlePhaseVoltageV;
const group = LAYING_METHOD_GROUP[layingMethod];
const 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<HTMLFormElement>) {
event.preventDefault();
if (!manualCrossSection.trim()) return;
await onApply({
cableCrossSection: manualCrossSection.trim(),
cableType: manualCableType.trim() || undefined,
cableLength: lengthM,
});
}
const recommendedRow = result?.rows.find((row) => row.recommended);
return (
<FormModal
description="Querschnittsempfehlung mit Strombelastbarkeit, Temperatur- und Häufungsfaktoren nach DIN VDE 0298-4:2023-06 einschließlich Berichtigung 1:2023-10. Spannungsfall und Schutzorgan werden ergänzend geprüft. Die Empfehlung vor der Übernahme fachlich prüfen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Leitungsberechnung – ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-length">
Leitungslänge [m]
</label>
<input
className="form-control"
id="cable-sizing-length"
min={0}
onChange={(event) => {
setLengthM(Number(event.target.value));
resetResult();
}}
type="number"
value={lengthM}
/>
{lengthM === 0 ? (
<div className="form-text text-warning">
Bei 0 m wird kein Spannungsfall angesetzt.
</div>
) : null}
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
Phasen
</label>
<select
className="form-select"
id="cable-sizing-phase"
onChange={(event) => {
setPhase(Number(event.target.value) as 1 | 3);
resetResult();
}}
value={phase}
>
<option value={1}>1~ ({derivedPhase === 1 && circuit.voltage ? circuit.voltage : singlePhaseVoltageV} V)</option>
<option value={3}>3~ ({derivedPhase === 3 && circuit.voltage ? circuit.voltage : threePhaseVoltageV} V)</option>
</select>
{phase !== derivedPhase ? (
<div className="form-text text-warning">
Abweichend vom Stromkreis ({derivedPhase === 3 ? "3~" : "1~"}).
</div>
) : null}
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-power">
Leistung (aus Stromkreis)
</label>
<input
className="form-control"
disabled
id="cable-sizing-power"
value={`${formatNumber(circuit.circuitTotalPower, 2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => {
const method = event.target.value as LayingMethod;
setLayingMethod(method);
setGroupingCircuits((current) => Math.min(current, maxGroupingCircuits(method)));
applyReferenceTemperature(method, insulation);
resetResult();
}}
value={layingMethod}
>
{LAYING_METHODS.map((method) => (
<option key={method} value={method}>
{LAYING_METHOD_LABELS[method]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-insulation">
Isolierstoff
</label>
<select
className="form-select"
id="cable-sizing-insulation"
onChange={(event) => {
const material = event.target.value as InsulationMaterial;
setInsulation(material);
if (!temperatureOptions(layingMethod, material).includes(ambientTemperatureC)) {
applyReferenceTemperature(layingMethod, material);
}
resetResult();
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-material">
Leitermaterial
</label>
<select
className="form-select"
id="cable-sizing-material"
onChange={(event) => {
setConductorMaterial(event.target.value as ConductorMaterial);
resetResult();
}}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-temperature">
{group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => {
setAmbientTemperatureC(Number(event.target.value));
resetResult();
}}
value={ambientTemperatureC}
>
{availableTemperatures.map((temperature) => (
<option key={temperature} value={temperature}>
{temperature} °C
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise)
</label>
<input
className="form-control"
id="cable-sizing-grouping"
max={groupingMaximum}
min={1}
onChange={(event) => {
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
resetResult();
}}
step={1}
type="number"
value={groupingCircuits}
/>
<div className="form-text">
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses
{layingMethod === "E"
? "; ohne Angaben zur Kabelpritsche ist nur 1 zulässig."
: `; für diese Verlegeart sind bis ${groupingMaximum} tabelliert.`}
</div>
</div>
{phase === 3 ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungen im Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) => {
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad);
resetResult();
}}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-control"
id="cable-sizing-cosphi"
max={1}
min={0.1}
onChange={(event) => {
setCosPhi(Math.min(1, Math.max(0.1, Number(event.target.value) || 1)));
resetResult();
}}
step={0.01}
type="number"
value={cosPhi}
/>
<div className="form-text">
Aus den Gerätezeilen: {formatNumber(derivedCosPhi, 2)}
</div>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. ΔU [%]
</label>
<input
className="form-control"
id="cable-sizing-voltage-drop"
max={10}
min={0.5}
onChange={(event) => {
setMaxVoltageDropPercent(Number(event.target.value) || 3);
resetResult();
}}
step={0.5}
type="number"
value={maxVoltageDropPercent}
/>
</div>
<div className="col-12">
<div className="form-text">
Schutzorgan:{" "}
{existingProtection
? `${existingProtection.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG"} ${existingProtection.ratedCurrentA} A (aus dem Stromkreis, In ≤ Iz und I₂ ≤ 1,45 · Iz)`
: "keines mit Überstromschutz hinterlegt – Bemessung nur nach Betriebsstrom"}
</div>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet …" : "Berechnen"}
</button>
{error ? <p className="text-danger mt-2">{error}</p> : null}
{result ? (
<div className="mt-3">
{result.supported ? (
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom I<sub>B</sub></div>
<div>{formatNumber(result.operatingCurrentA, 1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Erforderliches I<sub>z</sub></div>
<div>
<strong>{formatNumber(result.designCurrentA, 1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2 != null
? `${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²`
: "–"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${formatNumber(result.voltageDropAtRecommendedPercent, 2)} %`
: "–"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor gesamt</div>
<div>{formatNumber(result.combinedDerationFactor, 2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Temperatur / Häufung (gerechnet)</div>
<div>
{result.temperatureUsedC} °C ({formatNumber(result.temperatureFactor ?? 1, 2)}) /{" "}
{result.groupingUsed} ({formatNumber(result.groupingFactor ?? 1, 2)})
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge für ΔU</div>
<div>
{recommendedRow?.maxLengthForVoltageDropM != null
? `${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m`
: "–"}
</div>
</div>
</div>
) : null}
{alerts.map((alert, index) => (
<div
className={`alert mt-2 mb-0 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
</div>
) : null}
<hr className="my-3" />
<p className="text-muted small mb-2">
Kabeltyp und Querschnitt direkt eingeben oder die Empfehlung übernehmen.
Beides landet in denselben Feldern und kann vor dem Übernehmen angepasst
werden.
</p>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-type">
Kabeltyp
</label>
<input
className="form-control"
id="cable-sizing-manual-type"
onChange={(event) => setManualCableType(event.target.value)}
placeholder="z. B. NYM-J 3x2,5"
value={manualCableType}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
Querschnitt
</label>
<input
className="form-control"
id="cable-sizing-manual-cross-section"
onChange={(event) => setManualCrossSection(event.target.value)}
placeholder="z. B. 4 mm²"
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning ? (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
) : null}
</div>
</FormModal>
);
}

View file

@ -5,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<SelectedCell | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
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 ? (
<CableSizingModal
circuit={cableSizingEditorCircuit}
circuitCategory={
data.sections.find(
(section) => section.id === cableSizingEditorCircuit.sectionId
)?.category
}
isSaving={isSaving}
onApply={handleApplyCableSizing}
onClose={() => setCableSizingEditorCircuit(null)}
singlePhaseVoltageV={data.singlePhaseVoltageV}
threePhaseVoltageV={data.threePhaseVoltageV}
/>
) : null}
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4669,14 +4625,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<IconButton
disabled={isSaving}
icon={Calculator}
label="Leitungsberechnung"
onClick={() => setCableSizingEditorCircuit(row.circuit!)}
tabIndex={-1}
title="Leitungsberechnung: Querschnitt ermitteln oder Kabel eintragen"
/>
<IconButton
disabled={isSaving}
icon={ListPlus}

View file

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

View file

@ -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);

View file

@ -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);

View file

@ -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);
});
});