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.
This commit is contained in:
Grovy311 2026-08-07 18:02:28 +02:00
parent a35d04965e
commit 8562d6eb83
5 changed files with 145 additions and 29 deletions

View file

@ -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 {

View file

@ -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;

View file

@ -31,7 +31,11 @@ interface CableSizingModalProps {
isSaving: boolean;
projectId: string;
onClose: () => void;
onApply: (patch: { cableCrossSection: string; cableLength?: number }) => Promise<void>;
onApply: (patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) => Promise<void>;
}
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<string | null>(null);
const [result, setResult] = useState<CableSizingResult | null>(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<HTMLFormElement>) {
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 (
<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."
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={!result?.dataVerified || !result.recommendedCrossSectionMm2}
submitLabel="Empfehlung übernehmen"
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
>
<div className="row g-3">
@ -183,7 +200,6 @@ export function CableSizingModal({
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)
@ -355,6 +371,14 @@ export function CableSizingModal({
<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>
@ -394,14 +418,48 @@ export function CableSizingModal({
{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>
)}
{calculationId ? null : (
<p className="text-muted small mt-2">
Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch
geschrieben.
</p>
)}
<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>
</div>
</FormModal>
);
}

View file

@ -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;

View file

@ -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"));
});
});