Add optional cable-sizing module

Per-circuit cross-section recommendation as a separate module attached to
the circuit list. It never writes project data itself: "Übernehmen" applies
cable type, cross-section and length through the regular circuit.update
command, so revisions and Undo/Redo stay intact.

- Pure domain module (src/cable-sizing) with IEC 60364-5-52:2009 Annex B
  tables for PVC and XLPE, copper and aluminium, methods A1, A2, B1, B2, C,
  D1, D2 and E. Every value was cross-checked against public reproductions;
  where sources differ the lower value is used (docs/cable-sizing-module.md).
- Temperature and grouping between table values round to the safer value;
  values outside the tables return no result instead of a factor of 1.
  Ground methods use B.52.18 (D2) and B.52.19 A (D1).
- Overload protection: circuit-breakers In <= Iz; gG fuses additionally
  I2 <= 1.45 Iz with I2 = 1.6/1.9/2.1 In.
- Inputs prefilled from the circuit: power, phase (group or device rows),
  project voltage, power-weighted cos phi, length and protective device.
- Opened from a "Leitungsberechnung" button in the row actions, not from a
  grid cell, so cable cells keep the spreadsheet behavior.
- Stateless API under /api/cable-sizing; no database table or migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 10:59:41 +02:00
parent d95f0bf5c0
commit 49488f4e1b
17 changed files with 1999 additions and 4 deletions

View file

