Compare commits
4 commits
060a203425
...
8b1923e8ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1923e8ec | |||
| 5cbbd3a84a | |||
| 598003554a | |||
|
|
49488f4e1b |
17 changed files with 2017 additions and 4 deletions
27
AGENTS.md
27
AGENTS.md
|
|
@ -595,9 +595,34 @@ Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
|
|||
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
|
||||
domain objects and commands before adding migrations.
|
||||
|
||||
## Cable Sizing Module
|
||||
|
||||
`src/cable-sizing` is an agreed, separate calculator module attached to the
|
||||
circuit list (see `docs/cable-sizing-module.md`). Rules:
|
||||
|
||||
- The domain code stays pure: no imports from `db/`, `server/` or `frontend/`.
|
||||
- It never writes project data. Results are applied only by an explicit
|
||||
"Übernehmen", which runs the regular `circuit.update` command.
|
||||
- It is opened from the circuit row action button "Leitungsberechnung", never
|
||||
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
|
||||
- It has no database table and no migration; `/api/cable-sizing/*` is
|
||||
stateless.
|
||||
- Current-carrying capacities and correction factors use
|
||||
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the
|
||||
separate basis of voltage-drop and protection checks are documented in
|
||||
`docs/cable-sizing-module.md`. Inputs outside the supported tables return no
|
||||
result instead of assuming a factor of 1. Direct burial, grouped method E,
|
||||
aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic
|
||||
remain unsupported until their required inputs or normative basis exist.
|
||||
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
|
||||
length, protective device) and remain editable; manual cable entry without
|
||||
a calculation stays possible.
|
||||
|
||||
## Future Sizing
|
||||
|
||||
Do not implement full cable/protection sizing unless explicitly requested.
|
||||
Do not implement full automatic cable/protection sizing of all circuits
|
||||
unless explicitly requested. The cable sizing module above only suggests
|
||||
values for one circuit on demand.
|
||||
|
||||
Keep the structure ready for it.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
|||
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
||||
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
||||
|
||||
## Entwicklung und Betrieb
|
||||
|
||||
|
|
|
|||
109
docs/cable-sizing-module.md
Normal file
109
docs/cable-sizing-module.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Leitungsberechnung
|
||||
|
||||
Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare
|
||||
Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die
|
||||
ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`,
|
||||
`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`.
|
||||
Revisionen sowie Undo/Redo bleiben dadurch erhalten.
|
||||
|
||||
Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil
|
||||
des Moduls.
|
||||
|
||||
## Aufbau
|
||||
|
||||
```text
|
||||
src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen
|
||||
src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung
|
||||
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
|
||||
src/server/routes/cable-sizing.routes.ts Router
|
||||
src/frontend/components/cable-sizing-api.ts Fetch-Client
|
||||
src/frontend/components/cable-sizing-defaults.ts Vorbelegung
|
||||
src/frontend/components/cable-sizing-modal.tsx Dialog
|
||||
tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle
|
||||
```
|
||||
|
||||
Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist
|
||||
zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die
|
||||
Kabelzellen behalten ihr übliches Spreadsheet-Verhalten.
|
||||
|
||||
## Eingaben
|
||||
|
||||
Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes
|
||||
cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der
|
||||
Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung,
|
||||
zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen.
|
||||
|
||||
Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf
|
||||
hin, dass dann kein Spannungsfall angesetzt wird.
|
||||
|
||||
## Normative Grundlage und Grenzen
|
||||
|
||||
Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen
|
||||
**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich
|
||||
**Berichtigung 1:2023-10** geprüft:
|
||||
|
||||
- Strombelastbarkeit: Tabellen 3 bis 6.
|
||||
- Temperaturfaktoren: Tabellen 17 und 20.
|
||||
- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung.
|
||||
- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich.
|
||||
- Oberschwingungen: Anhang B, insbesondere Tabelle B.1.
|
||||
|
||||
Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich
|
||||
mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im
|
||||
Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür
|
||||
auf die Kabel-Produktnorm DIN VDE 0276-603 verweist.
|
||||
|
||||
Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der
|
||||
Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher
|
||||
ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens
|
||||
sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt.
|
||||
|
||||
Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine
|
||||
kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter
|
||||
Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser
|
||||
nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine
|
||||
Empfehlung.
|
||||
|
||||
Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit
|
||||
Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre
|
||||
Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination
|
||||
mit Leitungsschutzschaltern und gG-Sicherungen gehört zur
|
||||
Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu
|
||||
beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig
|
||||
normative Leitungsbemessung.
|
||||
|
||||
## Rechenweg
|
||||
|
||||
1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl.
|
||||
2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen
|
||||
Schutzorgan.
|
||||
3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls
|
||||
Oberschwingungsfaktor.
|
||||
4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt.
|
||||
5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall.
|
||||
6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen
|
||||
Mindestquerschnitt von 2,5 mm².
|
||||
|
||||
Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten
|
||||
ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs
|
||||
wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin
|
||||
manuell eingetragen und bewusst übernommen werden.
|
||||
|
||||
## Nicht enthalten
|
||||
|
||||
- direkte Erdverlegung
|
||||
- detaillierte Anordnung mehrerer Kabel bei Verlegeart E
|
||||
- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer
|
||||
- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität
|
||||
- abweichender Bodenwärmewiderstand sowie Verlegearten F und G
|
||||
- automatische Berechnung oder Änderung mehrerer Stromkreise
|
||||
|
||||
## API
|
||||
|
||||
`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist
|
||||
`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält
|
||||
`supported: false` und einen der Gründe `temperature`, `grouping` oder
|
||||
`harmonics`, wenn keine belastbare Empfehlung möglich ist.
|
||||
|
||||
`GET /api/cable-sizing/laying-methods` und
|
||||
`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten.
|
||||
|
|
@ -425,6 +425,29 @@ inverse command. The former direct restore/reconnect endpoints are removed.
|
|||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||
project device never triggers synchronization implicitly.
|
||||
|
||||
## Cable Sizing (optional module)
|
||||
|
||||
Stateless calculator endpoints mounted at `/api/cable-sizing`. They neither
|
||||
read nor write project data; applying a result uses `circuit.update` through
|
||||
`POST /projects/:projectId/commands`.
|
||||
|
||||
- `POST /cable-sizing/calculate`
|
||||
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
|
||||
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` |
|
||||
`E`), conductorMaterial, insulation (`pvc` | `xlpe`),
|
||||
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
|
||||
harmonicNeutralLoad, optional `existingProtection`
|
||||
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
|
||||
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
|
||||
is `false` with `unsupportedReason` when temperature or grouping lie
|
||||
outside the tables or the neutral current needed above 33 % third
|
||||
harmonic is unavailable
|
||||
- unknown fields are rejected (`400`)
|
||||
- `GET /cable-sizing/laying-methods`
|
||||
- `GET /cable-sizing/insulation-materials`
|
||||
|
||||
Full request example and calculation rules: `docs/cable-sizing-module.md`.
|
||||
|
||||
## Revit CSV Foundation
|
||||
|
||||
- `GET /projects/:projectId/external-csv/configuration`
|
||||
|
|
|
|||
|
|
@ -19,8 +19,12 @@
|
|||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||
- The global device library supports basic CRUD and copy operations, but has no
|
||||
versioning, permissions or controlled synchronization model.
|
||||
- Final electrical sizing logic is not implemented yet.
|
||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||
- There is no automatic sizing of all circuits. The optional cable sizing
|
||||
module suggests a cross-section for one circuit on demand using
|
||||
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
|
||||
burial, grouped method E and neutral-current sizing above 33 % third
|
||||
harmonic, and it does not check short-circuit withstand, disconnection time
|
||||
or selectivity. See `docs/cable-sizing-module.md`.
|
||||
- Sorting is view-only until users explicitly apply sorted order.
|
||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||
same category. Cross-category moves and multi-circuit moves across group
|
||||
|
|
|
|||
|
|
@ -504,6 +504,20 @@ werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
|
|||
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
|
||||
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
|
||||
## Leitungsberechnung
|
||||
|
||||
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
|
||||
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
|
||||
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
|
||||
Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit
|
||||
den in der Moduldokumentation beschriebenen Grenzen geprüft. Die
|
||||
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
|
||||
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
|
||||
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
|
||||
Querschnitt und Länge über den regulären `circuit.update`-Befehl, also mit
|
||||
Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
|
||||
`docs/cable-sizing-module.md`.
|
||||
|
||||
## Persistenz und Migration
|
||||
|
||||
- SQLite ist die aktuell unterstützte Datenbank.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ requirements and intended sequencing, not proof of implementation.
|
|||
- [x] Phase E: editor projection and editing.
|
||||
- [x] Phase F: documentation and full GUI verification.
|
||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||
the planner supplies the calculation requirements.
|
||||
the planner supplies the calculation requirements. The optional per-circuit
|
||||
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
|
||||
tables were confirmed against DIN VDE 0298-4:2023-06 with
|
||||
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
|
||||
- Detailed specification:
|
||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||
|
||||
|
|
|
|||
705
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
705
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/;
|
||||
// the module is an optional calculator attached to the circuit list and never
|
||||
// writes project data itself (see docs/cable-sizing-module.md).
|
||||
//
|
||||
// Reference data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with
|
||||
// Berichtigung 1:2023-10. The table-by-table scope and the separate sources
|
||||
// required for voltage drop and protection coordination are documented in
|
||||
// docs/cable-sizing-module.md.
|
||||
|
||||
// -- Reference installation methods (DIN VDE 0298-4, Table 2) ---------------
|
||||
|
||||
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const;
|
||||
export type LayingMethod = (typeof LAYING_METHODS)[number];
|
||||
|
||||
export const LAYING_METHOD_GROUP: Record<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;
|
||||
}
|
||||
47
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
47
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Request validation for the cable-sizing HTTP API. Pure - depends only on
|
||||
// zod and the sibling calculation module, never on db/server.
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CONDUCTOR_MATERIALS,
|
||||
HARMONIC_NEUTRAL_LOAD_OPTIONS,
|
||||
INSULATION_MATERIALS,
|
||||
LAYING_METHODS,
|
||||
PROTECTION_KINDS,
|
||||
type CableSizingInput,
|
||||
} from "./cable-sizing-calculation.js";
|
||||
|
||||
export const cableSizingRequestSchema = z
|
||||
.object({
|
||||
phase: z.union([z.literal(1), z.literal(3)]),
|
||||
mode: z.enum(["power", "current"]),
|
||||
powerKw: z.number().finite().min(0).optional(),
|
||||
currentA: z.number().finite().min(0).optional(),
|
||||
cosPhi: z.number().finite().min(0.1).max(1),
|
||||
voltage: z.number().finite().positive(),
|
||||
lengthM: z.number().finite().min(0).max(10000),
|
||||
layingMethod: z.enum(LAYING_METHODS),
|
||||
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
|
||||
insulation: z.enum(INSULATION_MATERIALS),
|
||||
ambientTemperatureC: z.number().finite().min(-40).max(120),
|
||||
groupingCircuits: z.number().int().min(1).max(100),
|
||||
maxVoltageDropPercent: z.number().finite().min(0.5).max(10),
|
||||
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
|
||||
existingProtection: z
|
||||
.object({
|
||||
ratedCurrentA: z.number().finite().positive(),
|
||||
kind: z.enum(PROTECTION_KINDS),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => (value.mode === "power" ? value.powerKw != null : value.currentA != null), {
|
||||
message: "powerKw is required for mode 'power', currentA for mode 'current'",
|
||||
});
|
||||
|
||||
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
|
||||
|
||||
// The request schema must stay assignable to the domain input shape.
|
||||
type AssertAssignable<T extends CableSizingInput> = T;
|
||||
export type CableSizingRequestIsDomainInput = AssertAssignable<CableSizingRequest>;
|
||||
37
src/frontend/components/cable-sizing-api.ts
Normal file
37
src/frontend/components/cable-sizing-api.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Thin fetch client for the cable-sizing module, kept separate from
|
||||
// ../utils/api.ts so the module stays self-contained (see
|
||||
// docs/cable-sizing-module.md).
|
||||
import type {
|
||||
CableSizingAlert,
|
||||
CableSizingInput,
|
||||
CableSizingResult,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||
|
||||
export interface CalculateCableSizingResponse {
|
||||
result: CableSizingResult;
|
||||
alerts: CableSizingAlert[];
|
||||
}
|
||||
|
||||
export async function calculateCableSizing(
|
||||
input: CableSizingInput
|
||||
): Promise<CalculateCableSizingResponse> {
|
||||
const response = await fetch("/api/cable-sizing/calculate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
let details = await response.text();
|
||||
try {
|
||||
const parsed = JSON.parse(details) as { error?: unknown };
|
||||
if (typeof parsed.error === "string") {
|
||||
details = parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<CalculateCableSizingResponse>;
|
||||
}
|
||||
54
src/frontend/components/cable-sizing-defaults.ts
Normal file
54
src/frontend/components/cable-sizing-defaults.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Derives the cable-sizing inputs that follow from the circuit itself, so the
|
||||
// planner starts from the circuit's real values instead of fixed defaults.
|
||||
import type {
|
||||
CircuitCategory,
|
||||
ExistingProtection,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto } from "../types";
|
||||
|
||||
/** Three-phase if the circuit group or any device row is three-phase. */
|
||||
export function deriveCircuitPhase(
|
||||
circuit: CircuitTreeCircuitDto,
|
||||
circuitCategory?: CircuitCategory
|
||||
): 1 | 3 {
|
||||
return circuitCategory === "three_phase" ||
|
||||
circuit.deviceRows.some((row) => row.phaseType === "three_phase")
|
||||
? 3
|
||||
: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-weighted cos φ of the circuit: P / S with S = Σ P_row / cos φ_row.
|
||||
* Rows without cos φ count as 1; a circuit without load yields 1.
|
||||
*/
|
||||
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
|
||||
let activePowerKw = 0;
|
||||
let apparentPowerKva = 0;
|
||||
for (const row of circuit.deviceRows) {
|
||||
activePowerKw += row.rowTotalPower;
|
||||
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
|
||||
}
|
||||
if (activePowerKw <= 0 || apparentPowerKva <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.round((activePowerKw / apparentPowerKva) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the circuit's protective device to the overload-protection kind of the
|
||||
* calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH
|
||||
* fuse links "fuse"; a residual-current device without overcurrent release
|
||||
* (FI) provides no overload protection and is ignored.
|
||||
*/
|
||||
export function toExistingProtection(
|
||||
protection: CircuitTreeProtectionDeviceDto | undefined
|
||||
): ExistingProtection | undefined {
|
||||
if (!protection || protection.type === "FI") {
|
||||
return undefined;
|
||||
}
|
||||
const kind =
|
||||
protection.type === "LS" || protection.type === "FI_LS" || protection.type === "AFDD"
|
||||
? "breaker"
|
||||
: "fuse";
|
||||
return { ratedCurrentA: protection.ratedCurrentA, kind };
|
||||
}
|
||||
581
src/frontend/components/cable-sizing-modal.tsx
Normal file
581
src/frontend/components/cable-sizing-modal.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import {
|
||||
CONDUCTOR_MATERIALS,
|
||||
CROSS_SECTIONS_MM2,
|
||||
HARMONIC_NEUTRAL_LOAD_LABELS,
|
||||
HARMONIC_NEUTRAL_LOAD_OPTIONS,
|
||||
INSULATION_MATERIALS,
|
||||
INSULATION_MATERIAL_LABELS,
|
||||
LAYING_METHODS,
|
||||
LAYING_METHOD_GROUP,
|
||||
LAYING_METHOD_LABELS,
|
||||
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
|
||||
REFERENCE_TEMPERATURE_C,
|
||||
maxGroupingCircuits,
|
||||
temperatureOptions,
|
||||
type CableSizingAlert,
|
||||
type CableSizingInput,
|
||||
type CableSizingResult,
|
||||
type CircuitCategory,
|
||||
type ConductorMaterial,
|
||||
type HarmonicNeutralLoad,
|
||||
type InsulationMaterial,
|
||||
type LayingMethod,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||
import { calculateCableSizing } from "./cable-sizing-api";
|
||||
import type { CircuitTreeCircuitDto } from "../types";
|
||||
import {
|
||||
deriveCircuitCosPhi,
|
||||
deriveCircuitPhase,
|
||||
toExistingProtection,
|
||||
} from "./cable-sizing-defaults";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CableSizingModalProps {
|
||||
circuit: CircuitTreeCircuitDto;
|
||||
circuitCategory?: CircuitCategory;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (patch: {
|
||||
cableCrossSection: string;
|
||||
cableType?: string;
|
||||
cableLength?: number;
|
||||
}) => Promise<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
ArrowDown,
|
||||
ArrowDown01,
|
||||
ArrowUp,
|
||||
Calculator,
|
||||
Check,
|
||||
Columns3,
|
||||
FilterX,
|
||||
|
|
@ -163,6 +164,7 @@ import { DistributionBoardComponentModal } from "./distribution-board-component-
|
|||
import { GzfPickerModal } from "./gzf-picker-modal";
|
||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||
import { CableSizingModal } from "./cable-sizing-modal";
|
||||
import {
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
|
|
@ -324,6 +326,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const [gzfPickerCell, setGzfPickerCell] = useState<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);
|
||||
|
|
@ -1444,6 +1448,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
});
|
||||
}
|
||||
|
||||
async function handleApplyCableSizing(patch: {
|
||||
cableCrossSection: string;
|
||||
cableType?: string;
|
||||
cableLength?: number;
|
||||
}) {
|
||||
const circuit = cableSizingEditorCircuit;
|
||||
if (!circuit) {
|
||||
return;
|
||||
}
|
||||
await runCommand({
|
||||
label: "Leitungsberechnung übernehmen",
|
||||
redo: async () => {
|
||||
const result = await updateCircuitById(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
circuit.id,
|
||||
patch
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
setCableSizingEditorCircuit(null);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRedo() {
|
||||
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
||||
return;
|
||||
|
|
@ -3249,6 +3278,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
onSave={handleSaveCircuitProtection}
|
||||
/>
|
||||
) : null}
|
||||
{cableSizingEditorCircuit ? (
|
||||
<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}
|
||||
|
|
@ -4625,6 +4669,14 @@ 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}
|
||||
|
|
|
|||
37
src/server/controllers/cable-sizing.controller.ts
Normal file
37
src/server/controllers/cable-sizing.controller.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
|
||||
import {
|
||||
buildCableSizingAlerts,
|
||||
calculateCableSizing,
|
||||
INSULATION_MATERIALS,
|
||||
INSULATION_MATERIAL_LABELS,
|
||||
LAYING_METHODS,
|
||||
LAYING_METHOD_LABELS,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
|
||||
|
||||
// Stateless calculation: it neither reads nor writes project data. The
|
||||
// planner applies a result through the existing circuit.update command, which
|
||||
// keeps revisions and Undo/Redo intact. See docs/cable-sizing-module.md.
|
||||
export function calculateCableSizingHandler(req: Request, res: Response) {
|
||||
const parsed = cableSizingRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const result = calculateCableSizing(parsed.data);
|
||||
return res.json({ result, alerts: buildCableSizingAlerts(parsed.data, result) });
|
||||
}
|
||||
|
||||
export function listLayingMethodsHandler(_req: Request, res: Response) {
|
||||
return res.json(
|
||||
LAYING_METHODS.map((method) => ({ method, label: LAYING_METHOD_LABELS[method] }))
|
||||
);
|
||||
}
|
||||
|
||||
export function listInsulationMaterialsHandler(_req: Request, res: Response) {
|
||||
return res.json(
|
||||
INSULATION_MATERIALS.map((insulation) => ({
|
||||
insulation,
|
||||
label: INSULATION_MATERIAL_LABELS[insulation],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
|||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
import { closeDefaultDatabase } from "../db/client.js";
|
||||
|
|
@ -49,6 +50,7 @@ app.get("/health", (_req, res) => {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
|
|
|
|||
9
src/server/routes/cable-sizing.routes.ts
Normal file
9
src/server/routes/cable-sizing.routes.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import express from "express";
|
||||
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
|
||||
|
||||
// Stateless calculator endpoints; no project data is read or written here.
|
||||
export const cableSizingRouter = express.Router();
|
||||
|
||||
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
|
||||
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
|
||||
cableSizingRouter.get("/insulation-materials", cableSizingController.listInsulationMaterialsHandler);
|
||||
309
tests/cable-sizing-calculation.test.ts
Normal file
309
tests/cable-sizing-calculation.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildCableSizingAlerts,
|
||||
calculateCableSizing,
|
||||
conventionalTrippingFactor,
|
||||
CROSS_SECTIONS_MM2,
|
||||
CURRENT_CAPACITY_A,
|
||||
minimumIzForProtection,
|
||||
temperatureOptions,
|
||||
type CableSizingInput,
|
||||
} from "../src/cable-sizing/domain/cable-sizing-calculation.js";
|
||||
import { cableSizingRequestSchema } from "../src/cable-sizing/domain/cable-sizing-contracts.js";
|
||||
import {
|
||||
deriveCircuitCosPhi,
|
||||
deriveCircuitPhase,
|
||||
toExistingProtection,
|
||||
} from "../src/frontend/components/cable-sizing-defaults.js";
|
||||
import type { CircuitTreeCircuitDto } from "../src/frontend/types.js";
|
||||
|
||||
const BASE_INPUT: CableSizingInput = {
|
||||
phase: 1,
|
||||
mode: "power",
|
||||
powerKw: 5,
|
||||
cosPhi: 1,
|
||||
voltage: 230,
|
||||
lengthM: 30,
|
||||
layingMethod: "C",
|
||||
conductorMaterial: "copper",
|
||||
insulation: "pvc",
|
||||
ambientTemperatureC: 30,
|
||||
groupingCircuits: 1,
|
||||
maxVoltageDropPercent: 3,
|
||||
harmonicNeutralLoad: "none",
|
||||
};
|
||||
|
||||
const capacity = (
|
||||
insulation: "pvc" | "xlpe",
|
||||
material: "copper" | "aluminum",
|
||||
method: keyof (typeof CURRENT_CAPACITY_A)["pvc"]["copper"],
|
||||
cores: 2 | 3,
|
||||
crossSection: number
|
||||
) =>
|
||||
CURRENT_CAPACITY_A[insulation][material][method][cores][
|
||||
CROSS_SECTIONS_MM2.indexOf(crossSection as (typeof CROSS_SECTIONS_MM2)[number])
|
||||
];
|
||||
|
||||
describe("cable sizing reference tables (DIN VDE 0298-4:2023-06)", () => {
|
||||
it("contains the checked PVC copper values", () => {
|
||||
assert.equal(capacity("pvc", "copper", "A1", 2, 1.5), 15.5);
|
||||
assert.equal(capacity("pvc", "copper", "A2", 2, 1.5), 15.5);
|
||||
assert.equal(capacity("pvc", "copper", "A1", 2, 2.5), 19.5);
|
||||
assert.equal(capacity("pvc", "copper", "A1", 2, 16), 61);
|
||||
assert.equal(capacity("pvc", "copper", "A1", 2, 120), 210);
|
||||
// DIN table 3: B2 from 50 mm² (previously copied from B1)
|
||||
assert.equal(capacity("pvc", "copper", "B2", 2, 50), 133);
|
||||
assert.equal(capacity("pvc", "copper", "B2", 2, 120), 232);
|
||||
// Tables 3 and 4, including reference method D in conduits/ducts.
|
||||
assert.equal(capacity("pvc", "copper", "C", 3, 6), 41);
|
||||
assert.equal(capacity("pvc", "copper", "D", 2, 2.5), 29);
|
||||
assert.equal(capacity("pvc", "copper", "A1", 2, 240), 321);
|
||||
assert.equal(capacity("pvc", "copper", "E", 3, 70), 196);
|
||||
});
|
||||
|
||||
it("uses aluminium only from the first DIN-tabulated size of 25 mm²", () => {
|
||||
for (const insulation of ["pvc", "xlpe"] as const) {
|
||||
for (const method of ["A1", "A2", "B1", "B2", "C", "D", "E"] as const) {
|
||||
for (const cores of [2, 3] as const) {
|
||||
for (const crossSection of CROSS_SECTIONS_MM2.filter((value) => value < 25)) {
|
||||
assert.equal(capacity(insulation, "aluminum", method, cores, crossSection), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(capacity("pvc", "aluminum", "C", 3, 25), 73);
|
||||
assert.equal(capacity("xlpe", "copper", "C", 2, 2.5), 33);
|
||||
assert.equal(capacity("xlpe", "aluminum", "E", 3, 25), 97);
|
||||
});
|
||||
|
||||
it("has a complete, increasing row for every combination", () => {
|
||||
for (const insulation of ["pvc", "xlpe"] as const) {
|
||||
for (const material of ["copper", "aluminum"] as const) {
|
||||
for (const [method, rows] of Object.entries(CURRENT_CAPACITY_A[insulation][material])) {
|
||||
for (const cores of [2, 3] as const) {
|
||||
const row = rows[cores];
|
||||
assert.equal(row.length, CROSS_SECTIONS_MM2.length, `${insulation}/${material}/${method}/${cores}`);
|
||||
const values = row.filter((value): value is number => value != null);
|
||||
for (let index = 1; index < values.length; index += 1) {
|
||||
assert.ok(values[index] > values[index - 1], `${insulation}/${material}/${method}/${cores}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateCableSizing", () => {
|
||||
it("sizes the reference case (1~, 5 kW, 230 V, 30 m, method C, Cu/PVC)", () => {
|
||||
const result = calculateCableSizing(BASE_INPUT);
|
||||
assert.equal(result.supported, true);
|
||||
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
||||
assert.equal(result.crossSectionByCapacityMm2, 2.5);
|
||||
assert.equal(result.crossSectionByVoltageDropMm2, 4);
|
||||
assert.equal(result.recommendedCrossSectionMm2, 4);
|
||||
const recommended = result.rows.find((row) => row.recommended)!;
|
||||
// The maximum length is the inverse of the voltage-drop formula.
|
||||
const atMaximum = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
lengthM: recommended.maxLengthForVoltageDropM!,
|
||||
});
|
||||
assert.ok(Math.abs(atMaximum.rows.find((row) => row.crossSectionMm2 === 4)!.voltageDropPercent! - 3) < 1e-9);
|
||||
});
|
||||
|
||||
it("rounds temperature and grouping to the next tabulated, safer value", () => {
|
||||
const warm = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 32 });
|
||||
assert.equal(warm.temperatureUsedC, 35);
|
||||
assert.equal(warm.temperatureFactor, 0.94);
|
||||
const cold = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 5 });
|
||||
assert.equal(cold.temperatureUsedC, 10);
|
||||
assert.equal(cold.temperatureFactor, 1.22);
|
||||
const exact = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 10 });
|
||||
assert.equal(exact.groupingUsed, 10);
|
||||
assert.equal(exact.groupingFactor, 0.48);
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 14 }).groupingFactor, 0.43);
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 18 }).groupingFactor, 0.39);
|
||||
const grouped = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 11 });
|
||||
assert.equal(grouped.groupingUsed, 12);
|
||||
assert.equal(grouped.groupingFactor, 0.45);
|
||||
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 11 }, grouped);
|
||||
assert.ok(alerts.some((alert) => alert.text.includes("12 Stromkreise gerundet")));
|
||||
});
|
||||
|
||||
it("uses the PVC 70 °C air factors, not the 60 °C column", () => {
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 20 }).temperatureFactor, 1.12);
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 50 }).temperatureFactor, 0.71);
|
||||
assert.equal(
|
||||
calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe", ambientTemperatureC: 65 }).temperatureFactor,
|
||||
0.65
|
||||
);
|
||||
});
|
||||
|
||||
it("limits grouping to the corrected DIN tables for D and E", () => {
|
||||
const d = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 6 });
|
||||
assert.equal(d.groupingFactor, 0.6);
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 7 }).supported, false);
|
||||
const e = calculateCableSizing({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 });
|
||||
assert.equal(e.supported, false);
|
||||
assert.equal(e.unsupportedReason, "grouping");
|
||||
assert.ok(buildCableSizingAlerts({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 }, e)[0]?.text.includes("Kabelpritsche"));
|
||||
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 30 }).temperatureFactor, 0.89);
|
||||
});
|
||||
|
||||
it("refuses inputs outside the tables instead of assuming a factor of 1", () => {
|
||||
const hot = calculateCableSizing({ ...BASE_INPUT, ambientTemperatureC: 65 });
|
||||
assert.equal(hot.supported, false);
|
||||
assert.equal(hot.unsupportedReason, "temperature");
|
||||
assert.equal(hot.recommendedCrossSectionMm2, null);
|
||||
assert.equal(buildCableSizingAlerts({ ...BASE_INPUT, ambientTemperatureC: 65 }, hot)[0]?.kind, "critical");
|
||||
const crowded = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 21 });
|
||||
assert.equal(crowded.supported, false);
|
||||
assert.equal(crowded.unsupportedReason, "grouping");
|
||||
});
|
||||
|
||||
it("never recommends a cross-section without tabulated capacity", () => {
|
||||
const result = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
powerKw: 0.5,
|
||||
lengthM: 5,
|
||||
conductorMaterial: "aluminum",
|
||||
});
|
||||
assert.equal(result.recommendedCrossSectionMm2, 25);
|
||||
for (const row of result.rows.filter((row) => row.crossSectionMm2 < 25)) {
|
||||
assert.equal(row.ratedCurrentA, null);
|
||||
assert.equal(row.recommended, false);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks gG fuses with I2 = 1.6 In and circuit-breakers with In ≤ Iz", () => {
|
||||
assert.equal(conventionalTrippingFactor({ kind: "breaker", ratedCurrentA: 16 }), 1.45);
|
||||
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 16 }), 1.6);
|
||||
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 10 }), 1.9);
|
||||
assert.equal(conventionalTrippingFactor({ kind: "fuse", ratedCurrentA: 4 }), 2.1);
|
||||
assert.ok(Math.abs(minimumIzForProtection({ kind: "fuse", ratedCurrentA: 16 }) - 17.655) < 0.01);
|
||||
|
||||
// At 40 °C: 1.5 mm² (C, 2 loaded) carries 19.5 × 0.87 = 16.97 A.
|
||||
const input = { ...BASE_INPUT, powerKw: 1, lengthM: 5, ambientTemperatureC: 40 };
|
||||
const breaker = calculateCableSizing({ ...input, existingProtection: { kind: "breaker", ratedCurrentA: 16 } });
|
||||
assert.equal(breaker.recommendedCrossSectionMm2, 1.5);
|
||||
assert.equal(breaker.protectionCoordination?.coordinated, true);
|
||||
const fuse = calculateCableSizing({ ...input, existingProtection: { kind: "fuse", ratedCurrentA: 16 } });
|
||||
assert.equal(fuse.recommendedCrossSectionMm2, 2.5);
|
||||
assert.equal(fuse.protectionCoordination?.coordinated, true);
|
||||
});
|
||||
|
||||
it("applies the 0.86 harmonic factor only for three-phase circuits with 15–33 %", () => {
|
||||
const three = calculateCableSizing({ ...BASE_INPUT, phase: 3, voltage: 400, harmonicNeutralLoad: "15to33Percent" });
|
||||
assert.equal(three.harmonicReductionApplied, true);
|
||||
assert.ok(Math.abs(three.combinedDerationFactor - 0.86) < 1e-9);
|
||||
const single = calculateCableSizing({ ...BASE_INPUT, harmonicNeutralLoad: "15to33Percent" });
|
||||
assert.equal(single.harmonicReductionApplied, false);
|
||||
});
|
||||
|
||||
it("does not recommend a cross-section above 33 % third harmonic without neutral current", () => {
|
||||
const input = { ...BASE_INPUT, phase: 3 as const, voltage: 400, harmonicNeutralLoad: "over33Percent" as const };
|
||||
const result = calculateCableSizing(input);
|
||||
assert.equal(result.supported, false);
|
||||
assert.equal(result.unsupportedReason, "harmonics");
|
||||
assert.equal(result.recommendedCrossSectionMm2, null);
|
||||
assert.equal(buildCableSizingAlerts(input, result)[0]?.kind, "critical");
|
||||
});
|
||||
|
||||
it("allows zero length and states that no voltage drop is applied", () => {
|
||||
const input = { ...BASE_INPUT, lengthM: 0 };
|
||||
const result = calculateCableSizing(input);
|
||||
assert.equal(result.supported, true);
|
||||
assert.equal(result.voltageDropAtRecommendedPercent, 0);
|
||||
assert.ok(buildCableSizingAlerts(input, result).some((alert) => alert.text.includes("kein Spannungsfall")));
|
||||
});
|
||||
|
||||
it("raises single-phase circuits to the 2.5 mm² practical minimum", () => {
|
||||
const result = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
powerKw: 1,
|
||||
lengthM: 5,
|
||||
circuitCategory: "single_phase",
|
||||
});
|
||||
assert.equal(result.recommendedCrossSectionMm2, 2.5);
|
||||
assert.equal(result.practicalMinimumApplied, true);
|
||||
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, powerKw: 1, lengthM: 5, circuitCategory: "single_phase" }, result);
|
||||
assert.ok(alerts.some((alert) => alert.text.includes("Auf 2,5 mm² angehoben")));
|
||||
assert.ok(!alerts.some((alert) => /d.d mm²/.test(alert.text)));
|
||||
});
|
||||
|
||||
it("offers only tabulated temperatures", () => {
|
||||
assert.deepEqual(temperatureOptions("C", "pvc").at(-1), 60);
|
||||
assert.deepEqual(temperatureOptions("C", "xlpe").at(-1), 80);
|
||||
assert.deepEqual(temperatureOptions("D", "pvc")[2], 20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cable sizing request schema", () => {
|
||||
it("accepts a complete request and rejects unknown or legacy fields", () => {
|
||||
assert.equal(cableSizingRequestSchema.safeParse(BASE_INPUT).success, true);
|
||||
assert.equal(
|
||||
cableSizingRequestSchema.safeParse({ ...BASE_INPUT, existingProtection: { kind: "fuse", ratedCurrentA: 25 } }).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
cableSizingRequestSchema.safeParse({ ...BASE_INPUT, existingProtectionRatedCurrentA: 16 }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "F" }).success, false);
|
||||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D1" }).success, false);
|
||||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D2" }).success, false);
|
||||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D" }).success, true);
|
||||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, mode: "current" }).success, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cable sizing defaults from the circuit", () => {
|
||||
const circuit = (rows: Array<{ power: number; cosPhi?: number; phaseType?: string }>): CircuitTreeCircuitDto => ({
|
||||
id: "circuit",
|
||||
circuitListId: "list",
|
||||
sectionId: "section",
|
||||
equipmentIdentifier: "-2F1.1",
|
||||
sortOrder: 1,
|
||||
isReserve: rows.length === 0,
|
||||
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0),
|
||||
deviceRows: rows.map((row, index) => ({
|
||||
id: `row-${index}`,
|
||||
sortOrder: index,
|
||||
name: "Gerät",
|
||||
displayName: "Gerät",
|
||||
quantity: 1,
|
||||
manualQuantity: 1,
|
||||
powerPerUnit: row.power,
|
||||
simultaneityFactor: 1,
|
||||
rowTotalPower: row.power,
|
||||
cosPhi: row.cosPhi,
|
||||
phaseType: row.phaseType,
|
||||
})),
|
||||
});
|
||||
|
||||
it("derives a power-weighted cos φ", () => {
|
||||
// P = 3 kW, S = 2 / 0.8 + 1 / 1 = 3.5 kVA → 0.857
|
||||
assert.equal(deriveCircuitCosPhi(circuit([{ power: 2, cosPhi: 0.8 }, { power: 1 }])), 0.86);
|
||||
assert.equal(deriveCircuitCosPhi(circuit([])), 1);
|
||||
});
|
||||
|
||||
it("derives the phase from the group category or three-phase rows", () => {
|
||||
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1);
|
||||
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);
|
||||
assert.equal(deriveCircuitPhase(circuit([]), "three_phase"), 3);
|
||||
});
|
||||
|
||||
it("maps protective devices to the overload-protection kind", () => {
|
||||
assert.deepEqual(toExistingProtection({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }), {
|
||||
kind: "breaker",
|
||||
ratedCurrentA: 16,
|
||||
});
|
||||
assert.deepEqual(toExistingProtection({ type: "NH00", ratedCurrentA: 63, fuseUtilizationCategory: "gG" }), {
|
||||
kind: "fuse",
|
||||
ratedCurrentA: 63,
|
||||
});
|
||||
assert.equal(toExistingProtection({ type: "FI", ratedCurrentA: 40, rcdType: "A", ratedResidualCurrentMa: 30 }), undefined);
|
||||
assert.equal(toExistingProtection(undefined), undefined);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue