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.
523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { type FormEvent, useMemo, useState } from "react";
|
|
import {
|
|
CONDUCTOR_MATERIALS,
|
|
CROSS_SECTIONS_MM2,
|
|
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,
|
|
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,
|
|
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;
|
|
circuitCategory?: CircuitCategory;
|
|
isSaving: boolean;
|
|
projectId: string;
|
|
onClose: () => void;
|
|
onApply: (patch: {
|
|
cableCrossSection: string;
|
|
cableType?: 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);
|
|
}
|
|
|
|
// 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,
|
|
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);
|
|
|
|
// 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<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,
|
|
circuitCategory,
|
|
}),
|
|
[
|
|
phase,
|
|
circuit.circuitTotalPower,
|
|
cosPhi,
|
|
voltage,
|
|
lengthM,
|
|
layingMethod,
|
|
conductorMaterial,
|
|
insulation,
|
|
ambientTemperatureC,
|
|
groupingCircuits,
|
|
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);
|
|
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);
|
|
// 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 {
|
|
setCalculating(false);
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!manualCrossSection.trim()) return;
|
|
await onApply({
|
|
cableCrossSection: manualCrossSection.trim(),
|
|
cableType: manualCableType.trim() || undefined,
|
|
cableLength: lengthM,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<FormModal
|
|
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4, oder Kabeltyp/Querschnitt direkt manuell eintragen. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis."
|
|
isSaving={isSaving}
|
|
onClose={onClose}
|
|
onSubmit={handleSubmit}
|
|
submitDisabled={!manualCrossSection.trim()}
|
|
submitLabel="Übernehmen"
|
|
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
|
|
circuit.displayName ? ` (${circuit.displayName})` : ""
|
|
}`}
|
|
>
|
|
<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">
|
|
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
|
|
</div>
|
|
<div>
|
|
<strong>{result.designCurrentA.toFixed(1)} A</strong>
|
|
</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 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>
|
|
{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>
|
|
))}
|
|
{circuit.protectionDevice && (
|
|
<p className="text-muted small mt-2 mb-0">
|
|
* 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.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<hr className="my-3" />
|
|
<p className="text-muted small mb-2">
|
|
Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in
|
|
dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.
|
|
</p>
|
|
<div className="row g-3">
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-manual-type">
|
|
Kabeltyp
|
|
</label>
|
|
<input
|
|
className="form-control"
|
|
id="cable-sizing-manual-type"
|
|
onChange={(event) => setManualCableType(event.target.value)}
|
|
placeholder="z.B. NYM-J 3x1.5"
|
|
value={manualCableType}
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
|
|
Querschnitt
|
|
</label>
|
|
<input
|
|
className="form-control"
|
|
id="cable-sizing-manual-cross-section"
|
|
onChange={(event) => setManualCrossSection(event.target.value)}
|
|
placeholder="z.B. 4 mm²"
|
|
value={manualCrossSection}
|
|
/>
|
|
</div>
|
|
{manualCrossSectionWarning && (
|
|
<div className="col-12">
|
|
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</FormModal>
|
|
);
|
|
}
|