331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
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 (DIN VDE 0298-4:2023-06)", () => {
|
||
it("contains the checked PVC copper values", () => {
|
||
assert.equal(capacity("pvc", "copper", "A1", 2, 1.5), 15.5);
|
||
assert.equal(capacity("pvc", "copper", "A2", 2, 1.5), 15.5);
|
||
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);
|
||
// DIN table 3: 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);
|
||
// Tables 3 and 4, including reference method D in conduits/ducts.
|
||
assert.equal(capacity("pvc", "copper", "C", 3, 6), 41);
|
||
assert.equal(capacity("pvc", "copper", "D", 2, 2.5), 29);
|
||
assert.equal(capacity("pvc", "copper", "A1", 2, 240), 321);
|
||
assert.equal(capacity("pvc", "copper", "E", 3, 70), 196);
|
||
});
|
||
|
||
it("uses aluminium only from the first DIN-tabulated size of 25 mm²", () => {
|
||
for (const insulation of ["pvc", "xlpe"] as const) {
|
||
for (const method of ["A1", "A2", "B1", "B2", "C", "D", "E"] as const) {
|
||
for (const cores of [2, 3] as const) {
|
||
for (const crossSection of CROSS_SECTIONS_MM2.filter((value) => value < 25)) {
|
||
assert.equal(capacity(insulation, "aluminum", method, cores, crossSection), null);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert.equal(capacity("pvc", "aluminum", "C", 3, 25), 73);
|
||
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 exact = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 10 });
|
||
assert.equal(exact.groupingUsed, 10);
|
||
assert.equal(exact.groupingFactor, 0.48);
|
||
assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 14 }).groupingFactor, 0.43);
|
||
assert.equal(calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 18 }).groupingFactor, 0.39);
|
||
const grouped = calculateCableSizing({ ...BASE_INPUT, groupingCircuits: 11 });
|
||
assert.equal(grouped.groupingUsed, 12);
|
||
assert.equal(grouped.groupingFactor, 0.45);
|
||
const alerts = buildCableSizingAlerts({ ...BASE_INPUT, groupingCircuits: 11 }, 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("limits grouping to the corrected DIN tables for D and E", () => {
|
||
const d = calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 6 });
|
||
assert.equal(d.groupingFactor, 0.6);
|
||
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", ambientTemperatureC: 20, groupingCircuits: 7 }).supported, false);
|
||
const e = calculateCableSizing({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 });
|
||
assert.equal(e.supported, false);
|
||
assert.equal(e.unsupportedReason, "grouping");
|
||
assert.ok(buildCableSizingAlerts({ ...BASE_INPUT, layingMethod: "E", groupingCircuits: 2 }, e)[0]?.text.includes("Kabelpritsche"));
|
||
assert.equal(calculateCableSizing({ ...BASE_INPUT, layingMethod: "D", 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, 25);
|
||
for (const row of result.rows.filter((row) => row.crossSectionMm2 < 25)) {
|
||
assert.equal(row.ratedCurrentA, null);
|
||
assert.equal(row.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("does not recommend a cross-section above 33 % third harmonic without neutral current", () => {
|
||
const input = { ...BASE_INPUT, phase: 3 as const, voltage: 400, harmonicNeutralLoad: "over33Percent" as const };
|
||
const result = calculateCableSizing(input);
|
||
assert.equal(result.supported, false);
|
||
assert.equal(result.unsupportedReason, "harmonics");
|
||
assert.equal(result.recommendedCrossSectionMm2, null);
|
||
assert.equal(buildCableSizingAlerts(input, result)[0]?.kind, "critical");
|
||
});
|
||
|
||
it("allows zero length and states that no voltage drop is applied", () => {
|
||
const input = { ...BASE_INPUT, lengthM: 0 };
|
||
const result = calculateCableSizing(input);
|
||
assert.equal(result.supported, true);
|
||
assert.equal(result.voltageDropAtRecommendedPercent, 0);
|
||
assert.ok(buildCableSizingAlerts(input, result).some((alert) => alert.text.includes("kein Spannungsfall")));
|
||
});
|
||
|
||
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("D", "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, layingMethod: "D1" }).success, false);
|
||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D2" }).success, false);
|
||
assert.equal(cableSizingRequestSchema.safeParse({ ...BASE_INPUT, layingMethod: "D" }).success, true);
|
||
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),
|
||
circuitApparentPower: rows.reduce(
|
||
(sum, row) => sum + row.power / (row.cosPhi ?? 1),
|
||
0
|
||
),
|
||
circuitCosPhi: rows.length
|
||
? rows.reduce((sum, row) => sum + row.power, 0) /
|
||
rows.reduce((sum, row) => sum + row.power / (row.cosPhi ?? 1), 0)
|
||
: 1,
|
||
downstreamBoardLoads: [],
|
||
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("uses the projected cos φ including downstream boards", () => {
|
||
const feeder = circuit([{ power: 1, cosPhi: 1 }]);
|
||
feeder.circuitCosPhi = 0.67;
|
||
feeder.downstreamBoardLoads = [{
|
||
distributionBoardId: "uv-2",
|
||
name: "UV 2",
|
||
activePowerKw: 3,
|
||
apparentPowerKva: 5,
|
||
cosPhi: 0.6,
|
||
}];
|
||
assert.equal(deriveCircuitCosPhi(feeder), 0.67);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|