leistungsbilanz-ts/tests/cable-sizing-calculation.test.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

274 lines
12 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.

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