Add proposed cable-sizing module

Isolated module (mirrors src/external-model/ dependency direction) that
adds an on-demand, DIN VDE 0298-4 referenced cable cross-section
calculator: a click-on-a-circuit input mask for laying method,
insulation, ambient temperature, grouping and voltage-drop limit that
suggests a cross-section for the circuit cableCrossSection/cableLength
fields. Applying a suggestion goes through the existing circuit.update
command (expectedRevision, undo/redo) unchanged - nothing here writes
to circuits directly or touches the revision/command system. See
docs/cable-sizing-module.md for the full rationale, API contract,
verification status and maintenance plan. Proposal, not yet reviewed
by the project owner - see the docs file.

423 existing + 11 new tests pass, build:api/build:web/typecheck:scripts
clean.
This commit is contained in:
Grovy311 2026-08-07 17:13:59 +02:00
parent a17e2e3f4b
commit 1d030971dd
17 changed files with 4179 additions and 2 deletions

View file

@ -0,0 +1,451 @@
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/ -
// mirrors the dependency direction of src/external-model/ (see
// docs/cable-sizing-module.md). Everything here is a stateless function on
// plain data; persistence and the versioned circuit.update command live
// outside this module.
//
// The numeric reference tables below (cross-section current-carrying
// capacity, temperature/grouping correction factors) are ported from a
// sibling project's already-verified Kabelliste module
// (elt-planung-suite/src/tools/kabel/index.ts), not re-derived. See
// docs/cable-sizing-module.md for the verification history and the explicit
// "not yet verified" methods, which intentionally return no numeric result
// instead of a guessed one (dataVerified: false).
// -- Laying methods (DIN VDE 0298-4 reference installation methods) --------
export const LAYING_METHODS = [
"A1",
"A2",
"B1",
"B2",
"C",
"D1",
"D2",
"E",
"F",
"G",
] as const;
export type LayingMethod = (typeof LAYING_METHODS)[number];
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
A1: "air",
A2: "air",
B1: "air",
B2: "air",
C: "air",
E: "air",
F: "air",
D1: "ground",
D2: "ground",
G: "ground",
};
// A1, B2, C, E, D1, D2: verified against elt-planung-suite's ported original
// tool. A2, B1, F, G: no verified capacity table available yet (see
// docs/cable-sizing-module.md) - calculateCableSizing returns
// dataVerified: false for these instead of a guessed number.
export const LAYING_METHOD_VERIFIED: Record<LayingMethod, boolean> = {
A1: true,
B2: true,
C: true,
E: true,
D1: true,
D2: true,
A2: false,
B1: false,
F: false,
G: false,
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
A1: "A1 - Rohr in wärmegedämmter Wand",
A2: "A2 - Mehradriges Kabel im Rohr in wärmegedämmter Wand (Werte nicht hinterlegt)",
B1: "B1 - Einzeladern im Rohr auf/unter Putz (Werte nicht hinterlegt)",
B2: "B2 - Rohr auf/unter Putz",
C: "C - Kabel direkt auf Wand verlegt",
E: "E - Kabelpritsche / frei in Luft",
F: "F - Kabel frei in Luft, einzeln mit Abstand (Werte nicht hinterlegt)",
D1: "D1 - Kabel direkt im Erdreich",
D2: "D2 - Kabel im Rohr im Erdreich",
G: "G - Erdverlegung, Sonderfall (Werte nicht hinterlegt)",
};
// -- Insulation / conductor material ----------------------------------------
export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const;
export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number];
export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
pvc: "PVC (Grenztemperatur 70 °C)",
xlpe: "VPE/XLPE (Grenztemperatur 90 °C) - Strombelastbarkeitswerte nicht hinterlegt",
};
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// Only PVC combined with one of the six verified laying methods returns a
// numeric result. XLPE has no verified capacity table for any method yet.
export function isCableSizingDataVerified(
layingMethod: LayingMethod,
insulation: InsulationMaterial
): boolean {
return insulation === "pvc" && LAYING_METHOD_VERIFIED[layingMethod];
}
// -- Harmonic neutral loading (IEC 60364-5-52, three-phase only) -----------
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
"none",
"15to33Percent",
"over33Percent",
] as const;
export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number];
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
none: "Keine nennenswerte 3. Harmonische (< 15 %) - Standardfall",
"15to33Percent":
"3. Harmonische 15-33 % - Reduktionsfaktor 0,86 (IEC 60364-5-52)",
over33Percent:
"3. Harmonische > 33 % - Neutralleiter wie Außenleiter dimensionieren",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
// -- Reference tables (ported, verified subset only) ------------------------
export const CROSS_SECTIONS_MM2 = [
1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240,
] as const;
type CoreCount = 2 | 3;
export const CURRENT_CAPACITY_A: Partial<
Record<LayingMethod, Record<CoreCount, (number | null)[]>>
> = {
A1: {
2: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, null, null, null],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, null, null, null],
},
B2: {
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 134, 171, 207, 239, null, null, null],
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, null, null, null],
},
C: {
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
},
E: {
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
},
D1: {
2: [26, 34, 44, 56, 74, 96, 123, 147, 174, 216, 256, 290, 328, 367, 424],
3: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 361],
},
D2: {
2: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 360],
3: [18.5, 24, 31, 39, 52, 67, 86, 103, 122, 151, 179, 203, 230, 258, 297],
},
};
export const TEMPERATURE_FACTOR_AIR: Record<number, number> = {
10: 1.29,
15: 1.22,
20: 1.15,
25: 1.08,
30: 1.0,
35: 0.91,
40: 0.82,
45: 0.71,
50: 0.58,
};
export const TEMPERATURE_FACTOR_GROUND: Record<number, number> = {
10: 1.1,
15: 1.05,
20: 1.0,
25: 0.95,
30: 0.89,
35: 0.84,
40: 0.77,
};
export const GROUPING_FACTOR_AIR: Record<number, number> = {
1: 1.0,
2: 0.8,
3: 0.7,
4: 0.65,
5: 0.6,
6: 0.57,
8: 0.54,
10: 0.5,
};
export const GROUPING_FACTOR_GROUND: Record<number, number> = {
1: 1.0,
2: 0.75,
3: 0.65,
4: 0.6,
5: 0.55,
6: 0.52,
8: 0.49,
10: 0.46,
};
export const ALUMINUM_CAPACITY_FACTOR = 0.78;
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const;
export const GROUPING_LABELS: Record<number, string> = {
1: "1 (keine Häufung)",
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
8: "7-9",
10: "≥10",
};
// -- Input / result -----------------------------------------------------
export interface CableSizingInput {
phase: 1 | 3;
mode: "power" | "current";
/** kW, only used when mode === "power" */
powerKw?: number;
/** A, only used when mode === "current" */
currentA?: number;
cosPhi: number;
/** Line voltage in V. The circuit-list app derives this from project
* settings (singlePhaseVoltageV / threePhaseVoltageV); pass it through
* rather than hard-coding 230/400 here. */
voltage: number;
/** Single-run cable length in meters (circuits.cableLength). */
lengthM: number;
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
ambientTemperatureC: number;
groupingCircuits: number;
maxVoltageDropPercent: number;
harmonicNeutralLoad: HarmonicNeutralLoad;
/** Optional: existing protection device rated current (LS/circuit
* breaker), for the simplified coordination check below. */
existingProtectionRatedCurrentA?: number;
}
export interface CrossSectionRow {
crossSectionMm2: number;
ratedCurrentA: number | null;
correctedCurrentA: number | null;
currentSufficient: boolean | null;
voltageDropPercent: number | null;
voltageDropSufficient: boolean | null;
recommended: boolean;
}
export interface ProtectionCoordinationResult {
ratedCurrentA: number;
/** Simplified In <= Iz check only - not full overload (I2 <= 1.45 x Iz)
* or short-circuit coordination. */
coordinated: boolean;
}
export interface CableSizingResult {
/** false: no verified capacity table for this layingMethod/insulation
* combination - every field below is null/empty, never a guess. */
dataVerified: boolean;
operatingCurrentA: number;
crossSectionByCapacityMm2: number | null;
crossSectionByVoltageDropMm2: number | null;
recommendedCrossSectionMm2: number | null;
voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number;
harmonicReductionApplied: boolean;
rows: CrossSectionRow[];
protectionCoordination: ProtectionCoordinationResult | null;
}
function operatingCurrentA(input: CableSizingInput): number {
if (input.mode === "current") {
return input.currentA ?? 0;
}
const powerW = (input.powerKw ?? 0) * 1000;
return input.phase === 1
? powerW / (input.voltage * input.cosPhi)
: powerW / (Math.sqrt(3) * input.voltage * input.cosPhi);
}
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
const ib = operatingCurrentA(input);
if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) {
return {
dataVerified: false,
operatingCurrentA: ib,
crossSectionByCapacityMm2: null,
crossSectionByVoltageDropMm2: null,
recommendedCrossSectionMm2: null,
voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1,
harmonicReductionApplied: false,
rows: [],
protectionCoordination: null,
};
}
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
const aluminumFactor =
input.conductorMaterial === "aluminum" ? ALUMINUM_CAPACITY_FACTOR : 1;
const kappa = KAPPA[input.conductorMaterial];
const group = LAYING_METHOD_GROUP[input.layingMethod];
const temperatureTable =
group === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
const groupingTable = group === "air" ? GROUPING_FACTOR_AIR : GROUPING_FACTOR_GROUND;
const temperatureFactor = temperatureTable[input.ambientTemperatureC] ?? 1;
const groupingFactor = groupingTable[input.groupingCircuits] ?? 1;
const harmonicReductionApplied =
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
const harmonicFactor = harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1;
const combinedDerationFactor = temperatureFactor * groupingFactor * harmonicFactor;
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
const ratedCurrents = CURRENT_CAPACITY_A[input.layingMethod]![coreCount];
const correctedCurrents = ratedCurrents.map((value) =>
value == null ? null : value * aluminumFactor * combinedDerationFactor
);
const voltageDrops = CROSS_SECTIONS_MM2.map(
(crossSection) =>
((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) /
(kappa * crossSection) /
input.voltage) *
100
);
const indexByCapacity = correctedCurrents.findIndex(
(value) => value != null && value >= ib
);
const indexByVoltageDrop = voltageDrops.findIndex(
(value) => value <= input.maxVoltageDropPercent
);
let indexRecommended = -1;
if (indexByCapacity >= 0 && indexByVoltageDrop >= 0) {
indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop);
}
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 voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
return {
crossSectionMm2: crossSection,
ratedCurrentA: rated,
correctedCurrentA: corrected,
currentSufficient,
voltageDropPercent: rated == null ? null : voltageDrops[i],
voltageDropSufficient: rated == null ? null : voltageDropSufficient,
recommended: i === indexRecommended,
};
});
let protectionCoordination: ProtectionCoordinationResult | null = null;
if (input.existingProtectionRatedCurrentA != null && indexRecommended >= 0) {
const recommendedRow = rows[indexRecommended];
protectionCoordination = {
ratedCurrentA: input.existingProtectionRatedCurrentA,
coordinated:
recommendedRow.correctedCurrentA != null &&
input.existingProtectionRatedCurrentA <= recommendedRow.correctedCurrentA,
};
}
return {
dataVerified: true,
operatingCurrentA: ib,
crossSectionByCapacityMm2:
indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
crossSectionByVoltageDropMm2:
indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null,
recommendedCrossSectionMm2:
indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null,
voltageDropAtRecommendedPercent:
indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
combinedDerationFactor,
harmonicReductionApplied,
rows,
protectionCoordination,
};
}
export interface CableSizingAlert {
kind: "critical" | "warn" | "info" | "ok";
text: string;
}
export function buildCableSizingAlerts(
input: CableSizingInput,
result: CableSizingResult
): CableSizingAlert[] {
const alerts: CableSizingAlert[] = [];
if (!result.dataVerified) {
alerts.push({
kind: "critical",
text: `Für Verlegeart ${input.layingMethod} mit Isolierstoff ${input.insulation.toUpperCase()} liegen keine geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich.`,
});
return alerts;
}
const byCapacity = result.crossSectionByCapacityMm2;
const byDrop = result.crossSectionByVoltageDropMm2;
const recommended = result.recommendedCrossSectionMm2;
if (byCapacity == null) {
alerts.push({
kind: "critical",
text: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`,
});
} else if (byDrop != null && byDrop > byCapacity) {
alerts.push({
kind: "warn",
text: `Spannungsfall ist maßgeblich, nicht die Belastbarkeit: ${byCapacity} mm² würde thermisch reichen, ${byDrop} mm² ist wegen ΔU ≤ ${input.maxVoltageDropPercent}% nötig.`,
});
}
if (result.harmonicReductionApplied) {
alerts.push({
kind: "info",
text: "Reduktionsfaktor 0,86 wegen Oberschwingungsanteil 15-33 % im Neutralleiter angewendet (IEC 60364-5-52).",
});
}
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
alerts.push({
kind: "warn",
text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.",
});
}
if (result.combinedDerationFactor < 1) {
alerts.push({
kind: "info",
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 (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.`,
});
}
return alerts;
}

