Add proposed cable-sizing module

Isolated module (mirrors src/external-model/ dependency direction) that
adds an on-demand, DIN VDE 0298-4 referenced cable cross-section
calculator: a click-on-a-circuit input mask for laying method,
insulation, ambient temperature, grouping and voltage-drop limit that
suggests a cross-section for the circuit cableCrossSection/cableLength
fields. Applying a suggestion goes through the existing circuit.update
command (expectedRevision, undo/redo) unchanged - nothing here writes
to circuits directly or touches the revision/command system. See
docs/cable-sizing-module.md for the full rationale, API contract,
verification status and maintenance plan. Proposal, not yet reviewed
by the project owner - see the docs file.

423 existing + 11 new tests pass, build:api/build:web/typecheck:scripts
clean.
This commit is contained in:
Grovy311 2026-08-07 17:13:59 +02:00
parent 2e3e85a8e5
commit 8b8526b8df
17 changed files with 4179 additions and 2 deletions

View file

@ -0,0 +1,407 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
GROUPING_LABELS,
GROUPING_OPTIONS,
HARMONIC_NEUTRAL_LOAD_LABELS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
TEMPERATURE_FACTOR_AIR,
TEMPERATURE_FACTOR_GROUND,
type CableSizingAlert,
type CableSizingInput,
type CableSizingResult,
type ConductorMaterial,
type HarmonicNeutralLoad,
type InsulationMaterial,
type LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import { calculateCableSizing } from "./cable-sizing-api";
import type { CircuitTreeCircuitDto } from "../types";
import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
isSaving: boolean;
projectId: string;
onClose: () => void;
onApply: (patch: { cableCrossSection: string; cableLength?: number }) => Promise<void>;
}
function temperatureOptions(method: LayingMethod) {
const table =
LAYING_METHOD_GROUP[method] === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
return Object.keys(table).map(Number).sort((a, b) => a - b);
}
export function CableSizingModal({
circuit,
isSaving,
projectId,
onClose,
onApply,
}: CableSizingModalProps) {
const [layingMethod, setLayingMethod] = useState<LayingMethod>("C");
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("copper");
const [ambientTemperatureC, setAmbientTemperatureC] = useState(30);
const [groupingCircuits, setGroupingCircuits] = useState(1);
const [cosPhi, setCosPhi] = useState(1);
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1);
const [calculating, setCalculating] = useState(false);
const [calculationId, setCalculationId] = useState<string | null>(null);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(null);
const voltage = circuit.voltage ?? (phase === 1 ? 230 : 400);
const input: CableSizingInput = useMemo(
() => ({
phase,
mode: "power",
powerKw: circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
}),
[
phase,
circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
circuit.protectionDevice?.ratedCurrentA,
]
);
function handleLayingMethodChange(method: LayingMethod) {
setLayingMethod(method);
const options = temperatureOptions(method);
const reference = LAYING_METHOD_GROUP[method] === "air" ? 30 : 20;
setAmbientTemperatureC(options.includes(reference) ? reference : options[0]);
setResult(null);
setCalculationId(null);
}
async function handleCalculate() {
setCalculating(true);
setError(null);
try {
const response = await calculateCableSizing(input, {
projectId,
circuitId: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
});
setResult(response.result);
setAlerts(response.alerts);
setCalculationId(response.calculationId);
} catch (err) {
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
} finally {
setCalculating(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!result?.recommendedCrossSectionMm2) return;
await onApply({
cableCrossSection: `${result.recommendedCrossSectionMm2} mm²`,
cableLength: lengthM,
});
}
return (
<FormModal
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis. Du kannst das Ergebnis vor der Übernahme frei prüfen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!result?.dataVerified || !result.recommendedCrossSectionMm2}
submitLabel="Empfehlung übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-length">
Leitungslänge (m)
</label>
<input
className="form-control"
id="cable-sizing-length"
min={0}
onChange={(event) => setLengthM(Number(event.target.value))}
type="number"
value={lengthM}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
Phasen
</label>
<select
className="form-select"
id="cable-sizing-phase"
onChange={(event) => setPhase(Number(event.target.value) as 1 | 3)}
value={phase}
>
<option value={1}>1~ ({circuit.voltage ?? 230} V)</option>
<option value={3}>3~ ({circuit.voltage ?? 400} V)</option>
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Leistung (aus Stromkreis)</label>
<input
className="form-control"
disabled
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart (DIN VDE 0298-4)
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => handleLayingMethodChange(event.target.value as LayingMethod)}
value={layingMethod}
>
{LAYING_METHODS.map((method) => (
<option key={method} value={method}>
{LAYING_METHOD_LABELS[method]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-insulation">
Isolierstoff
</label>
<select
className="form-select"
id="cable-sizing-insulation"
onChange={(event) => {
setInsulation(event.target.value as InsulationMaterial);
setResult(null);
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-material">
Leitermaterial
</label>
<select
className="form-select"
id="cable-sizing-material"
onChange={(event) => setConductorMaterial(event.target.value as ConductorMaterial)}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-temperature">
{LAYING_METHOD_GROUP[layingMethod] === "air"
? "Umgebungstemperatur"
: "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => setAmbientTemperatureC(Number(event.target.value))}
value={ambientTemperatureC}
>
{temperatureOptions(layingMethod).map((temp) => (
<option key={temp} value={temp}>
{temp}°C
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise nebeneinander)
</label>
<select
className="form-select"
id="cable-sizing-grouping"
onChange={(event) => setGroupingCircuits(Number(event.target.value))}
value={groupingCircuits}
>
{GROUPING_OPTIONS.map((grouping) => (
<option key={grouping} value={grouping}>
{GROUPING_LABELS[grouping]}
</option>
))}
</select>
</div>
{phase === 3 && (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungsanteil Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) =>
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad)
}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
)}
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-range"
id="cable-sizing-cosphi"
max={1}
min={0.6}
onChange={(event) => setCosPhi(Number(event.target.value))}
step={0.05}
type="range"
value={cosPhi}
/>
<span>{cosPhi.toFixed(2)}</span>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. zulässiger Spannungsfall
</label>
<input
className="form-range"
id="cable-sizing-voltage-drop"
max={5}
min={1}
onChange={(event) => setMaxVoltageDropPercent(Number(event.target.value))}
step={0.5}
type="range"
value={maxVoltageDropPercent}
/>
<span>{maxVoltageDropPercent.toFixed(1)} %</span>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet…" : "Berechnen"}
</button>
{error && <p className="text-danger mt-2">{error}</p>}
{result && !result.dataVerified && (
<div className="alert alert-danger mt-3">
Für Verlegeart {layingMethod} mit Isolierstoff {insulation.toUpperCase()} liegen keine
geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich. Bitte
eine andere Verlegeart/Isolierstoff-Kombination wählen.
</div>
)}
{result && result.dataVerified && (
<div className="mt-3">
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom Ib</div>
<div>{result.operatingCurrentA.toFixed(1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2
? `${result.recommendedCrossSectionMm2} mm²`
: "-"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %`
: "-"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor</div>
<div>{result.combinedDerationFactor.toFixed(2)}</div>
</div>
</div>
{alerts.map((alert, index) => (
<div
className={`alert mt-2 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
</div>
)}
{calculationId ? null : (
<p className="text-muted small mt-2">
Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch
geschrieben.
</p>
)}
</FormModal>
);
}