From cfb76a37549da8a530a10827fe6875ad69100fb7 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Fri, 7 Aug 2026 18:13:27 +0200 Subject: [PATCH] cable-sizing: max length, socket minimum, manual-entry warning, SVG icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/app/globals.css | 17 ++++- .../domain/cable-sizing-calculation.ts | 70 +++++++++++++++++++ .../domain/cable-sizing-contracts.ts | 1 + .../components/cable-sizing-modal.tsx | 60 +++++++++++++++- .../components/circuit-tree-editor.tsx | 5 ++ tests/cable-sizing-calculation.test.ts | 63 ++++++++++++++++- 6 files changed, 210 insertions(+), 6 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 2b137a0..88e1c35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1233,11 +1233,22 @@ a.kpi:hover { } .tree-grid .cell-cable-sizing-trigger::before { - content: "🧮"; + content: ""; position: absolute; left: 0.2em; - font-size: 0.8em; - opacity: 0.8; + top: 50%; + 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 { diff --git a/src/cable-sizing/domain/cable-sizing-calculation.ts b/src/cable-sizing/domain/cable-sizing-calculation.ts index b15385d..3f228f4 100644 --- a/src/cable-sizing/domain/cable-sizing-calculation.ts +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -209,6 +209,21 @@ export const GROUPING_LABELS: Record = { 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> = { + single_phase: 2.5, +}; + +export type CircuitCategory = "lighting" | "single_phase" | "three_phase"; + // -- Input / result ----------------------------------------------------- export interface CableSizingInput { @@ -235,6 +250,9 @@ export interface CableSizingInput { /** Optional: existing protection device rated current (LS/circuit * breaker), for the simplified coordination check below. */ 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 { @@ -244,6 +262,11 @@ export interface CrossSectionRow { currentSufficient: boolean | null; voltageDropPercent: number | 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; } @@ -272,6 +295,10 @@ export interface CableSizingResult { voltageDropAtRecommendedPercent: number | null; combinedDerationFactor: number; 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[]; protectionCoordination: ProtectionCoordinationResult | null; } @@ -305,6 +332,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult voltageDropAtRecommendedPercent: null, combinedDerationFactor: 1, harmonicReductionApplied: false, + practicalMinimumApplied: false, rows: [], protectionCoordination: null, }; @@ -351,11 +379,38 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult 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 rated = ratedCurrents[i]; const corrected = correctedCurrents[i]; const currentSufficient = corrected == null ? null : corrected >= designCurrentA; const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent; + const maxLengthForVoltageDropM = + rated == null || voltageDropCurrentBasis <= 0 + ? null + : (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) / + (100 * voltageDropCoefficient * voltageDropCurrentBasis); return { crossSectionMm2: crossSection, ratedCurrentA: rated, @@ -363,6 +418,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult currentSufficient, voltageDropPercent: rated == null ? null : voltageDrops[i], voltageDropSufficient: rated == null ? null : voltageDropSufficient, + maxLengthForVoltageDropM, recommended: i === indexRecommended, }; }); @@ -392,6 +448,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult indexRecommended >= 0 ? voltageDrops[indexRecommended] : null, combinedDerationFactor, harmonicReductionApplied, + practicalMinimumApplied, rows, 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.", }); } + 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) { alerts.push({ 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.`, }); } + 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; } diff --git a/src/cable-sizing/domain/cable-sizing-contracts.ts b/src/cable-sizing/domain/cable-sizing-contracts.ts index 6cafe24..5648d8d 100644 --- a/src/cable-sizing/domain/cable-sizing-contracts.ts +++ b/src/cable-sizing/domain/cable-sizing-contracts.ts @@ -26,6 +26,7 @@ export const cableSizingRequestSchema = z maxVoltageDropPercent: z.number().min(1).max(5), harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS), existingProtectionRatedCurrentA: z.number().positive().optional(), + circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(), // Optional context, persisted with the audit-log entry only - never // used for the calculation itself. context: z diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx index 00b7905..36b204c 100644 --- a/src/frontend/components/cable-sizing-modal.tsx +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -3,6 +3,7 @@ import { type FormEvent, useMemo, useState } from "react"; import { CONDUCTOR_MATERIALS, + CROSS_SECTIONS_MM2, GROUPING_LABELS, GROUPING_OPTIONS, HARMONIC_NEUTRAL_LOAD_LABELS, @@ -12,11 +13,13 @@ import { LAYING_METHODS, LAYING_METHOD_GROUP, LAYING_METHOD_LABELS, + PRACTICAL_MINIMUM_CROSS_SECTION_MM2, TEMPERATURE_FACTOR_AIR, TEMPERATURE_FACTOR_GROUND, type CableSizingAlert, type CableSizingInput, type CableSizingResult, + type CircuitCategory, type ConductorMaterial, type HarmonicNeutralLoad, type InsulationMaterial, @@ -28,6 +31,7 @@ import { FormModal } from "./form-modal"; interface CableSizingModalProps { circuit: CircuitTreeCircuitDto; + circuitCategory?: CircuitCategory; isSaving: boolean; projectId: string; onClose: () => void; @@ -44,8 +48,19 @@ function temperatureOptions(method: LayingMethod) { 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({ circuit, + circuitCategory, isSaving, projectId, onClose, @@ -92,6 +107,7 @@ export function CableSizingModal({ maxVoltageDropPercent, harmonicNeutralLoad, existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA, + circuitCategory, }), [ phase, @@ -107,9 +123,34 @@ export function CableSizingModal({ maxVoltageDropPercent, harmonicNeutralLoad, 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) { setLayingMethod(method); const options = temperatureOptions(method); @@ -162,7 +203,9 @@ export function CableSizingModal({ onSubmit={handleSubmit} submitDisabled={!manualCrossSection.trim()} submitLabel="Übernehmen" - title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`} + title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${ + circuit.displayName ? ` (${circuit.displayName})` : "" + }`} >
@@ -401,6 +444,16 @@ export function CableSizingModal({
Korrekturfaktor
{result.combinedDerationFactor.toFixed(2)}
+
+
Max. Länge (ΔU-Grenze)
+
+ {result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null + ? `${result.rows + .find((row) => row.recommended)! + .maxLengthForVoltageDropM!.toFixed(0)} m` + : "-"} +
+
{alerts.map((alert, index) => (
+ {manualCrossSectionWarning && ( +
+
{manualCrossSectionWarning}
+
+ )} ); diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 7f86029..d113b3b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -3170,6 +3170,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {cableSizingEditorCircuit ? ( section.id === cableSizingEditorCircuit.sectionId + )?.category + } isSaving={isSaving} projectId={projectId} onClose={() => setCableSizingEditorCircuit(null)} diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts index eaad683..89a935d 100644 --- a/tests/cable-sizing-calculation.test.ts +++ b/tests/cable-sizing-calculation.test.ts @@ -134,7 +134,7 @@ describe("buildCableSizingAlerts", () => { 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 = { ...BASE_INPUT, mode: "current", @@ -144,7 +144,66 @@ describe("buildCableSizingAlerts", () => { }; const result = calculateCableSizing(input); const alerts = buildCableSizingAlerts(input, result); - assert.equal(alerts.length, 1); + 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 + ); }); });