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,274 @@
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 (IEC 60364-5-52 Annex B)", () => {
it("contains the checked PVC copper values", () => {
// B.52.2: A1 two loaded conductors (the previously wrong row)
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);
// B.52.2: 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);
// B.52.4 and D1/D2
assert.equal(capacity("pvc", "copper", "C", 3, 6), 41);
assert.equal(capacity("pvc", "copper", "D1", 2, 2.5), 29);
assert.equal(capacity("pvc", "copper", "D2", 3, 16), 70);
// Conflicting sources resolved to the lower value
assert.equal(capacity("pvc", "copper", "A1", 2, 240), 320);
assert.equal(capacity("pvc", "copper", "E", 3, 70), 196);
});
it("uses the aluminium and XLPE tables instead of flat factors", () => {
assert.equal(capacity("pvc", "aluminum", "A1", 2, 1.5), null);
assert.equal(capacity("pvc", "aluminum", "C", 3, 16), 59);
assert.equal(capacity("pvc", "aluminum", "D2", 2, 10), null);
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 grouped = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 10 });
assert.equal(grouped.groupingUsed, 12);
assert.equal(grouped.groupingFactor, 0.45);
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 10 }, 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("uses the ground tables for D1 (ducts) and D2 (direct)", () => {
const d1 = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D1", ambientTemperatureC: 20, groupingCircuits: 10 });
assert.equal(d1.groupingFactor, 0.49);
const d2 = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D2", ambientTemperatureC: 20, groupingCircuits: 10 });
assert.equal(d2.groupingUsed, 12);
assert.equal(d2.groupingFactor, 0.36);
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D2", 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, 2.5);
assert.equal(result.rows[0].ratedCurrentA, null);
assert.equal(result.rows[0].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("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("D1", "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, 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);
});
});