diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md new file mode 100644 index 0000000..e7170c6 --- /dev/null +++ b/docs/cable-sizing-module.md @@ -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 `` alongside the + existing ``. 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. diff --git a/src/cable-sizing/domain/cable-sizing-calculation.ts b/src/cable-sizing/domain/cable-sizing-calculation.ts new file mode 100644 index 0000000..6dc681a --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-calculation.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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> +> = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { copper: 48, aluminum: 30 }; + +export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const; + +export const GROUPING_LABELS: Record = { + 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; +} diff --git a/src/cable-sizing/domain/cable-sizing-contracts.ts b/src/cable-sizing/domain/cable-sizing-contracts.ts new file mode 100644 index 0000000..6cafe24 --- /dev/null +++ b/src/cable-sizing/domain/cable-sizing-contracts.ts @@ -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; + +// Structural check that the request schema stays a superset of the domain +// input shape (minus context, which is API-only metadata). +type _AssertAssignable = T; +type _Check = _AssertAssignable>; diff --git a/src/db/migrations/0007_watery_kingpin.sql b/src/db/migrations/0007_watery_kingpin.sql new file mode 100644 index 0000000..a4f22bf --- /dev/null +++ b/src/db/migrations/0007_watery_kingpin.sql @@ -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`); \ No newline at end of file diff --git a/src/db/migrations/meta/0007_snapshot.json b/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..8a67832 --- /dev/null +++ b/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,2522 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "98546058-8ad8-4aa1-99ad-f4e915bea89c", + "prevId": "b25e1a40-c400-4848-bc24-a04bda7a8209", + "tables": { + "cable_sizing_calculations": { + "name": "cable_sizing_calculations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_to_circuit": { + "name": "applied_to_circuit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "cable_sizing_calculations_circuit_id_idx": { + "name": "cable_sizing_calculations_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cable_sizing_calculations_project_id_projects_id_fk": { + "name": "cable_sizing_calculations_project_id_projects_id_fk", + "tableFrom": "cable_sizing_calculations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cable_sizing_calculations_circuit_id_circuits_id_fk": { + "name": "cable_sizing_calculations_circuit_id_circuits_id_fk", + "tableFrom": "cable_sizing_calculations", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index eedfc7a..0b056a8 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786043080323, "tag": "0006_damp_skrulls", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1786114831610, + "tag": "0007_watery_kingpin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/cable-sizing-calculation.repository.ts b/src/db/repositories/cable-sizing-calculation.repository.ts new file mode 100644 index 0000000..803965b --- /dev/null +++ b/src/db/repositories/cable-sizing-calculation.repository.ts @@ -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(); + } +} diff --git a/src/db/schema/cable-sizing-calculations.ts b/src/db/schema/cable-sizing-calculations.ts new file mode 100644 index 0000000..511fab3 --- /dev/null +++ b/src/db/schema/cable-sizing-calculations.ts @@ -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; diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts new file mode 100644 index 0000000..ee52aef --- /dev/null +++ b/src/frontend/components/cable-sizing-api.ts @@ -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(url: string, init?: RequestInit): Promise { + 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; +} + +export interface CalculateCableSizingResponse { + calculationId: string; + result: CableSizingResult; + alerts: CableSizingAlert[]; +} + +export function calculateCableSizing( + input: CableSizingInput, + context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string } +): Promise { + return request("/api/cable-sizing/calculate", { + method: "POST", + body: JSON.stringify({ ...input, context }), + }); +} + +export function markCableSizingCalculationApplied(calculationId: string): Promise { + return request(`/api/cable-sizing/calculations/${calculationId}/applied`, { + method: "POST", + }); +} + +export interface LayingMethodOption { + method: LayingMethod; + label: string; + dataVerified: boolean; +} + +export function listLayingMethods(): Promise { + return request("/api/cable-sizing/laying-methods"); +} + +export interface InsulationMaterialOption { + insulation: InsulationMaterial; + label: string; +} + +export function listInsulationMaterials(): Promise { + return request("/api/cable-sizing/insulation-materials"); +} diff --git a/src/frontend/components/cable-sizing-modal.tsx b/src/frontend/components/cable-sizing-modal.tsx new file mode 100644 index 0000000..57b9a3a --- /dev/null +++ b/src/frontend/components/cable-sizing-modal.tsx @@ -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; +} + +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("C"); + const [insulation, setInsulation] = useState("pvc"); + const [conductorMaterial, setConductorMaterial] = useState("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("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(null); + const [result, setResult] = useState(null); + const [alerts, setAlerts] = useState([]); + const [error, setError] = useState(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) { + event.preventDefault(); + if (!result?.recommendedCrossSectionMm2) return; + await onApply({ + cableCrossSection: `${result.recommendedCrossSectionMm2} mm²`, + cableLength: lengthM, + }); + } + + return ( + +
+
+ + setLengthM(Number(event.target.value))} + type="number" + value={lengthM} + /> +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {phase === 3 && ( +
+ + +
+ )} +
+ + setCosPhi(Number(event.target.value))} + step={0.05} + type="range" + value={cosPhi} + /> + {cosPhi.toFixed(2)} +
+
+ + setMaxVoltageDropPercent(Number(event.target.value))} + step={0.5} + type="range" + value={maxVoltageDropPercent} + /> + {maxVoltageDropPercent.toFixed(1)} % +
+
+ + + {error &&

{error}

} + + {result && !result.dataVerified && ( +
+ 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. +
+ )} + + {result && result.dataVerified && ( +
+
+
+
Betriebsstrom Ib
+
{result.operatingCurrentA.toFixed(1)} A
+
+
+
Empfohlener Querschnitt
+
+ + {result.recommendedCrossSectionMm2 + ? `${result.recommendedCrossSectionMm2} mm²` + : "-"} + +
+
+
+
ΔU bei Empfehlung
+
+ {result.voltageDropAtRecommendedPercent != null + ? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %` + : "-"} +
+
+
+
Korrekturfaktor
+
{result.combinedDerationFactor.toFixed(2)}
+
+
+ {alerts.map((alert, index) => ( +
+ {alert.text} +
+ ))} +
+ )} + {calculationId ? null : ( +

+ Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch + geschrieben. +

+ )} +
+ ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..49200cf 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); + const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); 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 ? ( + setCableSizingEditorCircuit(null)} + onApply={handleApplyCableSizing} + /> + ) : null}