Proposal: cable-sizing module (on-demand DIN VDE 0298-4 cross-section calculator) #1
6 changed files with 210 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,21 @@ export const GROUPING_LABELS: Record<number, string> = {
|
|||
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<Record<CircuitCategory, number>> = {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})` : ""
|
||||
}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-4">
|
||||
|
|
@ -401,6 +444,16 @@ export function CableSizingModal({
|
|||
<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
|
||||
|
|
@ -459,6 +512,11 @@ export function CableSizingModal({
|
|||
value={manualCrossSection}
|
||||
/>
|
||||
</div>
|
||||
{manualCrossSectionWarning && (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3170,6 +3170,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
{cableSizingEditorCircuit ? (
|
||||
<CableSizingModal
|
||||
circuit={cableSizingEditorCircuit}
|
||||
circuitCategory={
|
||||
data.sections.find(
|
||||
(section) => section.id === cableSizingEditorCircuit.sectionId
|
||||
)?.category
|
||||
}
|
||||
isSaving={isSaving}
|
||||
projectId={projectId}
|
||||
onClose={() => setCableSizingEditorCircuit(null)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue