From 1d030971dd3958a9fd1a7e083e7e7b2284ae23df Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Fri, 7 Aug 2026 17:13:59 +0200 Subject: [PATCH 01/14] Add proposed cable-sizing module Isolated module (mirrors src/external-model/ dependency direction) that adds an on-demand, DIN VDE 0298-4 referenced cable cross-section calculator: a click-on-a-circuit input mask for laying method, insulation, ambient temperature, grouping and voltage-drop limit that suggests a cross-section for the circuit cableCrossSection/cableLength fields. Applying a suggestion goes through the existing circuit.update command (expectedRevision, undo/redo) unchanged - nothing here writes to circuits directly or touches the revision/command system. See docs/cable-sizing-module.md for the full rationale, API contract, verification status and maintenance plan. Proposal, not yet reviewed by the project owner - see the docs file. 423 existing + 11 new tests pass, build:api/build:web/typecheck:scripts clean. --- docs/cable-sizing-module.md | 162 ++ .../domain/cable-sizing-calculation.ts | 451 +++ .../domain/cable-sizing-contracts.ts | 48 + src/db/migrations/0007_watery_kingpin.sql | 14 + src/db/migrations/meta/0007_snapshot.json | 2522 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + .../cable-sizing-calculation.repository.ts | 37 + src/db/schema/cable-sizing-calculations.ts | 30 + src/frontend/components/cable-sizing-api.ts | 71 + .../components/cable-sizing-modal.tsx | 407 +++ .../components/circuit-tree-editor.tsx | 51 +- .../composition/application-repositories.ts | 2 + .../controllers/cable-sizing.controller.ts | 80 + src/server/index.ts | 2 + src/server/routes/cable-sizing.routes.ts | 19 + ...able-sizing-calculation.repository.test.ts | 152 + tests/cable-sizing-calculation.test.ts | 126 + 17 files changed, 4179 insertions(+), 2 deletions(-) create mode 100644 docs/cable-sizing-module.md create mode 100644 src/cable-sizing/domain/cable-sizing-calculation.ts create mode 100644 src/cable-sizing/domain/cable-sizing-contracts.ts create mode 100644 src/db/migrations/0007_watery_kingpin.sql create mode 100644 src/db/migrations/meta/0007_snapshot.json create mode 100644 src/db/repositories/cable-sizing-calculation.repository.ts create mode 100644 src/db/schema/cable-sizing-calculations.ts create mode 100644 src/frontend/components/cable-sizing-api.ts create mode 100644 src/frontend/components/cable-sizing-modal.tsx create mode 100644 src/server/controllers/cable-sizing.controller.ts create mode 100644 src/server/routes/cable-sizing.routes.ts create mode 100644 tests/cable-sizing-calculation.repository.test.ts create mode 100644 tests/cable-sizing-calculation.test.ts 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}
); diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 7f86029..d113b3b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -3170,6 +3170,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {cableSizingEditorCircuit ? ( section.id === cableSizingEditorCircuit.sectionId + )?.category + } isSaving={isSaving} projectId={projectId} onClose={() => setCableSizingEditorCircuit(null)} diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts index eaad683..89a935d 100644 --- a/tests/cable-sizing-calculation.test.ts +++ b/tests/cable-sizing-calculation.test.ts @@ -134,7 +134,7 @@ describe("buildCableSizingAlerts", () => { assert.ok(alerts[0].text.includes("keine geprüften")); }); - it("returns a single ok alert for a clean, unremarkable case", () => { + it("returns an ok alert plus a max-length info alert for a clean, unremarkable case", () => { const input: CableSizingInput = { ...BASE_INPUT, mode: "current", @@ -144,7 +144,66 @@ describe("buildCableSizingAlerts", () => { }; const result = calculateCableSizing(input); const alerts = buildCableSizingAlerts(input, result); - assert.equal(alerts.length, 1); + assert.equal(alerts.length, 2); assert.equal(alerts[0].kind, "ok"); + assert.equal(alerts[1].kind, "info"); + assert.ok(alerts[1].text.includes("Maximale Länge")); + }); +}); + +describe("maxLengthForVoltageDropM", () => { + it("is the inverse of the voltage-drop formula: recalculating at that length gives back the limit", () => { + const result = calculateCableSizing(BASE_INPUT); + const recommendedRow = result.rows.find((row) => row.recommended); + assert.ok(recommendedRow?.maxLengthForVoltageDropM != null); + + const atMaxLength = calculateCableSizing({ + ...BASE_INPUT, + lengthM: recommendedRow!.maxLengthForVoltageDropM!, + }); + const rowAtSameCrossSection = atMaxLength.rows.find( + (row) => row.crossSectionMm2 === recommendedRow!.crossSectionMm2 + ); + assert.ok( + Math.abs(rowAtSameCrossSection!.voltageDropPercent! - BASE_INPUT.maxVoltageDropPercent) < + 0.01 + ); + }); + + it("is null when there is no current flowing (division by zero guard)", () => { + const result = calculateCableSizing({ ...BASE_INPUT, mode: "current", currentA: 0, powerKw: undefined }); + assert.ok(result.rows.every((row) => row.maxLengthForVoltageDropM === null)); + }); +}); + +describe("practical minimum cross-section for single_phase circuits", () => { + it("raises a smaller calculated recommendation to 2.5 mm² for single_phase circuits", () => { + // 1 A load at 30 m would normally recommend 1.5 mm² by calculation alone. + const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined }; + const withoutCategory = calculateCableSizing(smallLoad); + const withCategory = calculateCableSizing({ ...smallLoad, circuitCategory: "single_phase" }); + + assert.equal(withoutCategory.recommendedCrossSectionMm2, 1.5); + assert.equal(withoutCategory.practicalMinimumApplied, false); + assert.equal(withCategory.recommendedCrossSectionMm2, 2.5); + assert.equal(withCategory.practicalMinimumApplied, true); + }); + + it("never lowers a recommendation that already needs more than the practical minimum", () => { + const result = calculateCableSizing({ ...BASE_INPUT, circuitCategory: "single_phase" }); + assert.equal(result.recommendedCrossSectionMm2, 4); + assert.equal(result.practicalMinimumApplied, false); + }); + + it("does not apply to lighting or three_phase categories", () => { + const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined }; + assert.equal( + calculateCableSizing({ ...smallLoad, circuitCategory: "lighting" }).recommendedCrossSectionMm2, + 1.5 + ); + assert.equal( + calculateCableSizing({ ...smallLoad, circuitCategory: "three_phase" }).recommendedCrossSectionMm2, + 1.5 + ); }); }); From 52cc8b20e090a01dc5ede0a482bc069f2b564d97 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Fri, 7 Aug 2026 18:16:34 +0200 Subject: [PATCH 10/14] docs: cover breaker-aware sizing, max length, socket minimum, manual entry --- docs/cable-sizing-module.md | 83 +++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md index 26e8c47..80b7cc8 100644 --- a/docs/cable-sizing-module.md +++ b/docs/cable-sizing-module.md @@ -76,14 +76,50 @@ cell, nothing crashes, no data is affected, and the rest of the app (including manual cable-field editing) is completely unaffected - the module's actual logic and persistence never depend on this column key. -## How a suggestion is applied +## Feature overview -The module never writes to `circuits` directly. The modal's "Empfehlung -übernehmen" button calls the **existing, unmodified** frontend helper: +- **Calculate a recommendation**: laying method, insulation, conductor + material, ambient temperature, grouping, cos phi and max voltage drop - + same VDE 0298-4 reference-method model as the sibling Kabelliste tool + (see Verification status below). +- **Breaker-aware sizing**: if the circuit already has a protection device + (`circuit.protectionDevice`), the cross-section is selected against + `designCurrentA = max(operatingCurrentA, protectionDevice.ratedCurrentA)`, + not the load current alone - the standard `In <= Iz` rule (a breaker only + trips at its own rated current, so a cable sized for the actual load + alone could overheat under sustained current below that threshold). If no + cross-section can cover an oversized breaker, that is reported as the + limiting factor by name, not a generic capacity error. +- **Maximum length for the voltage-drop limit**: each cross-section option + also reports the longest single run that still meets the requested + `maxVoltageDropPercent` at the given load - the inverse of the + voltage-drop check, shown for the recommended cross-section. +- **Practical minimum for socket circuits**: `single_phase`-category + circuits are raised to at least 2.5 mm² if the calculation alone would + recommend less - see the dedicated note under Verification status. Never + lowers a calculation that already needs more. +- **Manual entry, always available**: the modal has a plain text Kabeltyp/ + Querschnitt field. Running a calculation pre-fills it as a suggestion, + but the field is the single source of truth for what gets applied, and + stays freely editable - satisfying + `docs/spec/06-future-sizing-and-calculations.md`'s explicit requirement + that "users must remain able to manually override suggestions" without + requiring a calculation to have been run first. A non-blocking warning + appears if the typed value is not a standard cross-section, is smaller + than the last calculation, or is below the practical minimum above - + informational only, never forced, matching the same spec doc's "shown as + a warning or status indicator, not as an automatic forced change." + +## How a suggestion or manual entry is applied + +The module never writes to `circuits` directly. The modal's "Übernehmen" +button calls the **existing, unmodified** frontend helper with whatever is +currently in the Kabeltyp/Querschnitt fields, calculated or hand-typed: ```ts updateCircuitById(projectId, expectedRevision, circuitId, { cableCrossSection: "4 mm²", + cableType: "NYM-J 3x2.5", cableLength: 30, }); ``` @@ -94,10 +130,8 @@ 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" +- nothing is ever written automatically - calculating only fills the modal's + own fields, applying is always an explicit, separate click ## API contract @@ -119,6 +153,8 @@ cell edits. This means: "maxVoltageDropPercent": 3, "harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only "existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice + "circuitCategory": "single_phase", // optional, from the circuit's section.category - + // enables the practical-minimum convention below "context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" } } ``` @@ -149,6 +185,15 @@ 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. +`PRACTICAL_MINIMUM_CROSS_SECTION_MM2` (2.5 mm² for `single_phase`) is +**not** a verified norm value at all - it is a named planning convention, +sourced directly from this project's own +`docs/spec/06-future-sizing-and-calculations.md` ("Standard Single-Phase +Circuits ... usually use ... cable cross-section: 2.5 mm²"), applied as a +floor on top of the calculated recommendation. It intentionally only +covers the one category and one convention that document already states; +it is not a general substitute for norm-compliant calculation. + ## Relationship to the project's own future-sizing spec `docs/spec/06-future-sizing-and-calculations.md` separately describes simple @@ -177,8 +222,22 @@ 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. +Deployed and manually exercised end-to-end on an internal test host at +`http://192.168.0.133:3220` (own docker deployment, own subnet/ports to +avoid clashing with ~50 other containers on that host - see the two +commits marked "Local-only" in this branch, which are not part of this +proposal and should not be carried over if it is ever proposed upstream). +Exercised against seeded real project/circuit data created through the +actual command API (not direct DB writes), including a deliberately long +(85 m) circuit to trigger the voltage-drop-critical path. + +`npm test`, `npm run build:api`, `npm run build:web` and +`npm run typecheck:scripts` all pass with this module included at every +commit in this branch. + +This branch (`feature/cable-sizing-module`) is pushed to a mirror of this +repository under this homelab's own Forgejo instance, not to +`git.jappel.io` - no write access to the upstream repository was available +or used. See the branch's commit history for the incremental history of +this module, including fixes made during manual testing (trigger column, +icon rendering, breaker-aware sizing). From 9504905c8f1d7b34dce2e70fc19a90955f502e4f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 23 Aug 2026 18:05:50 +0200 Subject: [PATCH 11/14] Upgrade project to Node 24 Node 22 leaves active LTS at the end of October. Move the Docker base image and the documented local requirement to Node 24. Pull @types/node from ^25 down to ^24 so the type definitions no longer describe a newer runtime than the one in use. Types ahead of the runtime let TypeScript accept APIs that are not there at execution time; the opposite direction is harmless. Add an engines field to record the supported version. No .nvmrc, since the project is not used with a Node version manager. Co-Authored-By: Claude Opus 5 --- Dockerfile | 2 +- README.md | 2 +- package-lock.json | 23 ++++++++++++++--------- package.json | 5 ++++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41a08fd..5484443 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22 +FROM node:24 WORKDIR /app COPY package*.json ./ diff --git a/README.md b/README.md index c333917..d7a7b09 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Produktionsdeployment. Details stehen in Voraussetzungen: -- Node.js 22 +- Node.js 24 - npm ```powershell diff --git a/package-lock.json b/package-lock.json index b0abb41..417421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" + }, + "engines": { + "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1514,12 +1517,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -3688,10 +3692,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 9bc5ae3..d5ebe82 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -40,7 +43,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", From c6cdfc42d5c347a529dff46ab173baa3e07daa07 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 23 Aug 2026 18:05:50 +0200 Subject: [PATCH 12/14] Keep manual quantity consistent when synchronizing device rows Synchronizing a project device pushes its quantity onto every linked device row, but manualQuantity is not part of the sync snapshot and was left untouched. A device whose quantity is lower than the row's left the row at manualQuantity > quantity, violating the snapshot invariant. The violation surfaced far from its cause: the invariant is only checked when a full state snapshot is read, and the automatic snapshot runs every 25 revisions. A project could therefore accumulate the broken row silently and then reject every subsequent command, because the failing validation rolls back the whole transaction including the revision bump. Recompute manualQuantity as the synchronized quantity minus the total of the linked external objects, matching the invariant that assertCircuitDeviceRowQuantity enforces elsewhere, and reject a synchronized quantity that falls below that total. Co-Authored-By: Claude Opus 5 --- ...ice-row-sync-project-command.repository.ts | 26 ++- ...ow-sync-project-command.repository.test.ts | 183 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index da17458..eb722d7 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { + const values: typeof assignment.target & { + manualQuantity?: number; + } = { ...assignment.target }; + if (assignment.target.quantity !== assignment.expected.quantity) { + const externalTotal = tx + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where( + eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) + ) + .all() + .reduce( + (sum, object) => sum + object.planningValues.effectiveQuantity, + 0 + ); + const manualQuantity = assignment.target.quantity - externalTotal; + if (manualQuantity < 0) { + throw new Error( + "Synchronized quantity is below the total quantity of linked external objects." + ); + } + values.manualQuantity = manualQuantity; + } const updated = tx .update(circuitDeviceRows) - .set(assignment.target) + .set(values) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts index 75d56a8..593a7f6 100644 --- a/tests/project-device-row-sync-project-command.repository.test.ts +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, @@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) { return row; } +function linkExternalObject( + context: DatabaseContext, + rowId: string, + effectiveQuantity: number +) { + context.db + .insert(externalModelSources) + .values({ + id: "source-1", + projectId: "project-1", + name: "Revit", + sourceType: "revit_csv", + }) + .run(); + context.db + .insert(externalImportBatches) + .values({ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T16:00:00.000Z", + fileName: "revit.csv", + sha256: "a".repeat(64), + appliedProjectRevision: 0, + configurationVersion: 1, + configurationSnapshot: externalCsvTestConfiguration, + originalBytes: Buffer.from("test"), + document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + }) + .run(); + context.db + .insert(externalModelObjects) + .values({ + id: "object-1", + projectId: "project-1", + sourceId: "source-1", + ifcGuid: "ifc-1", + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 2, + roomNumber: "101", + roomName: "Büro", + familyAndType: "Leuchte: Standard", + selectionMarker: "Leuchte", + circuitIdentifier: "-1F1", + power: "30", + quantity: String(effectiveQuantity), + additionalSourceValues: {}, + }, + planningValues: { + displayName: "Leuchte", + internalDeviceType: "luminaire", + category: "single_phase", + connectionKind: "fixed", + effectiveQuantity, + powerPerUnitW: 30, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + overriddenFields: [], + externalRoomMappingId: null, + distributionBoardId: null, + linkedProjectDeviceId: null, + circuitDeviceRowId: rowId, + presenceStatus: "present", + }) + .run(); +} + function snapshot( context: DatabaseContext, rowId: string @@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => { } }); + it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 5 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [ + { + rowId: "row-1", + expected, + target: { ...expected, quantity: 2 }, + }, + ] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 2); + assert.equal(row.manualQuantity, 2); + } finally { + fixture.context.close(); + } + }); + + it("subtracts linked external objects when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 4); + assert.equal(row.manualQuantity, 1); + } finally { + fixture.context.close(); + } + }); + + it("rejects a synced quantity below the linked external total", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }] + ), + }), + /below the total quantity of linked external objects/ + ); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 5); + assert.equal(row.manualQuantity, 2); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + it("rolls back synchronized rows for a stale project revision", () => { const fixture = createTestDatabase(); try { From 906aa751c716a93b317a374456586079fd5ae9b0 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 23 Aug 2026 18:25:06 +0200 Subject: [PATCH 13/14] Run the Docker stack on built output instead of dev servers compose.yaml overrode the image's own entrypoint to start "tsx watch" and "next dev" with CHOKIDAR_USEPOLLING and WATCHPACK_POLLING enabled. The built dist/ and .next from the image were never used, and the polling watchers kept both containers busy around the clock whether or not anyone used the application. Split the two use cases: compose.yaml now runs node dist/server/index.js and next start with no source mounts and no watchers, and the watching setup moves to compose.dev.yaml. next build writes the rewrite destinations from next.config.mjs into .next/routes-manifest.json, so next start cannot pick up API_INTERNAL_URL at runtime the way next dev does. Pass it as a build argument. Give the web container its own probe. The healthcheck hit "/", which redirects to "/projects", and fetch follows redirects, so every probe rendered two pages every five seconds. "/health" could not serve as the probe because next.config.mjs rewrites it to the API. Add "/web-health" as a route handler, exclude it from the proxy matcher, and relax both intervals to 30s. Co-Authored-By: Claude Opus 5 --- Dockerfile | 7 +++ README.md | 18 +++++++- compose.dev.yaml | 87 +++++++++++++++++++++++++++++++++++++ compose.yaml | 65 ++++++++++++--------------- docs/deployment.md | 39 ++++++++++++----- package.json | 3 ++ src/app/web-health/route.ts | 10 +++++ src/proxy.ts | 9 ++-- 8 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 compose.dev.yaml create mode 100644 src/app/web-health/route.ts diff --git a/Dockerfile b/Dockerfile index 5484443..31d28c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,13 @@ COPY package*.json ./ RUN npm ci COPY . . + +# next build writes the rewrite destinations from next.config.mjs into +# .next/routes-manifest.json, so "next start" cannot pick up a different +# API URL later. The value has to be known here, not just at runtime. +ARG API_INTERNAL_URL=http://localhost:3000 +ENV API_INTERNAL_URL=$API_INTERNAL_URL + RUN npm run build:api && npm run build:web RUN mkdir -p data && chmod +x scripts/docker-start.sh diff --git a/README.md b/README.md index d7a7b09..fc82fca 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,24 @@ docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in [Deployment und Betrieb](docs/deployment.md). +Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit +Quellcode-Mounts und Watchern: + +```powershell +docker compose -f compose.dev.yaml up --build --detach +docker compose -f compose.dev.yaml logs --follow +docker compose -f compose.dev.yaml down +``` + +Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und +macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn +niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen +Server. + ## Direkte lokale Entwicklung Voraussetzungen: diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..86e42e2 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,87 @@ +# Development stack: source mounts, watching dev servers, hot reload. +# docker compose -f compose.dev.yaml up --build +# +# The polling watchers below are needed for bind mounts on Windows and +# macOS, where inotify events do not cross the VM boundary. They cost +# continuous CPU, which is why the production stack in compose.yaml does +# not run watchers at all. +name: leistungsbilanz-dev + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + +services: + api: + build: + context: . + command: + - sh + - -c + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + environment: + PORT: "3000" + CHOKIDAR_USEPOLLING: "true" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + ports: + - "3000:3000" + volumes: + - ./src:/app/src + - ./scripts:/app/scripts + - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s + + web: + build: + context: . + command: + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 + environment: + API_INTERNAL_URL: http://api:3000 + WATCHPACK_POLLING: "true" + NEXT_TELEMETRY_DISABLED: "1" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + depends_on: + api: + condition: service_healthy + ports: + - "3001:3001" + volumes: + - ./src:/app/src + - ./next.config.mjs:/app/next.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.next.json:/app/tsconfig.next.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s diff --git a/compose.yaml b/compose.yaml index 6a5ec7c..503a4b2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,82 +1,73 @@ name: leistungsbilanz +x-build: &build + context: . + args: + # Baked into .next/routes-manifest.json by next build; see Dockerfile. + API_INTERNAL_URL: http://api:3000 + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + services: api: - build: - context: . + build: *build command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" - CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging ports: - "3000:3000" volumes: - - ./src:/app/src - - ./scripts:/app/scripts - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s web: - build: - context: . + build: *build command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 + - node_modules/.bin/next + - start + - -p + - "3001" environment: + NODE_ENV: production API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging depends_on: api: condition: service_healthy ports: - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md index dfbb3f1..06289a7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,19 +2,27 @@ ## Aktueller Status -Es gibt derzeit kein unterstütztes Produktionsdeployment. +Es gibt zwei Compose-Stacks. -`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet -`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, -Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes -Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder -öffentlich erreichbar gemacht werden. +`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und +`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack +für einen Server. -## Entwicklungs-Topologie +`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom +Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows +und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch +ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen +Entwicklungsrechner. + +Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch +ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich +erreichbar gemacht werden. + +## Topologie | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /` | keine | +| Next.js Web | 3001 | `GET /web-health` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -24,15 +32,22 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - Dateibeobachtung in Docker +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst `npm run db:migrate` und -`npm run db:verify:circuit-schema`. +Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung +(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im +Entwicklungsstack über `npm run db:migrate` und +`npm run db:verify:circuit-schema`). + +`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` +schreibt die Rewrite-Ziele aus `next.config.mjs` fest in +`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als +Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. ## Logging diff --git a/package.json b/package.json index d5ebe82..414eb53 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", + "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", + "docker:dev:down": "docker compose -f compose.dev.yaml down", + "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +// Liveness probe for the web container itself. The "/health" path is +// rewritten to the API in next.config.mjs, so it cannot answer for this +// process. Kept as a route handler so a probe does not render a page. +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 4607ec0..3f4b83a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger"; const logger = createLogger("web:navigation"); export function proxy(request: NextRequest) { - // The Docker healthcheck hits "/" every few seconds with no User-Agent - // header; skip it so real navigation isn't drowned out in the logs. + // Probes hit "/web-health" and are excluded by the matcher below. The + // User-Agent guard stays as a fallback for anything else that polls + // without one, so real navigation isn't drowned out in the logs. if (request.headers.get("user-agent")) { logger.info("page request", { method: request.method, @@ -17,5 +18,7 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|api|web-health).*)", + ], }; From 204499d7e4010533b4edae304863a738e1d4e953 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 23 Aug 2026 23:13:35 +0200 Subject: [PATCH 14/14] Add Revit Dynamo diagnostic scripts --- .gitignore | 1 + dynamo/01_check_model_identity.py | 385 +++++++++++++ ..._electrical_fixture_parameter_inventory.py | 517 ++++++++++++++++++ dynamo/README.md | 69 +++ 4 files changed, 972 insertions(+) create mode 100644 dynamo/01_check_model_identity.py create mode 100644 dynamo/02_export_electrical_fixture_parameter_inventory.py create mode 100644 dynamo/README.md diff --git a/.gitignore b/.gitignore index 8d44fb4..ed5ffe6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ data/*.db data/backups/*.db .codex/*.log +dynamo/output/ diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py new file mode 100644 index 0000000..a3fe009 --- /dev/null +++ b/dynamo/01_check_model_identity.py @@ -0,0 +1,385 @@ +"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. + +Optional Dynamo input: + IN[0]: output directory or complete .json file path + +The script intentionally performs no Revit transaction and changes no model +data. OUT contains a compact summary plus the complete report. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = { + "hasValue": False, + "raw": None, + "display": None, + } + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + data_type = None + group_type = None + if definition is not None: + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def sorted_parameters(element): + parameters = [] + try: + parameters = [describe_parameter(parameter) for parameter in element.Parameters] + except Exception: + return [] + return sorted( + parameters, + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ), + ) + + +def named_parameter_occurrences(element, parameter_name): + result = [] + try: + parameters = element.GetParameters(parameter_name) + if parameters is not None: + result = [describe_parameter(parameter) for parameter in parameters] + except Exception: + parameter = None + try: + parameter = element.LookupParameter(parameter_name) + except Exception: + pass + if parameter is not None: + result = [describe_parameter(parameter)] + return result + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def get_cloud_identity(document): + result = {"isModelInCloud": False} + try: + result["isModelInCloud"] = bool(document.IsModelInCloud) + except Exception: + return result + if not result["isModelInCloud"]: + return result + + try: + model_path = document.GetCloudModelPath() + result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + for property_name, output_name in ( + ("GetProjectGUID", "projectGuid"), + ("GetModelGUID", "modelGuid"), + ): + try: + result[output_name] = str(getattr(model_path, property_name)()) + except Exception: + pass + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def get_worksharing_identity(document): + result = {"isWorkshared": False} + try: + result["isWorkshared"] = bool(document.IsWorkshared) + except Exception: + return result + if not result["isWorkshared"]: + return result + + try: + model_path = document.GetWorksharingCentralModelPath() + result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def resolve_output_path(configured_path, report_name): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + + project_information = document.ProjectInformation + if project_information is None: + raise RuntimeError("The active document has no Project Information element.") + + application = document.Application + checked_parameters = { + name: named_parameter_occurrences(project_information, name) + for name in CHECKED_PARAMETER_NAMES + } + warnings = [] + for name in CHECKED_PARAMETER_NAMES: + occurrences = checked_parameters[name] + populated = [ + parameter + for parameter in occurrences + if parameter.get("value", {}).get("raw") not in (None, "") + ] + if not occurrences: + warnings.append(name + " is not bound to Project Information.") + elif not populated: + warnings.append(name + " exists but has no value on Project Information.") + elif len(occurrences) > 1: + warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "isFamilyDocument": bool(document.IsFamilyDocument), + "cloud": get_cloud_identity(document), + "worksharing": get_worksharing_identity(document), + }, + "modelIdentityCandidates": { + "projectInformationUniqueId": safe_text(project_information.UniqueId), + "projectInformationElementId": element_id_text(project_information.Id), + "checkedProjectParameters": checked_parameters, + }, + "projectInformationParameters": sorted_parameters(project_information), + "warnings": warnings, + } + + +try: + report = build_report() + output_path = resolve_output_path(get_input(0), "model-identity") + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "projectInformationUniqueId": report["modelIdentityCandidates"][ + "projectInformationUniqueId" + ], + "warnings": report["warnings"], + "report": report, + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py new file mode 100644 index 0000000..440a222 --- /dev/null +++ b/dynamo/02_export_electrical_fixture_parameter_inventory.py @@ -0,0 +1,517 @@ +"""Export all Electrical Fixtures instance/type parameters from Revit 2026. + +Optional Dynamo inputs: + IN[0]: output directory or complete .json file path + IN[1]: include empty parameters (default True) + IN[2]: maximum aggregated sample values (default 5) + +The script is read-only and performs no Revit transaction. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ( # noqa: E402 + BuiltInCategory, + FilteredElementCollector, + ModelPathUtils, + StorageType, +) +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = {"hasValue": False, "raw": None, "display": None} + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter, scope): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + data_type = None + group_type = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "scope": scope, + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def has_meaningful_value(parameter_description): + value = parameter_description.get("value", {}) + return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( + "display" + ) not in (None, "") + + +def read_parameters(element, scope, include_empty): + result = [] + try: + for parameter in element.Parameters: + description = describe_parameter(parameter, scope) + if include_empty or has_meaningful_value(description): + result.append(description) + except Exception as error: + return [], [safe_text(error)] + result.sort( + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ) + ) + return result, [] + + +def read_space(element, document): + try: + space = element.Space + except Exception as error: + return None, safe_text(error) + if space is None: + return None, None + + level_name = None + try: + level = document.GetElement(space.LevelId) + level_name = None if level is None else safe_text(level.Name) + except Exception: + pass + + return { + "uniqueId": safe_text(space.UniqueId), + "elementId": element_id_text(space.Id), + "number": safe_text(getattr(space, "Number", None)), + "name": safe_text(getattr(space, "Name", None)), + "levelName": level_name, + }, None + + +def read_family_identity(element, document): + symbol = None + try: + symbol = element.Symbol + except Exception: + try: + symbol = document.GetElement(element.GetTypeId()) + except Exception: + pass + + family_name = None + type_name = None + type_unique_id = None + type_element_id = None + if symbol is not None: + try: + family_name = safe_text(symbol.Family.Name) + except Exception: + family_name = safe_text(getattr(symbol, "FamilyName", None)) + type_name = safe_text(getattr(symbol, "Name", None)) + type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) + type_element_id = element_id_text(getattr(symbol, "Id", None)) + + return { + "familyName": family_name, + "typeName": type_name, + "typeUniqueId": type_unique_id, + "typeElementId": type_element_id, + }, symbol + + +def parameter_inventory_key(parameter): + stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" + return "|".join( + ( + parameter.get("scope") or "", + stable_id, + parameter.get("name") or "", + parameter.get("dataTypeId") or "", + ) + ) + + +def sample_value_key(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def add_to_inventory(inventory, parameter, max_samples): + key = parameter_inventory_key(parameter) + entry = inventory.get(key) + if entry is None: + entry = { + "scope": parameter.get("scope"), + "name": parameter.get("name"), + "parameterId": parameter.get("parameterId"), + "isShared": parameter.get("isShared"), + "sharedGuid": parameter.get("sharedGuid"), + "storageType": parameter.get("storageType"), + "dataTypeId": parameter.get("dataTypeId"), + "groupTypeId": parameter.get("groupTypeId"), + "unitTypeId": parameter.get("unitTypeId"), + "occurrenceCount": 0, + "populatedCount": 0, + "sampleValues": [], + "_sampleKeys": set(), + } + inventory[key] = entry + entry["occurrenceCount"] += 1 + if has_meaningful_value(parameter): + entry["populatedCount"] += 1 + value = parameter.get("value") + value_key = sample_value_key(value) + if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: + entry["_sampleKeys"].add(value_key) + entry["sampleValues"].append(value) + + +def finalize_inventory(inventory): + result = [] + for entry in inventory.values(): + clean_entry = dict(entry) + clean_entry.pop("_sampleKeys", None) + result.append(clean_entry) + return sorted( + result, + key=lambda entry: ( + entry.get("scope") or "", + (entry.get("name") or "").casefold(), + entry.get("sharedGuid") or entry.get("parameterId") or "", + ), + ) + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def resolve_output_path(configured_path): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(include_empty, max_samples): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + if document.IsFamilyDocument: + raise RuntimeError("Open a Revit project document, not a family document.") + + application = document.Application + collector = ( + FilteredElementCollector(document) + .OfCategory(BuiltInCategory.OST_ElectricalFixtures) + .WhereElementIsNotElementType() + ) + source_elements = list(collector) + source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") + + elements = [] + types_by_unique_id = {} + inventory = {} + errors = [] + elements_without_space = 0 + + for element in source_elements: + element_errors = [] + try: + family_identity, symbol = read_family_identity(element, document) + instance_parameters, parameter_errors = read_parameters( + element, "instance", include_empty + ) + element_errors.extend(parameter_errors) + for parameter in instance_parameters: + add_to_inventory(inventory, parameter, max_samples) + + type_unique_id = family_identity.get("typeUniqueId") + if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: + type_parameters, type_errors = read_parameters(symbol, "type", include_empty) + element_errors.extend(type_errors) + for parameter in type_parameters: + add_to_inventory(inventory, parameter, max_samples) + types_by_unique_id[type_unique_id] = { + **family_identity, + "parameters": type_parameters, + } + + space, space_error = read_space(element, document) + if space_error: + element_errors.append("MEP Space: " + space_error) + if space is None: + elements_without_space += 1 + + elements.append( + { + "uniqueId": safe_text(element.UniqueId), + "elementId": element_id_text(element.Id), + "categoryName": safe_text( + None if element.Category is None else element.Category.Name + ), + "family": family_identity, + "space": space, + "instanceParameters": instance_parameters, + "warnings": element_errors, + } + ) + except Exception as error: + errors.append( + { + "uniqueId": safe_text(getattr(element, "UniqueId", None)), + "elementId": element_id_text(getattr(element, "Id", None)), + "error": safe_text(error), + } + ) + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "complete": len(errors) == 0, + "scope": { + "builtInCategory": "OST_ElectricalFixtures", + "wholeDocument": True, + "elementTypesExcluded": True, + "includeEmptyParameters": include_empty, + }, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), + }, + "summary": { + "elementCount": len(source_elements), + "exportedElementCount": len(elements), + "typeCount": len(types_by_unique_id), + "parameterDefinitionCount": len(inventory), + "elementsWithoutMepSpace": elements_without_space, + "elementErrorCount": len(errors), + }, + "parameterInventory": finalize_inventory(inventory), + "types": sorted( + types_by_unique_id.values(), + key=lambda entry: ( + (entry.get("familyName") or "").casefold(), + (entry.get("typeName") or "").casefold(), + entry.get("typeUniqueId") or "", + ), + ), + "elements": elements, + "errors": errors, + } + + +try: + include_empty_input = get_input(1, True) + include_empty = bool(include_empty_input) + try: + max_samples = int(get_input(2, 5)) + except Exception: + max_samples = 5 + max_samples = max(0, min(max_samples, 50)) + + report = build_report(include_empty, max_samples) + output_path = resolve_output_path(get_input(0)) + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "complete": report["complete"], + "summary": report["summary"], + "errors": report["errors"], + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/README.md b/dynamo/README.md new file mode 100644 index 0000000..800eded --- /dev/null +++ b/dynamo/README.md @@ -0,0 +1,69 @@ +# Revit 2026 / Dynamo diagnostics + +This directory contains self-contained Python scripts for a Dynamo **Python +Script** node. They use only Dynamo's built-in Revit integration, the Revit API +and the Python standard library. No Dynamo package is required. + +The scripts are read-only. They do not start a Revit transaction and do not +change the open model. + +## Python engine + +Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with +Revit; optional PythonNet3 packages are not required by these diagnostics. + +## 01 - Check model identity + +File: `01_check_model_identity.py` + +The script reports: + +- Revit, Dynamo and Python versions; +- `ProjectInformation.UniqueId` as a native model-identity candidate; +- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project + Information; +- all Project Information parameters; +- optional cloud/worksharing identity information when the API exposes it. + +Input `IN[0]` is optional. It may be either an output directory or a complete +`.json` file path. With no input, the report is written below the current +Windows temporary directory in `leistungsbilanz-dynamo`. + +## 02 - Inventory Electrical Fixtures parameters + +File: `02_export_electrical_fixture_parameter_inventory.py` + +The script reads every instance of +`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It +exports: + +- element, family, type and MEP Space identities; +- every instance parameter and value; +- every unique family-type parameter and value; +- an aggregated parameter inventory with occurrence counts and sample values; +- per-element warnings instead of aborting at the first unreadable element. + +Inputs: + +- `IN[0]` (optional): output directory or complete `.json` path; +- `IN[1]` (optional): include empty parameters, default `true`; +- `IN[2]` (optional): maximum sample values per aggregated parameter, default + `5`. + +The default output location is again the Windows temporary directory. The +generated report can contain model paths and project-specific parameter values; +review it before sharing or committing it. + +## Running a script + +1. Open the target model in Revit 2026. +2. Open Dynamo from **Manage > Visual Programming > Dynamo**. +3. Create a graph and add a **Python Script** node. +4. Select the `CPython3` engine for the node. +5. Copy the complete content of the desired `.py` file into the node. +6. Optionally connect a String node containing the output path to `IN[0]`. +7. Run the graph and inspect `OUT` for status, counts and the generated path. + +For the first test, run `01_check_model_identity.py` in the Revit main model. +Then run the parameter inventory. Keep both generated JSON files so their +structure can be checked before the production snapshot DTO is finalized.