From 8562d6eb837004ffddea3362c5535646849d2481 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Fri, 7 Aug 2026 18:02:28 +0200 Subject: [PATCH] cable-sizing: size for the protective device, not just the load; manual entry; icon position - calculateCableSizing now selects cross-section against designCurrentA = max(operatingCurrentA, existingProtectionRatedCurrentA) instead of the load current alone. A breaker only trips at its own rated current (In), so a cable sized for the actual load could overheat under sustained current below that threshold - the standard In <= Iz rule. Voltage-drop calculation still uses the real operating current, only the capacity check changed. Alerts and the ok-summary now name the breaker as the limiting factor when it is one. - Added a manual cable type/cross-section entry in the modal itself: running a calculation pre-fills these fields as a suggestion, but they are the single field that actually gets applied, and stay editable - this restores the direct manual-entry capability the modal replaced when it took over the cableSummary/cableCrossSection cell click. - Fixed calculator icon position from a trailing ::after (inconsistent position depending on cell text length, easy to miss on empty cells) to a fixed-position ::before, so it is always in the same place regardless of whether cable data is already filled in. - No new display for the existing protection device - it already has its own Schutz column in this app. --- src/app/globals.css | 13 ++- .../domain/cable-sizing-calculation.ts | 44 ++++++++-- .../components/cable-sizing-modal.tsx | 84 ++++++++++++++++--- .../components/circuit-tree-editor.tsx | 1 + tests/cable-sizing-calculation.test.ts | 32 ++++++- 5 files changed, 145 insertions(+), 29 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index bb8df89..2b137a0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1227,10 +1227,17 @@ a.kpi:hover { box-shadow: inset 0 0 0 1px var(--color-primary); } -.tree-grid .cell-cable-sizing-trigger::after { - content: " 🧮"; +.tree-grid .cell-cable-sizing-trigger { + position: relative; + padding-left: 1.4em; +} + +.tree-grid .cell-cable-sizing-trigger::before { + content: "🧮"; + position: absolute; + left: 0.2em; font-size: 0.8em; - opacity: 0.7; + opacity: 0.8; } .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 6dc681a..b15385d 100644 --- a/src/cable-sizing/domain/cable-sizing-calculation.ts +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -259,6 +259,13 @@ export interface CableSizingResult { * combination - every field below is null/empty, never a guess. */ dataVerified: boolean; operatingCurrentA: number; + /** max(operatingCurrentA, existingProtectionRatedCurrentA). The cable + * capacity (Iz) must cover the protective device's rated current (In), + * not just the actual load current - a breaker only trips at In, so a + * cable sized for Ib alone could overheat under sustained load below the + * trip threshold. This is the value cross-section selection actually + * uses; operatingCurrentA is kept for display/voltage-drop only. */ + designCurrentA: number; crossSectionByCapacityMm2: number | null; crossSectionByVoltageDropMm2: number | null; recommendedCrossSectionMm2: number | null; @@ -282,10 +289,16 @@ function operatingCurrentA(input: CableSizingInput): number { export function calculateCableSizing(input: CableSizingInput): CableSizingResult { const ib = operatingCurrentA(input); + const designCurrentA = + input.existingProtectionRatedCurrentA != null + ? Math.max(ib, input.existingProtectionRatedCurrentA) + : ib; + if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) { return { dataVerified: false, operatingCurrentA: ib, + designCurrentA, crossSectionByCapacityMm2: null, crossSectionByVoltageDropMm2: null, recommendedCrossSectionMm2: null, @@ -328,7 +341,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult ); const indexByCapacity = correctedCurrents.findIndex( - (value) => value != null && value >= ib + (value) => value != null && value >= designCurrentA ); const indexByVoltageDrop = voltageDrops.findIndex( (value) => value <= input.maxVoltageDropPercent @@ -341,7 +354,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => { const rated = ratedCurrents[i]; const corrected = correctedCurrents[i]; - const currentSufficient = corrected == null ? null : corrected >= ib; + const currentSufficient = corrected == null ? null : corrected >= designCurrentA; const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent; return { crossSectionMm2: crossSection, @@ -368,6 +381,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult return { dataVerified: true, operatingCurrentA: ib, + designCurrentA, crossSectionByCapacityMm2: indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null, crossSectionByVoltageDropMm2: @@ -407,9 +421,14 @@ export function buildCableSizingAlerts( const recommended = result.recommendedCrossSectionMm2; if (byCapacity == null) { + const dueToBreaker = + input.existingProtectionRatedCurrentA != null && + input.existingProtectionRatedCurrentA > result.operatingCurrentA; alerts.push({ kind: "critical", - text: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`, + text: dueToBreaker + ? `Vorhandene Sicherung (${input.existingProtectionRatedCurrentA} A) übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod} - nicht der Betriebsstrom (${result.operatingCurrentA.toFixed(1)} A). Größere Verlegeart oder kleinere Sicherung prüfen.` + : `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`, }); } else if (byDrop != null && byDrop > byCapacity) { alerts.push({ @@ -435,16 +454,23 @@ export function buildCableSizingAlerts( text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`, }); } - if (result.protectionCoordination && !result.protectionCoordination.coordinated) { - alerts.push({ - kind: "warn", - text: `Vorhandener Schutz (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen (vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`, - }); + if (result.protectionCoordination) { + alerts.push( + result.protectionCoordination.coordinated + ? { + kind: "info", + text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) ist bei der Dimensionierung berücksichtigt (In ≤ Iz, vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`, + } + : { + kind: "warn", + text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen.`, + } + ); } if (alerts.length === 0 && recommended != null) { alerts.push({ kind: "ok", - text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.operatingCurrentA.toFixed(1)} A) 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.`, }); } return alerts; diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx index 57b9a3a..00b7905 100644 --- a/src/frontend/components/cable-sizing-modal.tsx +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -31,7 +31,11 @@ interface CableSizingModalProps { isSaving: boolean; projectId: string; onClose: () => void; - onApply: (patch: { cableCrossSection: string; cableLength?: number }) => Promise; + onApply: (patch: { + cableCrossSection: string; + cableType?: string; + cableLength?: number; + }) => Promise; } function temperatureOptions(method: LayingMethod) { @@ -58,6 +62,12 @@ export function CableSizingModal({ const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0); const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1); + // Manual override: applying this never requires a calculation. Prefilled + // from the circuit's current values so opening the modal on an already + // specified cable doesn't lose that data. + const [manualCableType, setManualCableType] = useState(circuit.cableType ?? ""); + const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? ""); + const [calculating, setCalculating] = useState(false); const [calculationId, setCalculationId] = useState(null); const [result, setResult] = useState(null); @@ -121,6 +131,12 @@ export function CableSizingModal({ setResult(response.result); setAlerts(response.alerts); setCalculationId(response.calculationId); + // Calculating fills the manual field as a suggestion, but that field + // stays the single source of truth for what gets applied - the user + // can still edit it by hand before submitting. + if (response.result.dataVerified && response.result.recommendedCrossSectionMm2) { + setManualCrossSection(`${response.result.recommendedCrossSectionMm2} mm²`); + } } catch (err) { setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen"); } finally { @@ -130,21 +146,22 @@ export function CableSizingModal({ async function handleSubmit(event: FormEvent) { event.preventDefault(); - if (!result?.recommendedCrossSectionMm2) return; + if (!manualCrossSection.trim()) return; await onApply({ - cableCrossSection: `${result.recommendedCrossSectionMm2} mm²`, + cableCrossSection: manualCrossSection.trim(), + cableType: manualCableType.trim() || undefined, cableLength: lengthM, }); } return (
@@ -183,7 +200,6 @@ export function CableSizingModal({ value={`${circuit.circuitTotalPower.toFixed(2)} kW`} />
-
+
+
+ Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""} +
+
+ {result.designCurrentA.toFixed(1)} A +
+
Empfohlener Querschnitt
@@ -394,14 +418,48 @@ export function CableSizingModal({ {alert.text}
))} + {circuit.protectionDevice && ( +

+ * Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung + ({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem + eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur + für die tatsächliche Last. +

+ )}
)} - {calculationId ? null : ( -

- Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch - geschrieben. -

- )} + +
+

+ Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in + dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen. +

+
+
+ + setManualCableType(event.target.value)} + placeholder="z.B. NYM-J 3x1.5" + value={manualCableType} + /> +
+
+ + setManualCrossSection(event.target.value)} + placeholder="z.B. 4 mm²" + value={manualCrossSection} + /> +
+
); } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index e16a4bb..7f86029 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1403,6 +1403,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str async function handleApplyCableSizing(patch: { cableCrossSection: string; + cableType?: string; cableLength?: number; }) { const circuit = cableSizingEditorCircuit; diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts index 72ffae4..eaad683 100644 --- a/tests/cable-sizing-calculation.test.ts +++ b/tests/cable-sizing-calculation.test.ts @@ -84,19 +84,43 @@ describe("calculateCableSizing", () => { assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false); }); - it("flags a simplified protection coordination mismatch", () => { + 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.ok(result.protectionCoordination); - assert.equal(result.protectionCoordination?.coordinated, false); + 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.ok(alerts.some((alert) => alert.text.includes("Koordination prüfen"))); + assert.equal(alerts.length, 1); + assert.equal(alerts[0].kind, "critical"); + assert.ok(alerts[0].text.includes("Vorhandene Sicherung")); }); });