Proposal: cable-sizing module (on-demand DIN VDE 0298-4 cross-section calculator) #1

Open
Grovy311 wants to merge 10 commits from Grovy311/leistungsbilanz-ts:feature/cable-sizing-module into main
6 changed files with 210 additions and 6 deletions
Showing only changes of commit cfb76a3754 - Show all commits

View file

@ -1233,11 +1233,22 @@ a.kpi:hover {
} }
.tree-grid .cell-cable-sizing-trigger::before { .tree-grid .cell-cable-sizing-trigger::before {
content: "🧮"; content: "";
position: absolute; position: absolute;
left: 0.2em; left: 0.2em;
font-size: 0.8em; top: 50%;
opacity: 0.8; transform: translateY(-50%);
width: 0.9em;
height: 0.9em;
opacity: 0.85;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233f82a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E");
}
:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger::before {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a9d2e6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E");
} }
.tree-grid .device-drag-handle { .tree-grid .device-drag-handle {

View file

@ -209,6 +209,21 @@ export const GROUPING_LABELS: Record<number, string> = {
10: "≥10", 10: "≥10",
}; };
// -- Practical minimum cross-sections by circuit category ------------------
// Not a thermal/voltage-drop calculation result - a widely used planning
// convention for margin against future load growth, mechanical robustness
// and fault-current withstand, beyond what a pure Ib-based calculation
// would give. Matches this project's own docs/spec/06-future-sizing-and-
// calculations.md ("Standard Single-Phase Circuits ... usually use ...
// cable cross-section: 2.5 mm²"). Only applied for the "single_phase"
// circuit category (general sockets and similar loads); lighting and
// three-phase circuits are not covered by this specific convention.
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
single_phase: 2.5,
};
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
// -- Input / result ----------------------------------------------------- // -- Input / result -----------------------------------------------------
export interface CableSizingInput { export interface CableSizingInput {
@ -235,6 +250,9 @@ export interface CableSizingInput {
/** Optional: existing protection device rated current (LS/circuit /** Optional: existing protection device rated current (LS/circuit
* breaker), for the simplified coordination check below. */ * breaker), for the simplified coordination check below. */
existingProtectionRatedCurrentA?: number; existingProtectionRatedCurrentA?: number;
/** Optional: enables the practical-minimum-cross-section convention for
* "single_phase" circuits, see PRACTICAL_MINIMUM_CROSS_SECTION_MM2. */
circuitCategory?: CircuitCategory;
} }
export interface CrossSectionRow { export interface CrossSectionRow {
@ -244,6 +262,11 @@ export interface CrossSectionRow {
currentSufficient: boolean | null; currentSufficient: boolean | null;
voltageDropPercent: number | null; voltageDropPercent: number | null;
voltageDropSufficient: boolean | null; voltageDropSufficient: boolean | null;
/** Longest single-run length (m) at which this cross-section still meets
* the requested max voltage drop, at the given load/cosPhi/phase - the
* inverse of the voltage-drop formula used for voltageDropPercent above.
* null when the operating current is zero (division by zero). */
maxLengthForVoltageDropM: number | null;
recommended: boolean; recommended: boolean;
} }
@ -272,6 +295,10 @@ export interface CableSizingResult {
voltageDropAtRecommendedPercent: number | null; voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number; combinedDerationFactor: number;
harmonicReductionApplied: boolean; harmonicReductionApplied: boolean;
/** true if the recommendation was raised to satisfy
* PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (a convention, not a thermal/
* voltage-drop requirement of this specific circuit). */
practicalMinimumApplied: boolean;
rows: CrossSectionRow[]; rows: CrossSectionRow[];
protectionCoordination: ProtectionCoordinationResult | null; protectionCoordination: ProtectionCoordinationResult | null;
} }
@ -305,6 +332,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
voltageDropAtRecommendedPercent: null, voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1, combinedDerationFactor: 1,
harmonicReductionApplied: false, harmonicReductionApplied: false,
practicalMinimumApplied: false,
rows: [], rows: [],
protectionCoordination: null, protectionCoordination: null,
}; };
@ -351,11 +379,38 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop); indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop);
} }
// Practical-minimum convention (see PRACTICAL_MINIMUM_CROSS_SECTION_MM2):
// only ever raises the recommendation, never lowers what the thermal/
// voltage-drop calculation already required.
const practicalMinimumMm2 = input.circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory]
: undefined;
let practicalMinimumApplied = false;
if (
indexRecommended >= 0 &&
practicalMinimumMm2 != null &&
CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2
) {
const minimumIndex = CROSS_SECTIONS_MM2.indexOf(
practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number]
);
if (minimumIndex >= 0) {
indexRecommended = minimumIndex;
practicalMinimumApplied = true;
}
}
const voltageDropCurrentBasis = ib * input.cosPhi;
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => { const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
const rated = ratedCurrents[i]; const rated = ratedCurrents[i];
const corrected = correctedCurrents[i]; const corrected = correctedCurrents[i];
const currentSufficient = corrected == null ? null : corrected >= designCurrentA; const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent; const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
const maxLengthForVoltageDropM =
rated == null || voltageDropCurrentBasis <= 0
? null
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
(100 * voltageDropCoefficient * voltageDropCurrentBasis);
return { return {
crossSectionMm2: crossSection, crossSectionMm2: crossSection,
ratedCurrentA: rated, ratedCurrentA: rated,
@ -363,6 +418,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
currentSufficient, currentSufficient,
voltageDropPercent: rated == null ? null : voltageDrops[i], voltageDropPercent: rated == null ? null : voltageDrops[i],
voltageDropSufficient: rated == null ? null : voltageDropSufficient, voltageDropSufficient: rated == null ? null : voltageDropSufficient,
maxLengthForVoltageDropM,
recommended: i === indexRecommended, recommended: i === indexRecommended,
}; };
}); });
@ -392,6 +448,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
indexRecommended >= 0 ? voltageDrops[indexRecommended] : null, indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
combinedDerationFactor, combinedDerationFactor,
harmonicReductionApplied, harmonicReductionApplied,
practicalMinimumApplied,
rows, rows,
protectionCoordination, protectionCoordination,
}; };
@ -448,6 +505,12 @@ export function buildCableSizingAlerts(
text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.", text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.",
}); });
} }
if (result.practicalMinimumApplied) {
alerts.push({
kind: "info",
text: `Auf ${result.recommendedCrossSectionMm2} mm² angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine reine Berechnungsanforderung).`,
});
}
if (result.combinedDerationFactor < 1) { if (result.combinedDerationFactor < 1) {
alerts.push({ alerts.push({
kind: "info", kind: "info",
@ -473,5 +536,12 @@ export function buildCableSizingAlerts(
text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`, text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`,
}); });
} }
const recommendedRow = result.rows.find((row) => row.recommended);
if (recommendedRow?.maxLengthForVoltageDropM != null) {
alerts.push({
kind: "info",
text: `Maximale Länge bei ${recommendedRow.crossSectionMm2} mm² und ΔU ≤ ${input.maxVoltageDropPercent}%: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
});
}
return alerts; return alerts;
} }

View file

@ -26,6 +26,7 @@ export const cableSizingRequestSchema = z
maxVoltageDropPercent: z.number().min(1).max(5), maxVoltageDropPercent: z.number().min(1).max(5),
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS), harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
existingProtectionRatedCurrentA: z.number().positive().optional(), existingProtectionRatedCurrentA: z.number().positive().optional(),
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
// Optional context, persisted with the audit-log entry only - never // Optional context, persisted with the audit-log entry only - never
// used for the calculation itself. // used for the calculation itself.
context: z context: z

View file

@ -3,6 +3,7 @@
import { type FormEvent, useMemo, useState } from "react"; import { type FormEvent, useMemo, useState } from "react";
import { import {
CONDUCTOR_MATERIALS, CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
GROUPING_LABELS, GROUPING_LABELS,
GROUPING_OPTIONS, GROUPING_OPTIONS,
HARMONIC_NEUTRAL_LOAD_LABELS, HARMONIC_NEUTRAL_LOAD_LABELS,
@ -12,11 +13,13 @@ import {
LAYING_METHODS, LAYING_METHODS,
LAYING_METHOD_GROUP, LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS, LAYING_METHOD_LABELS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
TEMPERATURE_FACTOR_AIR, TEMPERATURE_FACTOR_AIR,
TEMPERATURE_FACTOR_GROUND, TEMPERATURE_FACTOR_GROUND,
type CableSizingAlert, type CableSizingAlert,
type CableSizingInput, type CableSizingInput,
type CableSizingResult, type CableSizingResult,
type CircuitCategory,
type ConductorMaterial, type ConductorMaterial,
type HarmonicNeutralLoad, type HarmonicNeutralLoad,
type InsulationMaterial, type InsulationMaterial,
@ -28,6 +31,7 @@ import { FormModal } from "./form-modal";
interface CableSizingModalProps { interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto; circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
isSaving: boolean; isSaving: boolean;
projectId: string; projectId: string;
onClose: () => void; onClose: () => void;
@ -44,8 +48,19 @@ function temperatureOptions(method: LayingMethod) {
return Object.keys(table).map(Number).sort((a, b) => a - b); return Object.keys(table).map(Number).sort((a, b) => a - b);
} }
// Extracts the first decimal number from a free-text cross-section entry
// like "2.5 mm²" or "2,5". Returns null if nothing parseable is found -
// used only for the manual-entry sanity check below, never persisted.
function parseCrossSectionMm2(text: string): number | null {
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return null;
const value = Number(match[1].replace(",", "."));
return Number.isFinite(value) ? value : null;
}
export function CableSizingModal({ export function CableSizingModal({
circuit, circuit,
circuitCategory,
isSaving, isSaving,
projectId, projectId,
onClose, onClose,
@ -92,6 +107,7 @@ export function CableSizingModal({
maxVoltageDropPercent, maxVoltageDropPercent,
harmonicNeutralLoad, harmonicNeutralLoad,
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA, existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
circuitCategory,
}), }),
[ [
phase, phase,
@ -107,9 +123,34 @@ export function CableSizingModal({
maxVoltageDropPercent, maxVoltageDropPercent,
harmonicNeutralLoad, harmonicNeutralLoad,
circuit.protectionDevice?.ratedCurrentA, circuit.protectionDevice?.ratedCurrentA,
circuitCategory,
] ]
); );
const manualCrossSectionWarning = useMemo(() => {
const trimmed = manualCrossSection.trim();
if (!trimmed) return null;
const parsed = parseCrossSectionMm2(trimmed);
if (parsed == null) return null;
if (!(CROSS_SECTIONS_MM2 as readonly number[]).includes(parsed)) {
return `${parsed} mm² ist kein Standard-Querschnitt (${CROSS_SECTIONS_MM2.join(", ")}).`;
}
if (
result?.dataVerified &&
result.recommendedCrossSectionMm2 != null &&
parsed < result.recommendedCrossSectionMm2
) {
return `${parsed} mm² ist kleiner als die zuletzt berechnete Empfehlung (${result.recommendedCrossSectionMm2} mm²) - passt nicht zu Last${circuit.protectionDevice ? "/Sicherung" : ""} und Verlegeart.`;
}
const practicalMinimum = circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory]
: undefined;
if (practicalMinimum != null && parsed < practicalMinimum) {
return `${parsed} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${practicalMinimum} mm²).`;
}
return null;
}, [manualCrossSection, result, circuit.protectionDevice, circuitCategory]);
function handleLayingMethodChange(method: LayingMethod) { function handleLayingMethodChange(method: LayingMethod) {
setLayingMethod(method); setLayingMethod(method);
const options = temperatureOptions(method); const options = temperatureOptions(method);
@ -162,7 +203,9 @@ export function CableSizingModal({
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()} submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen" submitLabel="Übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`} title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
> >
<div className="row g-3"> <div className="row g-3">
<div className="col-12 col-md-4"> <div className="col-12 col-md-4">
@ -401,6 +444,16 @@ export function CableSizingModal({
<div className="text-muted small">Korrekturfaktor</div> <div className="text-muted small">Korrekturfaktor</div>
<div>{result.combinedDerationFactor.toFixed(2)}</div> <div>{result.combinedDerationFactor.toFixed(2)}</div>
</div> </div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge (ΔU-Grenze)</div>
<div>
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null
? `${result.rows
.find((row) => row.recommended)!
.maxLengthForVoltageDropM!.toFixed(0)} m`
: "-"}
</div>
</div>
</div> </div>
{alerts.map((alert, index) => ( {alerts.map((alert, index) => (
<div <div
@ -459,6 +512,11 @@ export function CableSizingModal({
value={manualCrossSection} value={manualCrossSection}
/> />
</div> </div>
{manualCrossSectionWarning && (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
)}
</div> </div>
</FormModal> </FormModal>
); );

View file

@ -3170,6 +3170,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{cableSizingEditorCircuit ? ( {cableSizingEditorCircuit ? (
<CableSizingModal <CableSizingModal
circuit={cableSizingEditorCircuit} circuit={cableSizingEditorCircuit}
circuitCategory={
data.sections.find(
(section) => section.id === cableSizingEditorCircuit.sectionId
)?.category
}
isSaving={isSaving} isSaving={isSaving}
projectId={projectId} projectId={projectId}
onClose={() => setCableSizingEditorCircuit(null)} onClose={() => setCableSizingEditorCircuit(null)}

View file

@ -134,7 +134,7 @@ describe("buildCableSizingAlerts", () => {
assert.ok(alerts[0].text.includes("keine geprüften")); assert.ok(alerts[0].text.includes("keine geprüften"));
}); });
it("returns a single ok alert for a clean, unremarkable case", () => { it("returns an ok alert plus a max-length info alert for a clean, unremarkable case", () => {
const input: CableSizingInput = { const input: CableSizingInput = {
...BASE_INPUT, ...BASE_INPUT,
mode: "current", mode: "current",
@ -144,7 +144,66 @@ describe("buildCableSizingAlerts", () => {
}; };
const result = calculateCableSizing(input); const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result); const alerts = buildCableSizingAlerts(input, result);
assert.equal(alerts.length, 1); assert.equal(alerts.length, 2);
assert.equal(alerts[0].kind, "ok"); 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
);
}); });
}); });