leistungsbilanz-ts/src/cable-sizing/domain/cable-sizing-calculation.ts
Grovy311 49488f4e1b 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>
2026-09-26 11:16:18 +02:00

689 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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