@ -0,0 +1,689 @@
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/;
// the module is an optional calculator attached to the circuit list and never
// writes project data itself (see docs/cable-sizing-module.md).
//
// Reference data: IEC 60364-5-52:2009, Annex B (tables B.52.2 to B.52.5,
// B.52.10 to B.52.19). Every value was cross-checked against publicly
// available reproductions of these tables; where sources disagreed the lower,
// i.e. safer, value is used. The table-by-table verification record is in
// docs/cable-sizing-module.md. National tables (DIN VDE 0298-4) may differ in
// individual values and must be checked by the planner against the norm text.
// -- Reference installation methods (IEC 60364-5-52, Table B.52.1) ----------
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D1", "D2", "E"] as const;
export type LayingMethod = (typeof LAYING_METHODS)[number];
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
A1: "air",
A2: "air",
B1: "air",
B2: "air",
C: "air",
E: "air",
D1: "ground",
D2: "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",
D1: "D1 – Leitung im Rohr oder Kabelschutzrohr im Erdreich",
D2: "D2 – Kabel direkt im Erdreich",
E: "E – Mehradrige Leitung frei in Luft",
};
// -- Insulation / conductor material ----------------------------------------
export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const;
export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number];
export const INSULATION_MATERIAL_LABELS: Record<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 (IEC 60364-5-52, Annex E, three-phase only) ---
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
"none",
"15to33Percent",
"over33Percent",
] as const;
export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number];
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
over33Percent: "3. Harmonische > 33 % – Neutralleiter wie Außenleiter dimensionieren",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
// -- Current-carrying capacity tables ----------------------------------------
export const CROSS_SECTIONS_MM2 = [
1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240,
] as const;
type CoreCount = 2 | 3;
type CapacityRow = readonly (number | null)[];
type CapacityTable = Record<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: {
// B.52.2 / B.52.4; E: B.52.10 multi-core. A1 2L 240 mm²: sources 320/321 → 320.
// E 3L 70/120 mm²: sources 196/198 and 276/278 → 196 and 276.
A1: {
2: [14.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 320],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
},
A2: {
2: [14, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
},
B1: {
2: [17.5, 24, 32, 41, 57, 76, 101, 125, 151, 192, 232, 269, 300, 341, 400],
3: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, 262, 296, 346],
},
B2: {
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 133, 168, 201, 232, 258, 294, 344],
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, 225, 255, 297],
},
C: {
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
},
D1: {
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
},
D2: {
2: [22, 28, 38, 48, 64, 83, 110, 132, 156, 192, 230, 261, 293, 331, 382],
3: [19, 24, 33, 41, 54, 70, 92, 110, 130, 162, 193, 220, 246, 278, 320],
},
E: {
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
},
},
aluminum: {
// B.52.2 / B.52.4 aluminium; E: B.52.11. No values below 2.5 mm²
// (D2 below 16 mm²).
A1: {
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
},
A2: {
2: [n, 14.5, 19.5, 25, 33, 44, 58, 71, 86, 108, 130, 150, 172, 195, 229],
3: [n, 13.5, 17.5, 23, 31, 41, 53, 65, 78, 98, 118, 135, 155, 176, 207],
},
B1: {
2: [n, 18.5, 25, 32, 44, 60, 79, 97, 118, 150, 181, 210, 234, 266, 312],
3: [n, 16.5, 22, 28, 39, 53, 70, 86, 104, 133, 161, 186, 204, 230, 269],
},
B2: {
2: [n, 17.5, 24, 30, 41, 54, 71, 86, 104, 131, 157, 181, 201, 230, 269],
3: [n, 15.5, 21, 27, 36, 48, 62, 77, 92, 116, 139, 160, 176, 199, 232],
},
C: {
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
},
D1: {
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
},
D2: {
2: [n, n, n, n, n, 63, 82, 98, 117, 145, 173, 200, 224, 255, 298],
3: [n, n, n, n, n, 53, 69, 83, 99, 122, 148, 169, 189, 214, 250],
},
E: {
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
},
},
},
xlpe: {
copper: {
// B.52.3 / B.52.5; E: B.52.12 multi-core.
A1: {
2: [19, 26, 35, 45, 61, 81, 106, 131, 158, 200, 241, 278, 318, 362, 424],
3: [17, 23, 31, 40, 54, 73, 95, 117, 141, 179, 216, 249, 285, 324, 380],
},
A2: {
2: [18.5, 25, 33, 42, 57, 76, 99, 121, 145, 183, 220, 253, 290, 329, 386],
3: [16.5, 22, 30, 38, 51, 68, 89, 109, 130, 164, 197, 227, 259, 295, 346],
},
B1: {
2: [23, 31, 42, 54, 75, 100, 133, 164, 198, 253, 306, 354, 393, 449, 528],
3: [20, 28, 37, 48, 66, 88, 117, 144, 175, 222, 269, 312, 342, 384, 450],
},
B2: {
2: [22, 30, 40, 51, 69, 91, 119, 146, 175, 221, 265, 305, 334, 384, 459],
3: [19.5, 26, 35, 44, 60, 80, 105, 128, 154, 194, 233, 268, 300, 340, 398],
},
C: {
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
},
D1: {
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
},
D2: {
2: [27, 35, 46, 58, 77, 100, 129, 155, 183, 225, 270, 306, 343, 387, 448],
3: [23, 30, 39, 49, 65, 84, 107, 129, 153, 188, 226, 257, 287, 324, 375],
},
E: {
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
},
},
aluminum: {
// B.52.3 / B.52.5 aluminium; E: B.52.13.
A1: {
2: [n, 20, 27, 35, 48, 64, 84, 103, 125, 158, 191, 220, 253, 288, 338],
3: [n, 19, 25, 32, 44, 58, 76, 94, 113, 142, 171, 197, 226, 256, 300],
},
A2: {
2: [n, 19.5, 26, 33, 45, 60, 78, 96, 115, 145, 175, 201, 230, 262, 307],
3: [n, 18, 24, 31, 41, 55, 71, 87, 104, 131, 157, 180, 206, 233, 273],
},
B1: {
2: [n, 25, 33, 43, 59, 79, 105, 130, 157, 200, 242, 281, 307, 351, 412],
3: [n, 22, 29, 38, 52, 71, 93, 116, 140, 179, 217, 251, 267, 300, 351],
},
B2: {
2: [n, 23, 31, 40, 54, 72, 94, 115, 138, 175, 210, 242, 261, 300, 358],
3: [n, 21, 28, 35, 48, 64, 84, 103, 124, 156, 188, 216, 240, 272, 318],
},
C: {
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
},
D1: {
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
},
D2: {
2: [n, n, n, n, n, 76, 98, 117, 139, 170, 204, 233, 261, 296, 343],
3: [n, n, n, n, n, 64, 82, 98, 117, 144, 172, 197, 220, 250, 290],
},
E: {
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
},
},
},
};
// -- Correction factors ------------------------------------------------------
/** B.52.14 (air, reference 30 °C) and B.52.15 (ground, reference 20 °C). */
export const TEMPERATURE_FACTORS: Record<
"air" | "ground",
Record<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 for touching circuits/cables:
* air (A1–C, E): B.52.17 item 1 "bunched in air, on a surface, embedded or
* enclosed" – the most restrictive arrangement, also used for E (single
* layers on trays would allow higher factors);
* D1: B.52.19 A (multi-core cables in touching ducts);
* D2: B.52.18 (cables direct in ground, touching).
*/
export const GROUPING_FACTORS: Record<"air" | "D1" | "D2", Record<number, number>> = {
air: { 1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52, 9: 0.5, 12: 0.45, 16: 0.41, 20: 0.38 },
D1: {
1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6, 7: 0.57, 8: 0.54, 9: 0.52, 10: 0.49,
11: 0.47, 12: 0.45, 13: 0.44, 14: 0.42, 15: 0.41, 16: 0.39, 17: 0.38, 18: 0.37, 19: 0.35, 20: 0.34,
},
D2: { 1: 1.0, 2: 0.75, 3: 0.65, 4: 0.6, 5: 0.55, 6: 0.5, 7: 0.45, 8: 0.43, 9: 0.41, 12: 0.36, 16: 0.32, 20: 0.29 },
};
export const MAX_GROUPING_CIRCUITS = 20;
function groupingTableFor(method: LayingMethod) {
return method === "D1" ? GROUPING_FACTORS.D1 : method === "D2" ? GROUPING_FACTORS.D2 : GROUPING_FACTORS.air;
}
/**
* Looks up a tabulated factor. Values between tabulated keys use the next
* higher key (the lower, safer factor); values above the table are not
* supported (null) and values below the smallest key use the smallest key
* (whose factor is lower than the true one, i.e. safe).
*/
function lookupConservative(table: Record<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 D1/D2. */
ambientTemperatureC: number;
/** Number of grouped circuits or multi-core cables, including this one. */
groupingCircuits: number;
maxVoltageDropPercent: number;
harmonicNeutralLoad: HarmonicNeutralLoad;
existingProtection?: ExistingProtection;
circuitCategory?: CircuitCategory;
}
export interface CrossSectionRow {
crossSectionMm2: number;
/** Tabulated Iz; null if the table has no value for this size. */
ratedCurrentA: number | null;
correctedCurrentA: number | null;
currentSufficient: boolean | null;
voltageDropPercent: number | null;
voltageDropSufficient: boolean | null;
/** Longest single run meeting maxVoltageDropPercent at this load. */
maxLengthForVoltageDropM: number | null;
recommended: boolean;
}
export interface ProtectionCoordinationResult {
ratedCurrentA: number;
kind: ProtectionKind;
/** I2 / In used for the check. */
conventionalTrippingFactor: number;
/** In ≤ Iz and I2 ≤ 1.45 · Iz for the recommended cross-section. */
coordinated: boolean;
}
export type UnsupportedReason = "temperature" | "grouping";
export interface CableSizingResult {
/** false: the inputs are outside the tabulated range – no numeric result. */
supported: boolean;
unsupportedReason: UnsupportedReason | null;
operatingCurrentA: number;
/**
* Current the cross-section must carry after correction:
* max(Ib, In, I2/1.45) with an existing protective device, otherwise Ib.
*/
designCurrentA: number;
/** Tabulated temperature/grouping actually used (rounded to the safe side). */
temperatureUsedC: number | null;
groupingUsed: number | null;
temperatureFactor: number | null;
groupingFactor: number | null;
crossSectionByCapacityMm2: number | null;
crossSectionByVoltageDropMm2: number | null;
recommendedCrossSectionMm2: number | null;
voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number;
harmonicReductionApplied: boolean;
practicalMinimumApplied: boolean;
rows: CrossSectionRow[];
protectionCoordination: ProtectionCoordinationResult | null;
}
function operatingCurrentA(input: CableSizingInput): number {
if (input.mode === "current") {
return input.currentA ?? 0;
}
const powerW = (input.powerKw ?? 0) * 1000;
return input.phase === 1
? powerW / (input.voltage * input.cosPhi)
: powerW / (Math.sqrt(3) * input.voltage * input.cosPhi);
}
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
const ib = operatingCurrentA(input);
const designCurrentA = input.existingProtection
? Math.max(ib, minimumIzForProtection(input.existingProtection))
: ib;
const group = LAYING_METHOD_GROUP[input.layingMethod];
const temperature = lookupConservative(
TEMPERATURE_FACTORS[group][input.insulation],
input.ambientTemperatureC
);
const grouping =
input.groupingCircuits <= MAX_GROUPING_CIRCUITS
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
: null;
const empty: CableSizingResult = {
supported: false,
unsupportedReason: !temperature ? "temperature" : "grouping",
operatingCurrentA: ib,
designCurrentA,
temperatureUsedC: temperature?.key ?? null,
groupingUsed: grouping?.key ?? null,
temperatureFactor: temperature?.factor ?? null,
groupingFactor: grouping?.factor ?? null,
crossSectionByCapacityMm2: null,
crossSectionByVoltageDropMm2: null,
recommendedCrossSectionMm2: null,
voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1,
harmonicReductionApplied: false,
practicalMinimumApplied: false,
rows: [],
protectionCoordination: null,
};
if (!temperature || !grouping) {
return empty;
}
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
const harmonicReductionApplied =
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
const combinedDerationFactor =
temperature.factor * grouping.factor * (harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1);
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
const kappa = KAPPA[input.conductorMaterial];
const ratedCurrents =
CURRENT_CAPACITY_A[input.insulation][input.conductorMaterial][input.layingMethod][coreCount];
const correctedCurrents = ratedCurrents.map((value) =>
value == null ? null : value * combinedDerationFactor
);
const voltageDrops = CROSS_SECTIONS_MM2.map(
(crossSection) =>
((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) / (kappa * crossSection) / input.voltage) * 100
);
const indexByCapacity = correctedCurrents.findIndex(
(value) => value != null && value >= designCurrentA
);
const indexByVoltageDrop = voltageDrops.findIndex(
(value, index) => ratedCurrents[index] != null && value <= input.maxVoltageDropPercent
);
let indexRecommended =
indexByCapacity >= 0 && indexByVoltageDrop >= 0 ? Math.max(indexByCapacity, indexByVoltageDrop) : -1;
const practicalMinimumMm2 = input.circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory]
: undefined;
let practicalMinimumApplied = false;
if (indexRecommended >= 0 && practicalMinimumMm2 != null && CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2) {
const minimumIndex = CROSS_SECTIONS_MM2.indexOf(practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number]);
if (minimumIndex >= 0 && ratedCurrents[minimumIndex] != null) {
indexRecommended = minimumIndex;
practicalMinimumApplied = true;
}
}
const voltageDropCurrentBasis = ib * input.cosPhi;
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, index) => {
const rated = ratedCurrents[index];
const corrected = correctedCurrents[index];
return {
crossSectionMm2: crossSection,
ratedCurrentA: rated,
correctedCurrentA: corrected,
currentSufficient: corrected == null ? null : corrected >= designCurrentA,
voltageDropPercent: rated == null ? null : voltageDrops[index],
voltageDropSufficient: rated == null ? null : voltageDrops[index] <= input.maxVoltageDropPercent,
maxLengthForVoltageDropM:
rated == null || voltageDropCurrentBasis <= 0
? null
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
(100 * voltageDropCoefficient * voltageDropCurrentBasis),
recommended: index === indexRecommended,
};
});
let protectionCoordination: ProtectionCoordinationResult | null = null;
if (input.existingProtection && indexRecommended >= 0) {
const iz = rows[indexRecommended].correctedCurrentA;
const factor = conventionalTrippingFactor(input.existingProtection);
const inA = input.existingProtection.ratedCurrentA;
protectionCoordination = {
ratedCurrentA: inA,
kind: input.existingProtection.kind,
conventionalTrippingFactor: factor,
coordinated: iz != null && inA <= iz && factor * inA <= 1.45 * iz,
};
}
return {
...empty,
supported: true,
unsupportedReason: null,
crossSectionByCapacityMm2: indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
crossSectionByVoltageDropMm2: indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null,
recommendedCrossSectionMm2: indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null,
voltageDropAtRecommendedPercent: indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
combinedDerationFactor,
harmonicReductionApplied,
practicalMinimumApplied,
rows,
protectionCoordination,
};
}
// -- Alerts -------------------------------------------------------------------
export interface CableSizingAlert {
kind: "critical" | "warn" | "info" | "ok";
text: string;
}
const numberFormat = (value: number, digits = 1) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
const crossSectionFormat = (value: number) =>
`${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} mm²`;
export function buildCableSizingAlerts(input: CableSizingInput, result: CableSizingResult): CableSizingAlert[] {
const alerts: CableSizingAlert[] = [];
const group = LAYING_METHOD_GROUP[input.layingMethod];
if (!result.supported) {
alerts.push({
kind: "critical",
text:
result.unsupportedReason === "temperature"
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt außerhalb der Tabelle (max. ${MAX_GROUPING_CIRCUITS}) – keine Berechnung möglich.`,
});
return alerts;
}
if (result.temperatureUsedC !== input.ambientTemperatureC) {
alerts.push({
kind: "info",
text: `Temperatur auf den nächsten Tabellenwert ${result.temperatureUsedC} °C gerundet (sichere Seite).`,
});
}
if (result.groupingUsed !== input.groupingCircuits) {
alerts.push({
kind: "info",
text: `Häufung auf den nächsten Tabellenwert ${result.groupingUsed} Stromkreise gerundet (sichere Seite).`,
});
}
const byCapacity = result.crossSectionByCapacityMm2;
const byDrop = result.crossSectionByVoltageDropMm2;
if (byCapacity == null) {
const dueToProtection =
input.existingProtection != null && result.designCurrentA > result.operatingCurrentA;
alerts.push({
kind: "critical",
text: dueToProtection
? `Das vorhandene Schutzorgan (${input.existingProtection!.ratedCurrentA} A) verlangt Iz ≥ ${numberFormat(result.designCurrentA)} A – das erreicht bei Verlegeart ${input.layingMethod} kein Querschnitt bis 240 mm². Verlegeart oder Schutzorgan prüfen.`
: `Der Betriebsstrom ${numberFormat(result.operatingCurrentA)} A übersteigt bei Verlegeart ${input.layingMethod} die Belastbarkeit aller Querschnitte bis 240 mm².`,
});
} else if (byDrop == null) {
alerts.push({
kind: "critical",
text: `Der Spannungsfall von ${numberFormat(input.maxVoltageDropPercent)} % wird bei ${input.lengthM} m mit keinem Querschnitt bis 240 mm² eingehalten.`,
});
} else if (byDrop > byCapacity) {
alerts.push({
kind: "warn",
text: `Der Spannungsfall ist maßgeblich: ${crossSectionFormat(byCapacity)} reichen thermisch, für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} % sind ${crossSectionFormat(byDrop)} nötig.`,
});
}
if (result.harmonicReductionApplied) {
alerts.push({
kind: "info",
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
});
}
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
alerts.push({
kind: "warn",
text: "3. Harmonische > 33 %: Der Neutralleiter ist nach dem Neutralleiterstrom zu bemessen – hier nicht berücksichtigt.",
});
}
if (group === "ground") {
alerts.push({
kind: "info",
text: "Erdverlegung: Bodenwärmewiderstand 2,5 K·m/W angenommen (Tabellenreferenz).",
});
}
if (result.practicalMinimumApplied) {
alerts.push({
kind: "info",
text: `Auf ${crossSectionFormat(result.recommendedCrossSectionMm2!)} angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine Rechenanforderung).`,
});
}
if (result.protectionCoordination) {
const coordination = result.protectionCoordination;
const kindLabel = coordination.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG";
alerts.push(
coordination.coordinated
? {
kind: "info",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A berücksichtigt: In ≤ Iz und I₂ = ${numberFormat(coordination.conventionalTrippingFactor, 2)} · In ≤ 1,45 · Iz. Kurzschlussschutz und Abschaltbedingungen sind nicht Teil dieser Berechnung.`,
}
: {
kind: "warn",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A ist mit dem empfohlenen Querschnitt nicht koordiniert (In ≤ Iz, I₂ ≤ 1,45 · Iz).`,
}
);
}
const recommendedRow = result.rows.find((row) => row.recommended);
if (recommendedRow && byCapacity != null && byDrop != null) {
if (!alerts.some((alert) => alert.kind === "warn" || alert.kind === "critical")) {
alerts.push({
kind: "ok",
text: `${crossSectionFormat(recommendedRow.crossSectionMm2)} ${input.conductorMaterial === "aluminum" ? "Al" : "Cu"} erfüllt bei ${input.lengthM} m Belastbarkeit (${numberFormat(result.designCurrentA)} A) und Spannungsfall (ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %).`,
});
}
if (recommendedRow.maxLengthForVoltageDropM != null) {
alerts.push({
kind: "info",
text: `Maximale Länge bei ${crossSectionFormat(recommendedRow.crossSectionMm2)} für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
});
}
}
return alerts;
}

