Proposal: cable-sizing module (on-demand DIN VDE 0298-4 cross-section calculator) #1
17 changed files with 4179 additions and 2 deletions
162
docs/cable-sizing-module.md
Normal file
162
docs/cable-sizing-module.md
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Cable Sizing Module (Proposal)
|
||||||
|
|
||||||
|
**Status: proposal, not yet reviewed or merged by the project owner.** This
|
||||||
|
document and the accompanying `src/cable-sizing/`, `src/db/schema/cable-sizing-calculations.ts`,
|
||||||
|
`src/server/{controllers,routes}/cable-sizing.*` and `src/frontend/components/cable-sizing-*`
|
||||||
|
files were written by a third party (see git history/authorship) against the
|
||||||
|
project's own stated direction in `docs/spec/06-future-sizing-and-calculations.md`
|
||||||
|
("The app should later support rule-based protection and cable sizing") and
|
||||||
|
`AGENTS.md` ("... and later electrical sizing logic"). Nothing here has been
|
||||||
|
pushed to the project's own repository; it exists as a local branch for
|
||||||
|
review, testing and discussion.
|
||||||
|
|
||||||
|
## Why a separate module instead of extending core domain code
|
||||||
|
|
||||||
|
`AGENTS.md` is explicit that critical multi-write commands, the revision/undo
|
||||||
|
system and the Circuit-First domain model are the supported architecture, and
|
||||||
|
that changes should be small and reviewable. A cable-sizing calculation is
|
||||||
|
**not** a project mutation - it doesn't need `expectedRevision`, doesn't
|
||||||
|
belong in the undo/redo stack, and shouldn't grow the `circuit.update`
|
||||||
|
command's `switch` statement or the DTOs it doesn't already have.
|
||||||
|
|
||||||
|
So this module is built exactly like the existing `src/external-model/`
|
||||||
|
adapter (the Revit/CSV import foundation): a one-way dependency boundary.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/cable-sizing/domain/ <- pure functions and types, zero imports from
|
||||||
|
db/, server/ or frontend/
|
||||||
|
src/db/schema/ <- one new, fully additive table
|
||||||
|
src/db/repositories/ <- one plain repository (no revision semantics)
|
||||||
|
src/server/{controllers,routes}/cable-sizing.* <- one new, isolated route
|
||||||
|
group, mounted with a single app.use() line
|
||||||
|
src/frontend/components/cable-sizing-* <- one new modal + one small
|
||||||
|
API client, following the existing
|
||||||
|
FormModal / CircuitProtectionModal pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing outside these files imports from them except the two required
|
||||||
|
one-line hooks described below. If this module is rejected or needs to move
|
||||||
|
out again, removing it is a matter of deleting these files, the two
|
||||||
|
one-line hooks, and running a `DROP TABLE` migration - it never touches
|
||||||
|
`circuits`, `project_revisions` or any command/history table.
|
||||||
|
|
||||||
|
## The two required hooks into existing code
|
||||||
|
|
||||||
|
1. `src/server/index.ts`: one import + one `app.use("/api/cable-sizing", cableSizingRouter)`
|
||||||
|
line, next to the existing `app.use("/api/projects", ...)` etc.
|
||||||
|
2. `src/frontend/components/circuit-tree-editor.tsx`: one new `useState` for
|
||||||
|
the open modal, one new trigger condition (mirroring the existing
|
||||||
|
`isProtectionTrigger` / `protectionEditorCircuit` pattern almost exactly),
|
||||||
|
and one new conditionally-rendered `<CableSizingModal />` alongside the
|
||||||
|
existing `<CircuitProtectionModal />`. See the diff for the exact lines.
|
||||||
|
|
||||||
|
No changes to `circuit-project-command.model.ts`, `project-command.service.ts`,
|
||||||
|
any Zod command schema, any migration for `circuits`/`project_revisions`, or
|
||||||
|
any existing test.
|
||||||
|
|
||||||
|
## How a suggestion is applied
|
||||||
|
|
||||||
|
The module never writes to `circuits` directly. The modal's "Empfehlung
|
||||||
|
übernehmen" button calls the **existing, unmodified** frontend helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
updateCircuitById(projectId, expectedRevision, circuitId, {
|
||||||
|
cableCrossSection: "4 mm²",
|
||||||
|
cableLength: 30,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
which is the same `circuit.update` command the grid already uses for manual
|
||||||
|
cell edits. This means:
|
||||||
|
|
||||||
|
- optimistic concurrency (`expectedRevision`) is respected automatically
|
||||||
|
- the change appears in the project's revision history and is undoable/
|
||||||
|
redoable exactly like a manual edit
|
||||||
|
- manually typing a value into the `cableCrossSection` cell keeps working
|
||||||
|
unchanged - the module only adds a small calculator trigger next to the
|
||||||
|
cell, per `docs/spec/06-future-sizing-and-calculations.md`'s explicit
|
||||||
|
requirement that "users must remain able to manually override suggestions"
|
||||||
|
|
||||||
|
## API contract
|
||||||
|
|
||||||
|
`POST /api/cable-sizing/calculate`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"phase": 1, // 1 | 3
|
||||||
|
"mode": "power", // "power" | "current"
|
||||||
|
"powerKw": 4.2, // circuit.circuitTotalPower, when mode="power"
|
||||||
|
"cosPhi": 1,
|
||||||
|
"voltage": 230, // circuit.voltage (already project-derived, read-only)
|
||||||
|
"lengthM": 23.5, // circuit.cableLength
|
||||||
|
"layingMethod": "C", // DIN VDE 0298-4 reference method, A1|A2|B1|B2|C|D1|D2|E|F|G
|
||||||
|
"conductorMaterial": "copper", // "copper" | "aluminum"
|
||||||
|
"insulation": "pvc", // "pvc" | "xlpe"
|
||||||
|
"ambientTemperatureC": 30,
|
||||||
|
"groupingCircuits": 1,
|
||||||
|
"maxVoltageDropPercent": 3,
|
||||||
|
"harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only
|
||||||
|
"existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice
|
||||||
|
"context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `{ calculationId, result: CableSizingResult, alerts: CableSizingAlert[] }`
|
||||||
|
(see `src/cable-sizing/domain/cable-sizing-calculation.ts` for the exact
|
||||||
|
shape). `GET /api/cable-sizing/laying-methods` and `.../insulation-materials`
|
||||||
|
expose the pick-list metadata (including which combinations are verified) so
|
||||||
|
a future non-modal UI could build its own form without hard-coding the enum.
|
||||||
|
|
||||||
|
## Verification status - important
|
||||||
|
|
||||||
|
The reference current-carrying-capacity tables are ported from a sibling
|
||||||
|
project's Kabelliste module, which was itself only verified against ~8
|
||||||
|
public sources for **six** of the ten DIN VDE 0298-4 reference laying
|
||||||
|
methods (A1, B2, C, E, D1, D2) and **PVC insulation only**. The other four
|
||||||
|
methods (A2, B1, F, G) and XLPE/VPE insulation could not be verified without
|
||||||
|
contradiction across sources during that earlier work, so
|
||||||
|
`calculateCableSizing` deliberately returns `dataVerified: false` and no
|
||||||
|
numeric recommendation for those combinations, rather than a guessed value.
|
||||||
|
The UI surfaces this as a plain critical alert. Anyone with access to the
|
||||||
|
actual norm text can extend `LAYING_METHOD_VERIFIED` and
|
||||||
|
`CURRENT_CAPACITY_A` in `src/cable-sizing/domain/cable-sizing-calculation.ts`
|
||||||
|
once the missing tables are confirmed.
|
||||||
|
|
||||||
|
The protection-coordination check (`existingProtectionRatedCurrentA` vs. the
|
||||||
|
recommended cross-section's corrected capacity) is a **simplified** `In <=
|
||||||
|
Iz` check only - it is not a full IEC 60364-4-43 overload (`I2 <= 1.45 x
|
||||||
|
Iz`) or short-circuit withstand check.
|
||||||
|
|
||||||
|
## Relationship to the project's own future-sizing spec
|
||||||
|
|
||||||
|
`docs/spec/06-future-sizing-and-calculations.md` separately describes simple
|
||||||
|
category-based defaults (e.g. lighting circuits -> 10 A / 1.5 mm²) as
|
||||||
|
"common planning defaults, not a replacement for full norm-compliant
|
||||||
|
calculation". This module is the latter: an on-demand, norm-referenced
|
||||||
|
calculation for one circuit at a time, not a bulk default-filling tool. The
|
||||||
|
two are complementary and could later be wired together (e.g. the category
|
||||||
|
defaults pre-fill this module's laying-method/insulation fields), but that
|
||||||
|
integration is out of scope here.
|
||||||
|
|
||||||
|
The spec's `isPublicBuilding` halogen-free rule and the `Control Requirement`
|
||||||
|
(DALI/KNX/core-count) field are **not** implemented by this module - both
|
||||||
|
concern cable *type* selection, not cross-section sizing, and the latter
|
||||||
|
isn't in the current schema yet. They're natural follow-ups once this module
|
||||||
|
is reviewed.
|
||||||
|
|
||||||
|
## Maintenance plan
|
||||||
|
|
||||||
|
The `src/cable-sizing/domain/` folder has zero imports from the rest of the
|
||||||
|
app by design (see the dependency-direction note above), so it can be
|
||||||
|
extracted into an independently versioned/published package later (e.g. a
|
||||||
|
private npm package or a git subtree) without touching anything outside the
|
||||||
|
five files/folders listed above - "separately maintained but part of the
|
||||||
|
app for now", per the intent of this proposal.
|
||||||
|
|
||||||
|
## Local testing
|
||||||
|
|
||||||
|
Deployed and manually exercised on an internal test host at
|
||||||
|
`http://192.168.0.133:3220` (a fork of this repository's `main` branch, not
|
||||||
|
pushed anywhere). `npm test`, `npm run build:api`, `npm run build:web` and
|
||||||
|
`npm run typecheck:scripts` all pass with this module included; see the
|
||||||
|
accompanying diff/branch for the exact commit.
|
||||||
451
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
451
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal 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;
|
||||||
|
}
|
||||||
48
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
48
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal 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">>;
|
||||||
14
src/db/migrations/0007_watery_kingpin.sql
Normal file
14
src/db/migrations/0007_watery_kingpin.sql
Normal 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`);
|
||||||
2522
src/db/migrations/meta/0007_snapshot.json
Normal file
2522
src/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
||||||
"when": 1786043080323,
|
"when": 1786043080323,
|
||||||
"tag": "0006_damp_skrulls",
|
"tag": "0006_damp_skrulls",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1786114831610,
|
||||||
|
"tag": "0007_watery_kingpin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
37
src/db/repositories/cable-sizing-calculation.repository.ts
Normal file
37
src/db/repositories/cable-sizing-calculation.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/db/schema/cable-sizing-calculations.ts
Normal file
30
src/db/schema/cable-sizing-calculations.ts
Normal 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;
|
||||||
71
src/frontend/components/cable-sizing-api.ts
Normal file
71
src/frontend/components/cable-sizing-api.ts
Normal 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");
|
||||||
|
}
|
||||||
407
src/frontend/components/cable-sizing-modal.tsx
Normal file
407
src/frontend/components/cable-sizing-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -137,6 +137,7 @@ import type {
|
||||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||||
|
import { CableSizingModal } from "./cable-sizing-modal";
|
||||||
import {
|
import {
|
||||||
circuitGroupCategoryLabels,
|
circuitGroupCategoryLabels,
|
||||||
type CircuitGroupCategory,
|
type CircuitGroupCategory,
|
||||||
|
|
@ -278,6 +279,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
useState<CircuitGroupEditorIntent | null>(null);
|
useState<CircuitGroupEditorIntent | null>(null);
|
||||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
|
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
||||||
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||||
useState(false);
|
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() {
|
async function handleRedo() {
|
||||||
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3139,6 +3166,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
onSave={handleSaveCircuitProtection}
|
onSave={handleSaveCircuitProtection}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{cableSizingEditorCircuit ? (
|
||||||
|
<CableSizingModal
|
||||||
|
circuit={cableSizingEditorCircuit}
|
||||||
|
isSaving={isSaving}
|
||||||
|
projectId={projectId}
|
||||||
|
onClose={() => setCableSizingEditorCircuit(null)}
|
||||||
|
onApply={handleApplyCableSizing}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -4240,10 +4276,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
row.rowType !== "deviceRow"
|
row.rowType !== "deviceRow"
|
||||||
);
|
);
|
||||||
|
const isCableSizingTrigger = Boolean(
|
||||||
|
column.key === "cableCrossSection" &&
|
||||||
|
row.circuit &&
|
||||||
|
row.rowType !== "deviceRow"
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={column.key}
|
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")
|
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||||
? "device-drag-handle"
|
? "device-drag-handle"
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -4263,6 +4304,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
title={
|
title={
|
||||||
isProtectionTrigger
|
isProtectionTrigger
|
||||||
? "Schutzgerät bearbeiten"
|
? "Schutzgerät bearbeiten"
|
||||||
|
: isCableSizingTrigger
|
||||||
|
? "Kabel dimensionieren"
|
||||||
: column.key === "equipmentIdentifier" &&
|
: column.key === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
(row.rowType === "circuitCompact" ||
|
(row.rowType === "circuitCompact" ||
|
||||||
|
|
@ -4342,6 +4385,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
setProtectionEditorCircuit(row.circuit!);
|
setProtectionEditorCircuit(row.circuit!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isCableSizingTrigger) {
|
||||||
|
setCableSizingEditorCircuit(row.circuit!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
handleRowSelectionClick(row, column.key, {
|
handleRowSelectionClick(row, column.key, {
|
||||||
ctrlKey: event.ctrlKey,
|
ctrlKey: event.ctrlKey,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.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 =
|
export const circuitDeviceRowRepository =
|
||||||
new CircuitDeviceRowRepository(db);
|
new CircuitDeviceRowRepository(db);
|
||||||
|
|
@ -33,3 +34,4 @@ export const roomRepository = new RoomRepository(db);
|
||||||
export const externalCsvConfigurationRepository =
|
export const externalCsvConfigurationRepository =
|
||||||
new ExternalCsvConfigurationRepository(db);
|
new ExternalCsvConfigurationRepository(db);
|
||||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||||
|
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);
|
||||||
|
|
|
||||||
80
src/server/controllers/cable-sizing.controller.ts
Normal file
80
src/server/controllers/cable-sizing.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-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 { projectRouter } from "./routes/project.routes.js";
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.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/projects", projectRouter);
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
|
app.use("/api/cable-sizing", cableSizingRouter);
|
||||||
|
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|
||||||
|
|
|
||||||
19
src/server/routes/cable-sizing.routes.ts
Normal file
19
src/server/routes/cable-sizing.routes.ts
Normal 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
|
||||||
|
);
|
||||||
152
tests/cable-sizing-calculation.repository.test.ts
Normal file
152
tests/cable-sizing-calculation.repository.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { CableSizingCalculationRepository } from "../src/db/repositories/cable-sizing-calculation.repository.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
|
|
||||||
|
function createRepository() {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
return new CableSizingCalculationRepository(context.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRepositoryWithCircuits(): {
|
||||||
|
repository: CableSizingCalculationRepository;
|
||||||
|
context: DatabaseContext;
|
||||||
|
circuitOneId: string;
|
||||||
|
circuitTwoId: string;
|
||||||
|
} {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
const board = new DistributionBoardFixtureRepository(context.db).createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const circuitList = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||||
|
.get();
|
||||||
|
if (!circuitList) {
|
||||||
|
throw new Error("fixture did not create a circuit list");
|
||||||
|
}
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, circuitList.id))
|
||||||
|
.get();
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("fixture did not create a section");
|
||||||
|
}
|
||||||
|
const circuitOneId = "circuit-1";
|
||||||
|
const circuitTwoId = "circuit-2";
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: circuitOneId,
|
||||||
|
circuitListId: section.circuitListId,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: circuitTwoId,
|
||||||
|
circuitListId: section.circuitListId,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1.2",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return {
|
||||||
|
repository: new CableSizingCalculationRepository(context.db),
|
||||||
|
context,
|
||||||
|
circuitOneId,
|
||||||
|
circuitTwoId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CableSizingCalculationRepository", () => {
|
||||||
|
it("creates an entry with input/result JSON and defaults appliedToCircuit to false", () => {
|
||||||
|
const repository = createRepository();
|
||||||
|
const entry = repository.create({
|
||||||
|
id: "calc-1",
|
||||||
|
projectId: null,
|
||||||
|
circuitId: null,
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
input: { layingMethod: "C" },
|
||||||
|
result: { recommendedCrossSectionMm2: 4 },
|
||||||
|
appliedToCircuit: 0,
|
||||||
|
});
|
||||||
|
assert.equal(entry.id, "calc-1");
|
||||||
|
assert.equal(entry.appliedToCircuit, 0);
|
||||||
|
assert.deepEqual(entry.input, { layingMethod: "C" });
|
||||||
|
assert.deepEqual(entry.result, { recommendedCrossSectionMm2: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists entries by circuitId, most recent first", () => {
|
||||||
|
// createdAt defaults to SQLite's unixepoch() (second resolution), so two
|
||||||
|
// inserts in the same test can tie - pass explicit, distinct timestamps
|
||||||
|
// instead of racing the clock. circuitId has a real foreign key into
|
||||||
|
// circuits (foreign_keys = ON), so this needs actual circuit rows, not
|
||||||
|
// arbitrary strings.
|
||||||
|
const { repository, circuitOneId, circuitTwoId } = createRepositoryWithCircuits();
|
||||||
|
repository.create({
|
||||||
|
id: "calc-a",
|
||||||
|
projectId: null,
|
||||||
|
circuitId: circuitOneId,
|
||||||
|
equipmentIdentifier: null,
|
||||||
|
input: {},
|
||||||
|
result: {},
|
||||||
|
appliedToCircuit: 0,
|
||||||
|
createdAt: new Date(1000),
|
||||||
|
});
|
||||||
|
repository.create({
|
||||||
|
id: "calc-b",
|
||||||
|
projectId: null,
|
||||||
|
circuitId: circuitOneId,
|
||||||
|
equipmentIdentifier: null,
|
||||||
|
input: {},
|
||||||
|
result: {},
|
||||||
|
appliedToCircuit: 0,
|
||||||
|
createdAt: new Date(2000),
|
||||||
|
});
|
||||||
|
repository.create({
|
||||||
|
id: "calc-other-circuit",
|
||||||
|
projectId: null,
|
||||||
|
circuitId: circuitTwoId,
|
||||||
|
equipmentIdentifier: null,
|
||||||
|
input: {},
|
||||||
|
result: {},
|
||||||
|
appliedToCircuit: 0,
|
||||||
|
createdAt: new Date(3000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = repository.listByCircuit(circuitOneId);
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.equal(rows[0].id, "calc-b");
|
||||||
|
assert.equal(rows[1].id, "calc-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an entry as applied", () => {
|
||||||
|
const repository = createRepository();
|
||||||
|
repository.create({
|
||||||
|
id: "calc-1",
|
||||||
|
projectId: null,
|
||||||
|
circuitId: null,
|
||||||
|
equipmentIdentifier: null,
|
||||||
|
input: {},
|
||||||
|
result: {},
|
||||||
|
appliedToCircuit: 0,
|
||||||
|
});
|
||||||
|
const updated = repository.markApplied("calc-1");
|
||||||
|
assert.equal(updated?.appliedToCircuit, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
126
tests/cable-sizing-calculation.test.ts
Normal file
126
tests/cable-sizing-calculation.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildCableSizingAlerts,
|
||||||
|
calculateCableSizing,
|
||||||
|
isCableSizingDataVerified,
|
||||||
|
type CableSizingInput,
|
||||||
|
} from "../src/cable-sizing/domain/cable-sizing-calculation.js";
|
||||||
|
|
||||||
|
const BASE_INPUT: CableSizingInput = {
|
||||||
|
phase: 1,
|
||||||
|
mode: "power",
|
||||||
|
powerKw: 5,
|
||||||
|
cosPhi: 1,
|
||||||
|
voltage: 230,
|
||||||
|
lengthM: 30,
|
||||||
|
layingMethod: "C",
|
||||||
|
conductorMaterial: "copper",
|
||||||
|
insulation: "pvc",
|
||||||
|
ambientTemperatureC: 30,
|
||||||
|
groupingCircuits: 1,
|
||||||
|
maxVoltageDropPercent: 3,
|
||||||
|
harmonicNeutralLoad: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("calculateCableSizing", () => {
|
||||||
|
it("matches the verified reference case (1~, 5 kW, 230 V, 30 m, method C, copper)", () => {
|
||||||
|
const result = calculateCableSizing(BASE_INPUT);
|
||||||
|
assert.equal(result.dataVerified, true);
|
||||||
|
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
||||||
|
assert.equal(result.crossSectionByCapacityMm2, 2.5);
|
||||||
|
assert.equal(result.crossSectionByVoltageDropMm2, 4);
|
||||||
|
assert.equal(result.recommendedCrossSectionMm2, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns dataVerified: false and no numeric result for an unverified laying method", () => {
|
||||||
|
const result = calculateCableSizing({ ...BASE_INPUT, layingMethod: "A2" });
|
||||||
|
assert.equal(result.dataVerified, false);
|
||||||
|
assert.equal(result.recommendedCrossSectionMm2, null);
|
||||||
|
assert.equal(result.rows.length, 0);
|
||||||
|
// The operating current itself does not depend on the capacity table
|
||||||
|
// and is still reported so the UI can show at least that much.
|
||||||
|
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns dataVerified: false for xlpe regardless of method", () => {
|
||||||
|
const result = calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe" });
|
||||||
|
assert.equal(result.dataVerified, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isCableSizingDataVerified matches the six ported, verified methods only", () => {
|
||||||
|
for (const method of ["A1", "B2", "C", "E", "D1", "D2"] as const) {
|
||||||
|
assert.equal(isCableSizingDataVerified(method, "pvc"), true, method);
|
||||||
|
}
|
||||||
|
for (const method of ["A2", "B1", "F", "G"] as const) {
|
||||||
|
assert.equal(isCableSizingDataVerified(method, "pvc"), false, method);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the 0.86 harmonic reduction factor only for three-phase + 15to33Percent", () => {
|
||||||
|
const threePhase: CableSizingInput = {
|
||||||
|
...BASE_INPUT,
|
||||||
|
phase: 3,
|
||||||
|
mode: "current",
|
||||||
|
currentA: 10,
|
||||||
|
powerKw: undefined,
|
||||||
|
voltage: 400,
|
||||||
|
};
|
||||||
|
const base = calculateCableSizing({ ...threePhase, harmonicNeutralLoad: "none" });
|
||||||
|
const derated = calculateCableSizing({
|
||||||
|
...threePhase,
|
||||||
|
harmonicNeutralLoad: "15to33Percent",
|
||||||
|
});
|
||||||
|
assert.equal(base.harmonicReductionApplied, false);
|
||||||
|
assert.equal(derated.harmonicReductionApplied, true);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(derated.combinedDerationFactor - base.combinedDerationFactor * 0.86) < 1e-9
|
||||||
|
);
|
||||||
|
|
||||||
|
const singlePhaseWithHarmonics = calculateCableSizing({
|
||||||
|
...BASE_INPUT,
|
||||||
|
harmonicNeutralLoad: "15to33Percent",
|
||||||
|
});
|
||||||
|
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a simplified protection coordination mismatch", () => {
|
||||||
|
const result = calculateCableSizing({
|
||||||
|
...BASE_INPUT,
|
||||||
|
existingProtectionRatedCurrentA: 1000,
|
||||||
|
});
|
||||||
|
assert.ok(result.protectionCoordination);
|
||||||
|
assert.equal(result.protectionCoordination?.coordinated, false);
|
||||||
|
|
||||||
|
const alerts = buildCableSizingAlerts(
|
||||||
|
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert.ok(alerts.some((alert) => alert.text.includes("Koordination prüfen")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCableSizingAlerts", () => {
|
||||||
|
it("returns a single critical alert for an unverified combination, no numeric claims", () => {
|
||||||
|
const input: CableSizingInput = { ...BASE_INPUT, layingMethod: "G" };
|
||||||
|
const result = calculateCableSizing(input);
|
||||||
|
const alerts = buildCableSizingAlerts(input, result);
|
||||||
|
assert.equal(alerts.length, 1);
|
||||||
|
assert.equal(alerts[0].kind, "critical");
|
||||||
|
assert.ok(alerts[0].text.includes("keine geprüften"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a single ok alert for a clean, unremarkable case", () => {
|
||||||
|
const input: CableSizingInput = {
|
||||||
|
...BASE_INPUT,
|
||||||
|
mode: "current",
|
||||||
|
currentA: 15,
|
||||||
|
powerKw: undefined,
|
||||||
|
lengthM: 3,
|
||||||
|
};
|
||||||
|
const result = calculateCableSizing(input);
|
||||||
|
const alerts = buildCableSizingAlerts(input, result);
|
||||||
|
assert.equal(alerts.length, 1);
|
||||||
|
assert.equal(alerts[0].kind, "ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue