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