View 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>;

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

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

View file

@ -0,0 +1,571 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
HARMONIC_NEUTRAL_LOAD_LABELS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
MAX_GROUPING_CIRCUITS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
REFERENCE_TEMPERATURE_C,
temperatureOptions,
type CableSizingAlert,
type CableSizingInput,
type CableSizingResult,
type CircuitCategory,
type ConductorMaterial,
type HarmonicNeutralLoad,
type InsulationMaterial,
type LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import { calculateCableSizing } from "./cable-sizing-api";
import type { CircuitTreeCircuitDto } from "../types";
import {
deriveCircuitCosPhi,
deriveCircuitPhase,
toExistingProtection,
} from "./cable-sizing-defaults";
import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
isSaving: boolean;
onClose: () => void;
onApply: (patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) => Promise<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 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 nach IEC 60364-5-52 Anhang B (Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan) oder direkte Eingabe. Die Empfehlung vor der Übernahme fachlich prüfen; nationale Tabellen (DIN VDE 0298-4) können abweichen."
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}
/>
</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);
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={MAX_GROUPING_CIRCUITS}
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.
</div>
</div>
{phase === 3 ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungen im Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) => {
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad);
resetResult();
}}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-control"
id="cable-sizing-cosphi"
max={1}
min={0.1}
onChange={(event) => {
setCosPhi(Math.min(1, Math.max(0.1, Number(event.target.value) || 1)));
resetResult();
}}
step={0.01}
type="number"
value={cosPhi}
/>
<div className="form-text">
Aus den Gerätezeilen: {formatNumber(derivedCosPhi, 2)}
</div>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. ΔU [%]
</label>
<input
className="form-control"
id="cable-sizing-voltage-drop"
max={10}
min={0.5}
onChange={(event) => {
setMaxVoltageDropPercent(Number(event.target.value) || 3);
resetResult();
}}
step={0.5}
type="number"
value={maxVoltageDropPercent}
/>
</div>
<div className="col-12">
<div className="form-text">
Schutzorgan:{" "}
{existingProtection
? `${existingProtection.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG"} ${existingProtection.ratedCurrentA} A (aus dem Stromkreis, In ≤ Iz und I₂ ≤ 1,45 · Iz)`
: "keines mit Überstromschutz hinterlegt – Bemessung nur nach Betriebsstrom"}
</div>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet …" : "Berechnen"}
</button>
{error ? <p className="text-danger mt-2">{error}</p> : null}
{result ? (
<div className="mt-3">
{result.supported ? (
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom I<sub>B</sub></div>
<div>{formatNumber(result.operatingCurrentA, 1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Erforderliches I<sub>z</sub></div>
<div>
<strong>{formatNumber(result.designCurrentA, 1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2 != null
? `${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²`
: "–"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${formatNumber(result.voltageDropAtRecommendedPercent, 2)} %`
: "–"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor gesamt</div>
<div>{formatNumber(result.combinedDerationFactor, 2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Temperatur / Häufung (gerechnet)</div>
<div>
{result.temperatureUsedC} °C ({formatNumber(result.temperatureFactor ?? 1, 2)}) /{" "}
{result.groupingUsed} ({formatNumber(result.groupingFactor ?? 1, 2)})
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge für ΔU</div>
<div>
{recommendedRow?.maxLengthForVoltageDropM != null
? `${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m`
: "–"}
</div>
</div>
</div>
) : null}
{alerts.map((alert, index) => (
<div
className={`alert mt-2 mb-0 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
</div>
) : null}
<hr className="my-3" />
<p className="text-muted small mb-2">
Kabeltyp und Querschnitt direkt eingeben oder die Empfehlung übernehmen.
Beides landet in denselben Feldern und kann vor dem Übernehmen angepasst
werden.
</p>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-type">
Kabeltyp
</label>
<input
className="form-control"
id="cable-sizing-manual-type"
onChange={(event) => setManualCableType(event.target.value)}
placeholder="z. B. NYM-J 3x2,5"
value={manualCableType}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
Querschnitt
</label>
<input
className="form-control"
id="cable-sizing-manual-cross-section"
onChange={(event) => setManualCrossSection(event.target.value)}
placeholder="z. B. 4 mm²"
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning ? (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
) : null}
</div>
</FormModal>
);
}

View file

@ -5,6 +5,7 @@ import {
ArrowDown,
ArrowDown01,
ArrowUp,
Calculator,
Check,
Columns3,
FilterX,
@ -160,6 +161,7 @@ import type {
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { CableSizingModal } from "./cable-sizing-modal";
import {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
@ -319,6 +321,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<CircuitGroupEditorIntent | 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);
@ -1439,6 +1443,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleApplyCableSizing(patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) {
const circuit = cableSizingEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Leitungsberechnung übernehmen",
redo: async () => {
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuit.id,
patch
);
applyProjectCommandResult(result);
setCableSizingEditorCircuit(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@ -3185,6 +3214,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitProtection}
/>
) : null}
{cableSizingEditorCircuit ? (
<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}
@ -4530,6 +4574,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}

View 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],
}))
);
}

View file

@ -2,6 +2,7 @@ import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
import { closeDefaultDatabase } from "../db/client.js";
@ -48,6 +49,7 @@ app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/cable-sizing", cableSizingRouter);
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);

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