View file

@ -0,0 +1,48 @@
// Request/response validation for the cable-sizing HTTP API. Pure - depends
// only on zod and the sibling calculation module, never on db/server.
import { z } from "zod";
import {
CONDUCTOR_MATERIALS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
LAYING_METHODS,
type CableSizingInput,
} from "./cable-sizing-calculation.js";
export const cableSizingRequestSchema = z
.object({
phase: z.union([z.literal(1), z.literal(3)]),
mode: z.enum(["power", "current"]),
powerKw: z.number().min(0).optional(),
currentA: z.number().min(0).optional(),
cosPhi: z.number().min(0.6).max(1),
voltage: z.number().positive(),
lengthM: z.number().min(0),
layingMethod: z.enum(LAYING_METHODS),
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
insulation: z.enum(INSULATION_MATERIALS),
ambientTemperatureC: z.number(),
groupingCircuits: z.number().int().min(1),
maxVoltageDropPercent: z.number().min(1).max(5),
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
existingProtectionRatedCurrentA: z.number().positive().optional(),
// Optional context, persisted with the audit-log entry only - never
// used for the calculation itself.
context: z
.object({
projectId: z.string().min(1).optional(),
circuitId: z.string().min(1).optional(),
equipmentIdentifier: z.string().min(1).optional(),
})
.optional(),
})
.refine((v) => (v.mode === "power" ? v.powerKw != null : v.currentA != null), {
message: "powerKw is required for mode 'power', currentA for mode 'current'",
});
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
// Structural check that the request schema stays a superset of the domain
// input shape (minus context, which is API-only metadata).
type _AssertAssignable<T extends CableSizingInput> = T;
type _Check = _AssertAssignable<Omit<CableSizingRequest, "context">>;

