forked from jappel/leistungsbilanz-ts
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:
parent
8562d6eb83
commit
cfb76a3754
6 changed files with 210 additions and 6 deletions
|
|
@ -1233,11 +1233,22 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .cell-cable-sizing-trigger::before {
|
.tree-grid .cell-cable-sizing-trigger::before {
|
||||||
content: "🧮";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.2em;
|
left: 0.2em;
|
||||||
font-size: 0.8em;
|
top: 50%;
|
||||||
opacity: 0.8;
|
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 {
|
.tree-grid .device-drag-handle {
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,21 @@ export const GROUPING_LABELS: Record<number, string> = {
|
||||||
10: "≥10",
|
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 -----------------------------------------------------
|
// -- Input / result -----------------------------------------------------
|
||||||
|
|
||||||
export interface CableSizingInput {
|
export interface CableSizingInput {
|
||||||
|
|
@ -235,6 +250,9 @@ export interface CableSizingInput {
|
||||||
/** Optional: existing protection device rated current (LS/circuit
|
/** Optional: existing protection device rated current (LS/circuit
|
||||||
* breaker), for the simplified coordination check below. */
|
* breaker), for the simplified coordination check below. */
|
||||||
existingProtectionRatedCurrentA?: number;
|
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 {
|
export interface CrossSectionRow {
|
||||||
|
|
@ -244,6 +262,11 @@ export interface CrossSectionRow {
|
||||||
currentSufficient: boolean | null;
|
currentSufficient: boolean | null;
|
||||||
voltageDropPercent: number | null;
|
voltageDropPercent: number | null;
|
||||||
voltageDropSufficient: boolean | 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;
|
recommended: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +295,10 @@ export interface CableSizingResult {
|
||||||
voltageDropAtRecommendedPercent: number | null;
|
voltageDropAtRecommendedPercent: number | null;
|
||||||
combinedDerationFactor: number;
|
combinedDerationFactor: number;
|
||||||
harmonicReductionApplied: boolean;
|
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[];
|
rows: CrossSectionRow[];
|
||||||
protectionCoordination: ProtectionCoordinationResult | null;
|
protectionCoordination: ProtectionCoordinationResult | null;
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +332,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
voltageDropAtRecommendedPercent: null,
|
voltageDropAtRecommendedPercent: null,
|
||||||
combinedDerationFactor: 1,
|
combinedDerationFactor: 1,
|
||||||
harmonicReductionApplied: false,
|
harmonicReductionApplied: false,
|
||||||
|
practicalMinimumApplied: false,
|
||||||
rows: [],
|
rows: [],
|
||||||
protectionCoordination: null,
|
protectionCoordination: null,
|
||||||
};
|
};
|
||||||
|
|
@ -351,11 +379,38 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop);
|
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 rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
|
||||||
const rated = ratedCurrents[i];
|
const rated = ratedCurrents[i];
|
||||||
const corrected = correctedCurrents[i];
|
const corrected = correctedCurrents[i];
|
||||||
const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
|
const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
|
||||||
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
|
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
|
||||||
|
const maxLengthForVoltageDropM =
|
||||||
|
rated == null || voltageDropCurrentBasis <= 0
|
||||||
|
? null
|
||||||
|
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
|
||||||
|
(100 * voltageDropCoefficient * voltageDropCurrentBasis);
|
||||||
return {
|
return {
|
||||||
crossSectionMm2: crossSection,
|
crossSectionMm2: crossSection,
|
||||||
ratedCurrentA: rated,
|
ratedCurrentA: rated,
|
||||||
|
|
@ -363,6 +418,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
currentSufficient,
|
currentSufficient,
|
||||||
voltageDropPercent: rated == null ? null : voltageDrops[i],
|
voltageDropPercent: rated == null ? null : voltageDrops[i],
|
||||||
voltageDropSufficient: rated == null ? null : voltageDropSufficient,
|
voltageDropSufficient: rated == null ? null : voltageDropSufficient,
|
||||||
|
maxLengthForVoltageDropM,
|
||||||
recommended: i === indexRecommended,
|
recommended: i === indexRecommended,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -392,6 +448,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
|
indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
|
||||||
combinedDerationFactor,
|
combinedDerationFactor,
|
||||||
harmonicReductionApplied,
|
harmonicReductionApplied,
|
||||||
|
practicalMinimumApplied,
|
||||||
rows,
|
rows,
|
||||||
protectionCoordination,
|
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.",
|
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) {
|
if (result.combinedDerationFactor < 1) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
kind: "info",
|
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.`,
|
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;
|
return alerts;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export const cableSizingRequestSchema = z
|
||||||
maxVoltageDropPercent: z.number().min(1).max(5),
|
maxVoltageDropPercent: z.number().min(1).max(5),
|
||||||
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
|
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
|
||||||
existingProtectionRatedCurrentA: z.number().positive().optional(),
|
existingProtectionRatedCurrentA: z.number().positive().optional(),
|
||||||
|
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
|
||||||
// Optional context, persisted with the audit-log entry only - never
|
// Optional context, persisted with the audit-log entry only - never
|
||||||
// used for the calculation itself.
|
// used for the calculation itself.
|
||||||
context: z
|
context: z
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { type FormEvent, useMemo, useState } from "react";
|
import { type FormEvent, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CONDUCTOR_MATERIALS,
|
CONDUCTOR_MATERIALS,
|
||||||
|
CROSS_SECTIONS_MM2,
|
||||||
GROUPING_LABELS,
|
GROUPING_LABELS,
|
||||||
GROUPING_OPTIONS,
|
GROUPING_OPTIONS,
|
||||||
HARMONIC_NEUTRAL_LOAD_LABELS,
|
HARMONIC_NEUTRAL_LOAD_LABELS,
|
||||||
|
|
@ -12,11 +13,13 @@ import {
|
||||||
LAYING_METHODS,
|
LAYING_METHODS,
|
||||||
LAYING_METHOD_GROUP,
|
LAYING_METHOD_GROUP,
|
||||||
LAYING_METHOD_LABELS,
|
LAYING_METHOD_LABELS,
|
||||||
|
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
|
||||||
TEMPERATURE_FACTOR_AIR,
|
TEMPERATURE_FACTOR_AIR,
|
||||||
TEMPERATURE_FACTOR_GROUND,
|
TEMPERATURE_FACTOR_GROUND,
|
||||||
type CableSizingAlert,
|
type CableSizingAlert,
|
||||||
type CableSizingInput,
|
type CableSizingInput,
|
||||||
type CableSizingResult,
|
type CableSizingResult,
|
||||||
|
type CircuitCategory,
|
||||||
type ConductorMaterial,
|
type ConductorMaterial,
|
||||||
type HarmonicNeutralLoad,
|
type HarmonicNeutralLoad,
|
||||||
type InsulationMaterial,
|
type InsulationMaterial,
|
||||||
|
|
@ -28,6 +31,7 @@ import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
interface CableSizingModalProps {
|
interface CableSizingModalProps {
|
||||||
circuit: CircuitTreeCircuitDto;
|
circuit: CircuitTreeCircuitDto;
|
||||||
|
circuitCategory?: CircuitCategory;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -44,8 +48,19 @@ function temperatureOptions(method: LayingMethod) {
|
||||||
return Object.keys(table).map(Number).sort((a, b) => a - b);
|
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({
|
export function CableSizingModal({
|
||||||
circuit,
|
circuit,
|
||||||
|
circuitCategory,
|
||||||
isSaving,
|
isSaving,
|
||||||
projectId,
|
projectId,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -92,6 +107,7 @@ export function CableSizingModal({
|
||||||
maxVoltageDropPercent,
|
maxVoltageDropPercent,
|
||||||
harmonicNeutralLoad,
|
harmonicNeutralLoad,
|
||||||
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
|
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
|
||||||
|
circuitCategory,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
phase,
|
phase,
|
||||||
|
|
@ -107,9 +123,34 @@ export function CableSizingModal({
|
||||||
maxVoltageDropPercent,
|
maxVoltageDropPercent,
|
||||||
harmonicNeutralLoad,
|
harmonicNeutralLoad,
|
||||||
circuit.protectionDevice?.ratedCurrentA,
|
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) {
|
function handleLayingMethodChange(method: LayingMethod) {
|
||||||
setLayingMethod(method);
|
setLayingMethod(method);
|
||||||
const options = temperatureOptions(method);
|
const options = temperatureOptions(method);
|
||||||
|
|
@ -162,7 +203,9 @@ export function CableSizingModal({
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitDisabled={!manualCrossSection.trim()}
|
submitDisabled={!manualCrossSection.trim()}
|
||||||
submitLabel="Übernehmen"
|
submitLabel="Übernehmen"
|
||||||
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
|
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
|
||||||
|
circuit.displayName ? ` (${circuit.displayName})` : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-12 col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
|
|
@ -401,6 +444,16 @@ export function CableSizingModal({
|
||||||
<div className="text-muted small">Korrekturfaktor</div>
|
<div className="text-muted small">Korrekturfaktor</div>
|
||||||
<div>{result.combinedDerationFactor.toFixed(2)}</div>
|
<div>{result.combinedDerationFactor.toFixed(2)}</div>
|
||||||
</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>
|
</div>
|
||||||
{alerts.map((alert, index) => (
|
{alerts.map((alert, index) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -459,6 +512,11 @@ export function CableSizingModal({
|
||||||
value={manualCrossSection}
|
value={manualCrossSection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{manualCrossSectionWarning && (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3170,6 +3170,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
{cableSizingEditorCircuit ? (
|
{cableSizingEditorCircuit ? (
|
||||||
<CableSizingModal
|
<CableSizingModal
|
||||||
circuit={cableSizingEditorCircuit}
|
circuit={cableSizingEditorCircuit}
|
||||||
|
circuitCategory={
|
||||||
|
data.sections.find(
|
||||||
|
(section) => section.id === cableSizingEditorCircuit.sectionId
|
||||||
|
)?.category
|
||||||
|
}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClose={() => setCableSizingEditorCircuit(null)}
|
onClose={() => setCableSizingEditorCircuit(null)}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ describe("buildCableSizingAlerts", () => {
|
||||||
assert.ok(alerts[0].text.includes("keine geprüften"));
|
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 = {
|
const input: CableSizingInput = {
|
||||||
...BASE_INPUT,
|
...BASE_INPUT,
|
||||||
mode: "current",
|
mode: "current",
|
||||||
|
|
@ -144,7 +144,66 @@ describe("buildCableSizingAlerts", () => {
|
||||||
};
|
};
|
||||||
const result = calculateCableSizing(input);
|
const result = calculateCableSizing(input);
|
||||||
const alerts = buildCableSizingAlerts(input, result);
|
const alerts = buildCableSizingAlerts(input, result);
|
||||||
assert.equal(alerts.length, 1);
|
assert.equal(alerts.length, 2);
|
||||||
assert.equal(alerts[0].kind, "ok");
|
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