cable-sizing: max length, socket minimum, manual-entry warning, SVG icon

- 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.
This commit is contained in:
Grovy311 2026-08-07 18:13:27 +02:00
parent 8562d6eb83
commit cfb76a3754
6 changed files with 210 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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