View file

@ -0,0 +1,14 @@
CREATE TABLE `cable_sizing_calculations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text,
`circuit_id` text,
`equipment_identifier` text,
`input` text NOT NULL,
`result` text NOT NULL,
`applied_to_circuit` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `cable_sizing_calculations_circuit_id_idx` ON `cable_sizing_calculations` (`circuit_id`);

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,13 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1786114831610,
"tag": "0007_watery_kingpin",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,37 @@
import { desc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
cableSizingCalculations,
type CableSizingCalculation,
type NewCableSizingCalculation,
} from "../schema/cable-sizing-calculations.js";
// Plain repository, no revision/command semantics - mirrors
// application-repositories.ts's flat repository pattern, not the
// project-command-transaction boundary. A calculation is an audit record,
// not a project mutation.
export class CableSizingCalculationRepository {
constructor(private readonly db: AppDatabase) {}
create(input: NewCableSizingCalculation): CableSizingCalculation {
return this.db.insert(cableSizingCalculations).values(input).returning().get();
}
listByCircuit(circuitId: string): CableSizingCalculation[] {
return this.db
.select()
.from(cableSizingCalculations)
.where(eq(cableSizingCalculations.circuitId, circuitId))
.orderBy(desc(cableSizingCalculations.createdAt))
.all();
}
markApplied(id: string): CableSizingCalculation | undefined {
return this.db
.update(cableSizingCalculations)
.set({ appliedToCircuit: 1 })
.where(eq(cableSizingCalculations.id, id))
.returning()
.get();
}
}

View file

@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js";
import { projects } from "./projects.js";
// Audit log for the cable-sizing module (see docs/cable-sizing-module.md).
// Deliberately separate from the revision/command system: a calculation is
// not itself a project mutation, only applying its result via the existing
// circuit.update command is. circuitId/projectId are nullable and
// onDelete "set null" so a deleted circuit or project never blocks or
// cascades into losing the audit trail.
export const cableSizingCalculations = sqliteTable(
"cable_sizing_calculations",
{
id: text("id").primaryKey(),
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
circuitId: text("circuit_id").references(() => circuits.id, { onDelete: "set null" }),
equipmentIdentifier: text("equipment_identifier"),
input: text("input", { mode: "json" }).notNull(),
result: text("result", { mode: "json" }).notNull(),
appliedToCircuit: integer("applied_to_circuit").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [index("cable_sizing_calculations_circuit_id_idx").on(table.circuitId)]
);
export type CableSizingCalculation = typeof cableSizingCalculations.$inferSelect;
export type NewCableSizingCalculation = typeof cableSizingCalculations.$inferInsert;

View file

@ -0,0 +1,71 @@
// Thin, self-contained fetch client for the cable-sizing module. Deliberately
// not merged into ../utils/api.ts to keep this module's frontend footprint a
// single new file rather than growing the existing shared API file - see
// docs/cable-sizing-module.md.
import type {
CableSizingAlert,
CableSizingInput,
CableSizingResult,
InsulationMaterial,
LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
cache: "no-store",
});
if (!response.ok) {
let details = await response.text();
try {
const parsed = JSON.parse(details) as { error?: unknown };
if (typeof parsed.error === "string") details = parsed.error;
} catch {
// keep raw text
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
}
return response.json() as Promise<T>;
}
export interface CalculateCableSizingResponse {
calculationId: string;
result: CableSizingResult;
alerts: CableSizingAlert[];
}
export function calculateCableSizing(
input: CableSizingInput,
context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string }
): Promise<CalculateCableSizingResponse> {
return request("/api/cable-sizing/calculate", {
method: "POST",
body: JSON.stringify({ ...input, context }),
});
}
export function markCableSizingCalculationApplied(calculationId: string): Promise<unknown> {
return request(`/api/cable-sizing/calculations/${calculationId}/applied`, {
method: "POST",
});
}
export interface LayingMethodOption {
method: LayingMethod;
label: string;
dataVerified: boolean;
}
export function listLayingMethods(): Promise<LayingMethodOption[]> {
return request("/api/cable-sizing/laying-methods");
}
export interface InsulationMaterialOption {
insulation: InsulationMaterial;
label: string;
}
export function listInsulationMaterials(): Promise<InsulationMaterialOption[]> {
return request("/api/cable-sizing/insulation-materials");
}

View file

@ -0,0 +1,407 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
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,
TEMPERATURE_FACTOR_AIR,
TEMPERATURE_FACTOR_GROUND,
type CableSizingAlert,
type CableSizingInput,
type CableSizingResult,
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;
isSaving: boolean;
projectId: string;
onClose: () => void;
onApply: (patch: { cableCrossSection: 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);
}
export function CableSizingModal({
circuit,
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);
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,
}),
[
phase,
circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
circuit.protectionDevice?.ratedCurrentA,
]
);
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);
} catch (err) {
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
} finally {
setCalculating(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!result?.recommendedCrossSectionMm2) return;
await onApply({
cableCrossSection: `${result.recommendedCrossSectionMm2} mm²`,
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."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!result?.dataVerified || !result.recommendedCrossSectionMm2}
submitLabel="Empfehlung übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
>
<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">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>
{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>
))}
</div>
)}
{calculationId ? null : (
<p className="text-muted small mt-2">
Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch
geschrieben.
</p>
)}
</FormModal>
);
}

View file

@ -137,6 +137,7 @@ import type {
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { CableSizingModal } from "./cable-sizing-modal";
import {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
@ -278,6 +279,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
@ -1398,6 +1401,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleApplyCableSizing(patch: {
cableCrossSection: string;
cableLength?: number;
}) {
const circuit = cableSizingEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Kabel dimensionieren",
redo: async () => {
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuit.id,
patch
);
applyProjectCommandResult(result);
setCableSizingEditorCircuit(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@ -3139,6 +3166,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitProtection}
/>
) : null}
{cableSizingEditorCircuit ? (
<CableSizingModal
circuit={cableSizingEditorCircuit}
isSaving={isSaving}
projectId={projectId}
onClose={() => setCableSizingEditorCircuit(null)}
onApply={handleApplyCableSizing}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
@ -4240,10 +4276,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.circuit &&
row.rowType !== "deviceRow"
);
const isCableSizingTrigger = Boolean(
column.key === "cableCrossSection" &&
row.circuit &&
row.rowType !== "deviceRow"
);
return (
<td
key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isCableSizingTrigger ? "cell-cable-sizing-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
: ""
@ -4263,7 +4304,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
title={
isProtectionTrigger
? "Schutzgerät bearbeiten"
: column.key === "equipmentIdentifier" &&
: isCableSizingTrigger
? "Kabel dimensionieren"
: column.key === "equipmentIdentifier" &&
row.circuit &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
@ -4342,6 +4385,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setProtectionEditorCircuit(row.circuit!);
return;
}
if (isCableSizingTrigger) {
setCableSizingEditorCircuit(row.circuit!);
return;
}
if (cell.editable) {
handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey,

View file

@ -13,6 +13,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { CableSizingCalculationRepository } from "../../db/repositories/cable-sizing-calculation.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -33,3 +34,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);

View file

@ -0,0 +1,80 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
import {
calculateCableSizing,
buildCableSizingAlerts,
LAYING_METHODS,
LAYING_METHOD_LABELS,
LAYING_METHOD_VERIFIED,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
import { cableSizingCalculationRepository } from "../composition/application-repositories.js";
// Stateless calculation, not a project command: no expectedRevision, no
// circuit mutation here. The caller applies the result via the existing
// circuit.update command (POST /api/projects/:projectId/commands) if the
// user confirms it. See docs/cable-sizing-module.md.
export async function calculateCableSizingHandler(req: Request, res: Response) {
const parsed = cableSizingRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { context, ...input } = parsed.data;
const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result);
const entry = await cableSizingCalculationRepository.create({
id: randomUUID(),
projectId: context?.projectId ?? null,
circuitId: context?.circuitId ?? null,
equipmentIdentifier: context?.equipmentIdentifier ?? null,
input,
result,
appliedToCircuit: 0,
});
return res.status(201).json({ calculationId: entry.id, result, alerts });
}
export async function listLayingMethodsHandler(_req: Request, res: Response) {
return res.json(
LAYING_METHODS.map((method) => ({
method,
label: LAYING_METHOD_LABELS[method],
dataVerified: LAYING_METHOD_VERIFIED[method],
}))
);
}
export async function listInsulationMaterialsHandler(_req: Request, res: Response) {
return res.json(
INSULATION_MATERIALS.map((insulation) => ({
insulation,
label: INSULATION_MATERIAL_LABELS[insulation],
}))
);
}
export async function markCalculationAppliedHandler(req: Request, res: Response) {
const { calculationId } = req.params;
if (typeof calculationId !== "string") {
return res.status(400).json({ error: "Invalid calculationId" });
}
const updated = await cableSizingCalculationRepository.markApplied(calculationId);
if (!updated) {
return res.status(404).json({ error: "Calculation not found" });
}
return res.json(updated);
}
export async function listCalculationsForCircuitHandler(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const rows = await cableSizingCalculationRepository.listByCircuit(circuitId);
return res.json(rows);
}

View file

@ -1,6 +1,7 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -49,6 +50,7 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/cable-sizing", cableSizingRouter);
app.use(errorMiddleware);

View file

@ -0,0 +1,19 @@
import express from "express";
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
export const cableSizingRouter = express.Router();
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
cableSizingRouter.get(
"/insulation-materials",
cableSizingController.listInsulationMaterialsHandler
);
cableSizingRouter.post(
"/calculations/:calculationId/applied",
cableSizingController.markCalculationAppliedHandler
);
cableSizingRouter.get(
"/circuits/:circuitId/calculations",
cableSizingController.listCalculationsForCircuitHandler
);