forked from jappel/leistungsbilanz-ts
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:
parent
d95f0bf5c0
commit
49488f4e1b
17 changed files with 1999 additions and 4 deletions
274
tests/cable-sizing-calculation.test.ts
Normal file
274
tests/cable-sizing-calculation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue