forked from jappel/leistungsbilanz-ts
- Cross-section rows now include maxLengthForVoltageDropM, the inverse of the voltage-drop formula: the longest single run that stays within the requested max voltage drop at the given load/cosPhi/phase. Shown as a KPI and in the ok-summary alert. - Added PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (2.5 mm² for single_phase circuits) - a planning convention already named in this projects own docs/spec/06-future-sizing-and-calculations.md, not a thermal/ voltage-drop requirement. Only ever raises a calculated recommendation, never lowers one that already needs more. circuitCategory is looked up from the circuits section and passed through from the editor. - The manual cross-section field now warns (not blocks - manual override must stay possible per the same spec doc) when the typed value is not a standard cross-section, is smaller than the last calculation, or is below the practical minimum for single_phase circuits. - Modal title now includes the circuit displayName next to the equipment identifier. - Replaced the calculator emoji trigger icon with an inline SVG - the emoji did not render in at least one tested environment (missing font glyph), SVG has no such dependency.
209 lines
8.3 KiB
TypeScript
209 lines
8.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import {
|
|
buildCableSizingAlerts,
|
|
calculateCableSizing,
|
|
isCableSizingDataVerified,
|
|
type CableSizingInput,
|
|
} from "../src/cable-sizing/domain/cable-sizing-calculation.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",
|
|
};
|
|
|
|
describe("calculateCableSizing", () => {
|
|
it("matches the verified reference case (1~, 5 kW, 230 V, 30 m, method C, copper)", () => {
|
|
const result = calculateCableSizing(BASE_INPUT);
|
|
assert.equal(result.dataVerified, 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);
|
|
});
|
|
|
|
it("returns dataVerified: false and no numeric result for an unverified laying method", () => {
|
|
const result = calculateCableSizing({ ...BASE_INPUT, layingMethod: "A2" });
|
|
assert.equal(result.dataVerified, false);
|
|
assert.equal(result.recommendedCrossSectionMm2, null);
|
|
assert.equal(result.rows.length, 0);
|
|
// The operating current itself does not depend on the capacity table
|
|
// and is still reported so the UI can show at least that much.
|
|
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
|
});
|
|
|
|
it("returns dataVerified: false for xlpe regardless of method", () => {
|
|
const result = calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe" });
|
|
assert.equal(result.dataVerified, false);
|
|
});
|
|
|
|
it("isCableSizingDataVerified matches the six ported, verified methods only", () => {
|
|
for (const method of ["A1", "B2", "C", "E", "D1", "D2"] as const) {
|
|
assert.equal(isCableSizingDataVerified(method, "pvc"), true, method);
|
|
}
|
|
for (const method of ["A2", "B1", "F", "G"] as const) {
|
|
assert.equal(isCableSizingDataVerified(method, "pvc"), false, method);
|
|
}
|
|
});
|
|
|
|
it("applies the 0.86 harmonic reduction factor only for three-phase + 15to33Percent", () => {
|
|
const threePhase: CableSizingInput = {
|
|
...BASE_INPUT,
|
|
phase: 3,
|
|
mode: "current",
|
|
currentA: 10,
|
|
powerKw: undefined,
|
|
voltage: 400,
|
|
};
|
|
const base = calculateCableSizing({ ...threePhase, harmonicNeutralLoad: "none" });
|
|
const derated = calculateCableSizing({
|
|
...threePhase,
|
|
harmonicNeutralLoad: "15to33Percent",
|
|
});
|
|
assert.equal(base.harmonicReductionApplied, false);
|
|
assert.equal(derated.harmonicReductionApplied, true);
|
|
assert.ok(
|
|
Math.abs(derated.combinedDerationFactor - base.combinedDerationFactor * 0.86) < 1e-9
|
|
);
|
|
|
|
const singlePhaseWithHarmonics = calculateCableSizing({
|
|
...BASE_INPUT,
|
|
harmonicNeutralLoad: "15to33Percent",
|
|
});
|
|
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
|
|
});
|
|
|
|
it("uses max(operatingCurrentA, existingProtectionRatedCurrentA) as the design current for cross-section selection", () => {
|
|
// Load alone (21.7 A) would recommend 4 mm² (see the reference case
|
|
// above); a 32 A breaker on the same circuit must still be covered by
|
|
// the cable (In <= Iz), so the recommendation should grow accordingly.
|
|
const withoutBreaker = calculateCableSizing(BASE_INPUT);
|
|
const withBreaker = calculateCableSizing({
|
|
...BASE_INPUT,
|
|
existingProtectionRatedCurrentA: 32,
|
|
});
|
|
assert.equal(withoutBreaker.designCurrentA, withoutBreaker.operatingCurrentA);
|
|
assert.equal(withBreaker.designCurrentA, 32);
|
|
assert.ok(
|
|
(withBreaker.recommendedCrossSectionMm2 ?? 0) >=
|
|
(withoutBreaker.recommendedCrossSectionMm2 ?? 0)
|
|
);
|
|
assert.ok(withBreaker.protectionCoordination?.coordinated);
|
|
});
|
|
|
|
it("reports an oversized breaker as the limiting factor when no cross-section can cover it", () => {
|
|
const result = calculateCableSizing({
|
|
...BASE_INPUT,
|
|
existingProtectionRatedCurrentA: 1000,
|
|
});
|
|
assert.equal(result.designCurrentA, 1000);
|
|
assert.equal(result.recommendedCrossSectionMm2, null);
|
|
// No cross-section satisfies the design current at all, so there is no
|
|
// "recommended but under-protected" case to flag - protectionCoordination
|
|
// is only meaningful once a recommendation exists.
|
|
assert.equal(result.protectionCoordination, null);
|
|
|
|
const alerts = buildCableSizingAlerts(
|
|
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
|
|
result
|
|
);
|
|
assert.equal(alerts.length, 1);
|
|
assert.equal(alerts[0].kind, "critical");
|
|
assert.ok(alerts[0].text.includes("Vorhandene Sicherung"));
|
|
});
|
|
});
|
|
|
|
describe("buildCableSizingAlerts", () => {
|
|
it("returns a single critical alert for an unverified combination, no numeric claims", () => {
|
|
const input: CableSizingInput = { ...BASE_INPUT, layingMethod: "G" };
|
|
const result = calculateCableSizing(input);
|
|
const alerts = buildCableSizingAlerts(input, result);
|
|
assert.equal(alerts.length, 1);
|
|
assert.equal(alerts[0].kind, "critical");
|
|
assert.ok(alerts[0].text.includes("keine geprüften"));
|
|
});
|
|
|
|
it("returns an ok alert plus a max-length info alert for a clean, unremarkable case", () => {
|
|
const input: CableSizingInput = {
|
|
...BASE_INPUT,
|
|
mode: "current",
|
|
currentA: 15,
|
|
powerKw: undefined,
|
|
lengthM: 3,
|
|
};
|
|
const result = calculateCableSizing(input);
|
|
const alerts = buildCableSizingAlerts(input, result);
|
|
assert.equal(alerts.length, 2);
|
|
assert.equal(alerts[0].kind, "ok");
|
|
assert.equal(alerts[1].kind, "info");
|
|
assert.ok(alerts[1].text.includes("Maximale Länge"));
|
|
});
|
|
});
|
|
|
|
describe("maxLengthForVoltageDropM", () => {
|
|
it("is the inverse of the voltage-drop formula: recalculating at that length gives back the limit", () => {
|
|
const result = calculateCableSizing(BASE_INPUT);
|
|
const recommendedRow = result.rows.find((row) => row.recommended);
|
|
assert.ok(recommendedRow?.maxLengthForVoltageDropM != null);
|
|
|
|
const atMaxLength = calculateCableSizing({
|
|
...BASE_INPUT,
|
|
lengthM: recommendedRow!.maxLengthForVoltageDropM!,
|
|
});
|
|
const rowAtSameCrossSection = atMaxLength.rows.find(
|
|
(row) => row.crossSectionMm2 === recommendedRow!.crossSectionMm2
|
|
);
|
|
assert.ok(
|
|
Math.abs(rowAtSameCrossSection!.voltageDropPercent! - BASE_INPUT.maxVoltageDropPercent) <
|
|
0.01
|
|
);
|
|
});
|
|
|
|
it("is null when there is no current flowing (division by zero guard)", () => {
|
|
const result = calculateCableSizing({ ...BASE_INPUT, mode: "current", currentA: 0, powerKw: undefined });
|
|
assert.ok(result.rows.every((row) => row.maxLengthForVoltageDropM === null));
|
|
});
|
|
});
|
|
|
|
describe("practical minimum cross-section for single_phase circuits", () => {
|
|
it("raises a smaller calculated recommendation to 2.5 mm² for single_phase circuits", () => {
|
|
// 1 A load at 30 m would normally recommend 1.5 mm² by calculation alone.
|
|
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
|
|
const withoutCategory = calculateCableSizing(smallLoad);
|
|
const withCategory = calculateCableSizing({ ...smallLoad, circuitCategory: "single_phase" });
|
|
|
|
assert.equal(withoutCategory.recommendedCrossSectionMm2, 1.5);
|
|
assert.equal(withoutCategory.practicalMinimumApplied, false);
|
|
assert.equal(withCategory.recommendedCrossSectionMm2, 2.5);
|
|
assert.equal(withCategory.practicalMinimumApplied, true);
|
|
});
|
|
|
|
it("never lowers a recommendation that already needs more than the practical minimum", () => {
|
|
const result = calculateCableSizing({ ...BASE_INPUT, circuitCategory: "single_phase" });
|
|
assert.equal(result.recommendedCrossSectionMm2, 4);
|
|
assert.equal(result.practicalMinimumApplied, false);
|
|
});
|
|
|
|
it("does not apply to lighting or three_phase categories", () => {
|
|
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
|
|
assert.equal(
|
|
calculateCableSizing({ ...smallLoad, circuitCategory: "lighting" }).recommendedCrossSectionMm2,
|
|
1.5
|
|
);
|
|
assert.equal(
|
|
calculateCableSizing({ ...smallLoad, circuitCategory: "three_phase" }).recommendedCrossSectionMm2,
|
|
1.5
|
|
);
|
|
});
|
|
});
|