From a88c4b20861af368189adf0e1a95fb2a1c032971 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 19:03:36 +0200 Subject: [PATCH] Add protection domain foundation --- docs/spec/08-current-product-backlog.md | 7 + ...-board-components-and-protection-groups.md | 11 + package.json | 4 +- src/domain/models/protection-device.model.ts | 40 ++++ .../services/circuit-group-numbering.ts | 165 ++++++++++++++ .../services/protection-device-defaults.ts | 41 ++++ src/shared/constants/circuit-group.ts | 20 ++ .../constants/distribution-board-component.ts | 54 +++++ src/shared/constants/protection-device.ts | 108 +++++++++ .../validation/protection-device.schemas.ts | 95 ++++++++ tests/circuit-group-numbering.test.ts | 104 +++++++++ tests/distribution-board-component.test.ts | 49 ++++ tests/protection-device.test.ts | 213 ++++++++++++++++++ 13 files changed, 909 insertions(+), 2 deletions(-) create mode 100644 src/domain/models/protection-device.model.ts create mode 100644 src/domain/services/circuit-group-numbering.ts create mode 100644 src/domain/services/protection-device-defaults.ts create mode 100644 src/shared/constants/circuit-group.ts create mode 100644 src/shared/constants/distribution-board-component.ts create mode 100644 src/shared/constants/protection-device.ts create mode 100644 src/shared/validation/protection-device.schemas.ts create mode 100644 tests/circuit-group-numbering.test.ts create mode 100644 tests/distribution-board-component.test.ts create mode 100644 tests/protection-device.test.ts diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index e2d9cfc..ad76708 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -12,6 +12,13 @@ requirements and intended sequencing, not proof of implementation. - [ ] Implement the agreed protected circuit-group and distribution-board component model in phased work packages. +- [x] Phase A: central protection catalog, validation, defaults and pure grouped + BMK rules. +- [ ] Phase B: persistence and snapshot/transfer compatibility model. +- [ ] Phase C: persistent commands and new-board defaults. +- [ ] Phase D: group numbering, moves and destructive operations. +- [ ] Phase E: editor projection and editing. +- [ ] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until the planner supplies the calculation requirements. - Detailed specification: diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index a1a3d4d..b883375 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -519,12 +519,23 @@ specification supplied by the planner. ### A. Catalog and Pure Domain Rules +Status: Complete. + - introduce typed component roles, categories and protection-device types - centralize rating and characteristic catalogs - validate device-specific field combinations - define default circuit and group-RCD protection values - test numbering parsing and formatting independently from persistence +Implemented foundation: + +- shared circuit-group categories, German labels and category numbers +- one protection-device catalog with all agreed ratings and characteristics +- strict device-dependent validation for fuse, LS, FI, FI/LS and AFDD fields +- pure factories for circuit and group-RCD defaults +- pure nested BMK formatting, parsing and highest-number-plus-one rules +- no runtime database or editor integration before delivery phase B + Acceptance: - every agreed valid combination is accepted diff --git a/package.json b/package.json index 7b76558..ec6b584 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "build:web": "next build", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/domain/models/protection-device.model.ts b/src/domain/models/protection-device.model.ts new file mode 100644 index 0000000..16c2776 --- /dev/null +++ b/src/domain/models/protection-device.model.ts @@ -0,0 +1,40 @@ +import type { + BreakerTripCharacteristic, + FuseProtectionDeviceType, + FuseUtilizationCategory, + RatedResidualCurrentMa, + RcdType, +} from "../../shared/constants/protection-device.js"; + +export interface FuseProtectionDeviceConfiguration { + type: FuseProtectionDeviceType; + ratedCurrentA: number; + fuseUtilizationCategory: FuseUtilizationCategory; +} + +export interface BreakerProtectionDeviceConfiguration { + type: "LS" | "AFDD"; + ratedCurrentA: number; + tripCharacteristic: BreakerTripCharacteristic; +} + +export interface ResidualCurrentDeviceConfiguration { + type: "FI"; + ratedCurrentA: number; + rcdType: RcdType; + ratedResidualCurrentMa: RatedResidualCurrentMa; +} + +export interface CombinedResidualCurrentBreakerConfiguration { + type: "FI_LS"; + ratedCurrentA: number; + tripCharacteristic: BreakerTripCharacteristic; + rcdType: RcdType; + ratedResidualCurrentMa: RatedResidualCurrentMa; +} + +export type ProtectionDeviceConfiguration = + | FuseProtectionDeviceConfiguration + | BreakerProtectionDeviceConfiguration + | ResidualCurrentDeviceConfiguration + | CombinedResidualCurrentBreakerConfiguration; diff --git a/src/domain/services/circuit-group-numbering.ts b/src/domain/services/circuit-group-numbering.ts new file mode 100644 index 0000000..938bc26 --- /dev/null +++ b/src/domain/services/circuit-group-numbering.ts @@ -0,0 +1,165 @@ +import { + circuitGroupCategoryNumbers, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group.js"; + +export type GroupedEquipmentIdentifierKind = + | "group_upstream_protection" + | "group_rcd" + | "circuit"; + +export interface ParsedGroupedEquipmentIdentifier { + category: CircuitGroupCategory; + groupNumber: number; + kind: GroupedEquipmentIdentifierKind; + circuitNumber: number | null; +} + +const categoryByNumber: Record = { + "1": "lighting", + "2": "single_phase", + "3": "three_phase", +}; + +function requirePositiveInteger(value: number, fieldName: string): void { + if (!Number.isInteger(value) || value < 1) { + throw new RangeError(`${fieldName} must be a positive integer`); + } +} + +function getGroupStem( + category: CircuitGroupCategory, + functionLetter: "F" | "Q", + groupNumber: number +): string { + requirePositiveInteger(groupNumber, "groupNumber"); + return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`; +} + +export function formatGroupUpstreamProtectionIdentifier( + category: CircuitGroupCategory, + groupNumber: number +): string { + return `${getGroupStem(category, "F", groupNumber)}.0`; +} + +export function formatGroupRcdIdentifier( + category: CircuitGroupCategory, + groupNumber: number +): string { + return `${getGroupStem(category, "Q", groupNumber)}.0`; +} + +export function formatGroupedCircuitIdentifier( + category: CircuitGroupCategory, + groupNumber: number, + circuitNumber: number +): string { + requirePositiveInteger(circuitNumber, "circuitNumber"); + return `${getGroupStem(category, "F", groupNumber)}.${circuitNumber}`; +} + +export function parseGroupedEquipmentIdentifier( + equipmentIdentifier: string +): ParsedGroupedEquipmentIdentifier | null { + const match = /^-(1|2|3)(F|Q)([1-9]\d*)\.(0|[1-9]\d*)$/.exec( + equipmentIdentifier + ); + if (!match) { + return null; + } + + const [, categoryNumber, functionLetter, groupNumberValue, suffixValue] = + match; + const category = categoryByNumber[categoryNumber]; + const groupNumber = Number(groupNumberValue); + const suffix = Number(suffixValue); + + if (functionLetter === "Q") { + return suffix === 0 + ? { + category, + groupNumber, + kind: "group_rcd", + circuitNumber: null, + } + : null; + } + + if (suffix === 0) { + return { + category, + groupNumber, + kind: "group_upstream_protection", + circuitNumber: null, + }; + } + + return { + category, + groupNumber, + kind: "circuit", + circuitNumber: suffix, + }; +} + +export function getNextGroupedCircuitNumber( + category: CircuitGroupCategory, + groupNumber: number, + equipmentIdentifiers: Iterable +): number { + requirePositiveInteger(groupNumber, "groupNumber"); + let highestCircuitNumber = 0; + + for (const equipmentIdentifier of equipmentIdentifiers) { + const parsed = parseGroupedEquipmentIdentifier(equipmentIdentifier); + if ( + parsed?.kind === "circuit" && + parsed.category === category && + parsed.groupNumber === groupNumber && + parsed.circuitNumber !== null + ) { + highestCircuitNumber = Math.max( + highestCircuitNumber, + parsed.circuitNumber + ); + } + } + + return highestCircuitNumber + 1; +} + +export function getNextGroupedCircuitIdentifier( + category: CircuitGroupCategory, + groupNumber: number, + equipmentIdentifiers: Iterable +): string { + return formatGroupedCircuitIdentifier( + category, + groupNumber, + getNextGroupedCircuitNumber( + category, + groupNumber, + equipmentIdentifiers + ) + ); +} + +export function getNextCircuitGroupNumber( + category: CircuitGroupCategory, + groups: Iterable<{ + category: CircuitGroupCategory; + groupNumber: number; + }> +): number { + let highestGroupNumber = 0; + + for (const group of groups) { + if (group.category === category) { + requirePositiveInteger(group.groupNumber, "groupNumber"); + highestGroupNumber = Math.max(highestGroupNumber, group.groupNumber); + } + } + + return highestGroupNumber + 1; +} diff --git a/src/domain/services/protection-device-defaults.ts b/src/domain/services/protection-device-defaults.ts new file mode 100644 index 0000000..63dc822 --- /dev/null +++ b/src/domain/services/protection-device-defaults.ts @@ -0,0 +1,41 @@ +import type { ProtectionDeviceConfiguration } from "../models/protection-device.model.js"; +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; + +const defaultCircuitProtectionByCategory = { + lighting: { + type: "LS", + ratedCurrentA: 10, + tripCharacteristic: "B", + }, + single_phase: { + type: "FI_LS", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + three_phase: { + type: "FI_LS", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }, +} as const satisfies Record; + +const defaultGroupRcd = { + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, +} as const satisfies ProtectionDeviceConfiguration; + +export function createDefaultCircuitProtection( + category: CircuitGroupCategory +): ProtectionDeviceConfiguration { + return { ...defaultCircuitProtectionByCategory[category] }; +} + +export function createDefaultGroupRcd(): ProtectionDeviceConfiguration { + return { ...defaultGroupRcd }; +} diff --git a/src/shared/constants/circuit-group.ts b/src/shared/constants/circuit-group.ts new file mode 100644 index 0000000..4db5bb7 --- /dev/null +++ b/src/shared/constants/circuit-group.ts @@ -0,0 +1,20 @@ +export const circuitGroupCategories = [ + "lighting", + "single_phase", + "three_phase", +] as const; + +export type CircuitGroupCategory = (typeof circuitGroupCategories)[number]; + +export const circuitGroupCategoryNumbers: Record = + { + lighting: 1, + single_phase: 2, + three_phase: 3, + }; + +export const circuitGroupCategoryLabels: Record = { + lighting: "Beleuchtung", + single_phase: "1-phasig", + three_phase: "3-phasig", +}; diff --git a/src/shared/constants/distribution-board-component.ts b/src/shared/constants/distribution-board-component.ts new file mode 100644 index 0000000..69f83e9 --- /dev/null +++ b/src/shared/constants/distribution-board-component.ts @@ -0,0 +1,54 @@ +export const distributionBoardComponentRoles = [ + "main_switch", + "surge_protective_device", + "group_upstream_protection", + "group_residual_current_protection", + "auxiliary", +] as const; + +export type DistributionBoardComponentRole = + (typeof distributionBoardComponentRoles)[number]; + +export const distributionBoardComponentPlacements = [ + "header", + "group", + "footer", +] as const; + +export type DistributionBoardComponentPlacement = + (typeof distributionBoardComponentPlacements)[number]; + +export const distributionBoardComponentRoleLabels: Record< + DistributionBoardComponentRole, + string +> = { + main_switch: "Hauptschalter", + surge_protective_device: "Überspannungsableiter", + group_upstream_protection: "Gruppenvorsicherung", + group_residual_current_protection: "Gruppen-FI", + auxiliary: "Verteilergerät", +}; + +export const defaultMainSwitchComponent = { + equipmentIdentifier: "-Q0", + name: "Hauptschalter", + role: "main_switch", + placement: "header", +} as const satisfies { + equipmentIdentifier: string; + name: string; + role: DistributionBoardComponentRole; + placement: DistributionBoardComponentPlacement; +}; + +export const defaultSurgeProtectiveDeviceComponent = { + equipmentIdentifier: "-FA", + name: "Überspannungsableiter", + role: "surge_protective_device", + placement: "header", +} as const satisfies { + equipmentIdentifier: string; + name: string; + role: DistributionBoardComponentRole; + placement: DistributionBoardComponentPlacement; +}; diff --git a/src/shared/constants/protection-device.ts b/src/shared/constants/protection-device.ts new file mode 100644 index 0000000..adf8777 --- /dev/null +++ b/src/shared/constants/protection-device.ts @@ -0,0 +1,108 @@ +export const fuseProtectionDeviceTypes = [ + "D02", + "NH000", + "NH00", + "NH0", + "NH1", + "NH2", + "NH3", +] as const; + +export type FuseProtectionDeviceType = + (typeof fuseProtectionDeviceTypes)[number]; + +export const breakerProtectionDeviceTypes = ["LS", "FI_LS", "AFDD"] as const; + +export type BreakerProtectionDeviceType = + (typeof breakerProtectionDeviceTypes)[number]; + +export const protectionDeviceTypes = [ + ...fuseProtectionDeviceTypes, + "LS", + "FI", + "FI_LS", + "AFDD", +] as const; + +export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number]; + +export const fuseUtilizationCategories = ["gG", "gR"] as const; +export type FuseUtilizationCategory = + (typeof fuseUtilizationCategories)[number]; + +export const breakerTripCharacteristics = ["B", "C", "D", "Z"] as const; +export type BreakerTripCharacteristic = + (typeof breakerTripCharacteristics)[number]; + +export const rcdTypes = ["A", "AC", "B", "B+"] as const; +export type RcdType = (typeof rcdTypes)[number]; + +export const ratedResidualCurrentsMa = [10, 30, 100, 300, 500] as const; +export type RatedResidualCurrentMa = + (typeof ratedResidualCurrentsMa)[number]; + +const d02RatedCurrentsA = [20, 25, 32, 35, 40, 50, 63] as const; +const nh000AndNh00RatedCurrentsA = [ + 6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, +] as const; +const nh0RatedCurrentsA = [ + 6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, +] as const; +const nh1RatedCurrentsA = [ + 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, +] as const; +const nh2RatedCurrentsA = [ + 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355, + 400, +] as const; +const nh3RatedCurrentsA = [ + 50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800, +] as const; +const breakerRatedCurrentsA = [ + 6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125, +] as const; +const rcdRatedCurrentsA = [16, 25, 40, 63, 80, 100, 125] as const; + +export const allowedRatedCurrentsAByProtectionDeviceType = { + D02: d02RatedCurrentsA, + NH000: nh000AndNh00RatedCurrentsA, + NH00: nh000AndNh00RatedCurrentsA, + NH0: nh0RatedCurrentsA, + NH1: nh1RatedCurrentsA, + NH2: nh2RatedCurrentsA, + NH3: nh3RatedCurrentsA, + LS: breakerRatedCurrentsA, + FI: rcdRatedCurrentsA, + FI_LS: breakerRatedCurrentsA, + AFDD: breakerRatedCurrentsA, +} as const satisfies Record; + +export const protectionDeviceTypeLabels: Record = { + D02: "D02 / Neozed", + NH000: "NH000", + NH00: "NH00", + NH0: "NH0", + NH1: "NH1", + NH2: "NH2", + NH3: "NH3", + LS: "LS", + FI: "FI", + FI_LS: "FI/LS", + AFDD: "AFDD", +}; + +export function isAllowedRatedCurrentA( + type: ProtectionDeviceType, + ratedCurrentA: number +): boolean { + const allowedValues: readonly number[] = + allowedRatedCurrentsAByProtectionDeviceType[type]; + return allowedValues.includes(ratedCurrentA); +} + +export function isAllowedRatedResidualCurrentMa( + ratedResidualCurrentMa: number +): ratedResidualCurrentMa is RatedResidualCurrentMa { + const allowedValues: readonly number[] = ratedResidualCurrentsMa; + return allowedValues.includes(ratedResidualCurrentMa); +} diff --git a/src/shared/validation/protection-device.schemas.ts b/src/shared/validation/protection-device.schemas.ts new file mode 100644 index 0000000..b4c5c0e --- /dev/null +++ b/src/shared/validation/protection-device.schemas.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { + breakerTripCharacteristics, + fuseProtectionDeviceTypes, + fuseUtilizationCategories, + isAllowedRatedCurrentA, + isAllowedRatedResidualCurrentMa, + protectionDeviceTypes, + rcdTypes, +} from "../constants/protection-device.js"; + +function hasValue(value: unknown): boolean { + return value !== undefined; +} + +export const protectionDeviceConfigurationSchema = z + .object({ + type: z.enum(protectionDeviceTypes), + ratedCurrentA: z.number().finite().positive(), + fuseUtilizationCategory: z.enum(fuseUtilizationCategories).optional(), + tripCharacteristic: z.enum(breakerTripCharacteristics).optional(), + rcdType: z.enum(rcdTypes).optional(), + ratedResidualCurrentMa: z + .number() + .int() + .refine(isAllowedRatedResidualCurrentMa, { + message: "Der Bemessungsdifferenzstrom ist nicht zulässig.", + }) + .optional(), + }) + .strict() + .superRefine((value, context) => { + if (!isAllowedRatedCurrentA(value.type, value.ratedCurrentA)) { + context.addIssue({ + code: "custom", + path: ["ratedCurrentA"], + message: "Der Bemessungsstrom ist für diesen Schutzgerätetyp nicht zulässig.", + }); + } + + const isFuse = (fuseProtectionDeviceTypes as readonly string[]).includes( + value.type + ); + const hasBreakerFunction = + value.type === "LS" || value.type === "FI_LS" || value.type === "AFDD"; + const hasResidualCurrentFunction = + value.type === "FI" || value.type === "FI_LS"; + + if (isFuse !== hasValue(value.fuseUtilizationCategory)) { + context.addIssue({ + code: "custom", + path: ["fuseUtilizationCategory"], + message: isFuse + ? "Für diesen Sicherungstyp ist eine Sicherungscharakteristik erforderlich." + : "Dieser Schutzgerätetyp darf keine Sicherungscharakteristik enthalten.", + }); + } + + if (hasBreakerFunction !== hasValue(value.tripCharacteristic)) { + context.addIssue({ + code: "custom", + path: ["tripCharacteristic"], + message: hasBreakerFunction + ? "Für diesen Schutzgerätetyp ist eine Auslösecharakteristik erforderlich." + : "Dieser Schutzgerätetyp darf keine Auslösecharakteristik enthalten.", + }); + } + + if (hasResidualCurrentFunction !== hasValue(value.rcdType)) { + context.addIssue({ + code: "custom", + path: ["rcdType"], + message: hasResidualCurrentFunction + ? "Für diesen Schutzgerätetyp ist ein FI-Typ erforderlich." + : "Dieser Schutzgerätetyp darf keinen FI-Typ enthalten.", + }); + } + + if ( + hasResidualCurrentFunction !== + hasValue(value.ratedResidualCurrentMa) + ) { + context.addIssue({ + code: "custom", + path: ["ratedResidualCurrentMa"], + message: hasResidualCurrentFunction + ? "Für diesen Schutzgerätetyp ist ein Bemessungsdifferenzstrom erforderlich." + : "Dieser Schutzgerätetyp darf keinen Bemessungsdifferenzstrom enthalten.", + }); + } + }); + +export type ProtectionDeviceConfigurationInput = z.infer< + typeof protectionDeviceConfigurationSchema +>; diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts new file mode 100644 index 0000000..8128d93 --- /dev/null +++ b/tests/circuit-group-numbering.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + formatGroupedCircuitIdentifier, + formatGroupRcdIdentifier, + formatGroupUpstreamProtectionIdentifier, + getNextCircuitGroupNumber, + getNextGroupedCircuitIdentifier, + parseGroupedEquipmentIdentifier, +} from "../src/domain/services/circuit-group-numbering.js"; + +describe("circuit group numbering", () => { + it("formats the agreed identifiers including the leading hyphen", () => { + assert.equal( + formatGroupUpstreamProtectionIdentifier("lighting", 1), + "-1F1.0" + ); + assert.equal(formatGroupRcdIdentifier("lighting", 1), "-1Q1.0"); + assert.equal( + formatGroupedCircuitIdentifier("lighting", 1, 2), + "-1F1.2" + ); + assert.equal( + formatGroupedCircuitIdentifier("single_phase", 3, 7), + "-2F3.7" + ); + assert.equal( + formatGroupedCircuitIdentifier("three_phase", 2, 1), + "-3F2.1" + ); + }); + + it("parses circuit and group component identifiers", () => { + assert.deepEqual(parseGroupedEquipmentIdentifier("-1F2.4"), { + category: "lighting", + groupNumber: 2, + kind: "circuit", + circuitNumber: 4, + }); + assert.deepEqual(parseGroupedEquipmentIdentifier("-2F3.0"), { + category: "single_phase", + groupNumber: 3, + kind: "group_upstream_protection", + circuitNumber: null, + }); + assert.deepEqual(parseGroupedEquipmentIdentifier("-3Q1.0"), { + category: "three_phase", + groupNumber: 1, + kind: "group_rcd", + circuitNumber: null, + }); + }); + + it("rejects legacy, malformed and unsupported identifiers", () => { + for (const identifier of [ + "-1F1", + "1F1.1", + "-1Q1.2", + "-4F1.1", + "-1F0.1", + "-1F1.01", + ]) { + assert.equal(parseGroupedEquipmentIdentifier(identifier), null); + } + }); + + it("uses the highest target-group suffix plus one without filling gaps", () => { + assert.equal( + getNextGroupedCircuitIdentifier("lighting", 2, [ + "-1F2.1", + "-1F2.3", + "-1F2.7", + "-1F1.12", + "-2F2.9", + "-1F2.0", + "-1Q2.0", + "-1F8", + ]), + "-1F2.8" + ); + }); + + it("uses the highest group number in the selected category plus one", () => { + assert.equal( + getNextCircuitGroupNumber("lighting", [ + { category: "lighting", groupNumber: 1 }, + { category: "lighting", groupNumber: 4 }, + { category: "single_phase", groupNumber: 9 }, + ]), + 5 + ); + }); + + it("rejects non-positive group and circuit numbers", () => { + assert.throws( + () => formatGroupedCircuitIdentifier("lighting", 0, 1), + RangeError + ); + assert.throws( + () => formatGroupedCircuitIdentifier("lighting", 1, 0), + RangeError + ); + }); +}); diff --git a/tests/distribution-board-component.test.ts b/tests/distribution-board-component.test.ts new file mode 100644 index 0000000..ba4ac10 --- /dev/null +++ b/tests/distribution-board-component.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + defaultMainSwitchComponent, + defaultSurgeProtectiveDeviceComponent, + distributionBoardComponentPlacements, + distributionBoardComponentRoles, +} from "../src/shared/constants/distribution-board-component.js"; +import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js"; + +describe("distribution board component catalog", () => { + it("defines the agreed component roles and placement zones", () => { + assert.deepEqual(distributionBoardComponentRoles, [ + "main_switch", + "surge_protective_device", + "group_upstream_protection", + "group_residual_current_protection", + "auxiliary", + ]); + assert.deepEqual(distributionBoardComponentPlacements, [ + "header", + "group", + "footer", + ]); + }); + + it("defines exactly the three supported circuit group categories", () => { + assert.deepEqual(circuitGroupCategories, [ + "lighting", + "single_phase", + "three_phase", + ]); + }); + + it("provides the fixed header component defaults", () => { + assert.deepEqual(defaultMainSwitchComponent, { + equipmentIdentifier: "-Q0", + name: "Hauptschalter", + role: "main_switch", + placement: "header", + }); + assert.deepEqual(defaultSurgeProtectiveDeviceComponent, { + equipmentIdentifier: "-FA", + name: "Überspannungsableiter", + role: "surge_protective_device", + placement: "header", + }); + }); +}); diff --git a/tests/protection-device.test.ts b/tests/protection-device.test.ts new file mode 100644 index 0000000..06d13b2 --- /dev/null +++ b/tests/protection-device.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + allowedRatedCurrentsAByProtectionDeviceType, + breakerTripCharacteristics, + fuseUtilizationCategories, + protectionDeviceTypes, + ratedResidualCurrentsMa, + rcdTypes, +} from "../src/shared/constants/protection-device.js"; +import { + createDefaultCircuitProtection, + createDefaultGroupRcd, +} from "../src/domain/services/protection-device-defaults.js"; +import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js"; + +describe("protection device catalog", () => { + it("contains exactly the agreed protection device types", () => { + assert.deepEqual(protectionDeviceTypes, [ + "D02", + "NH000", + "NH00", + "NH0", + "NH1", + "NH2", + "NH3", + "LS", + "FI", + "FI_LS", + "AFDD", + ]); + assert.equal("D01" in allowedRatedCurrentsAByProtectionDeviceType, false); + assert.equal("NH4" in allowedRatedCurrentsAByProtectionDeviceType, false); + }); + + it("exposes the agreed device-specific rated currents", () => { + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.D02, [ + 20, 25, 32, 35, 40, 50, 63, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH000, [ + 6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, + ]); + assert.deepEqual( + allowedRatedCurrentsAByProtectionDeviceType.NH00, + allowedRatedCurrentsAByProtectionDeviceType.NH000 + ); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH0, [ + 6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, + 250, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH1, [ + 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH2, [ + 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, + 355, 400, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH3, [ + 50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, + 800, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.FI, [ + 16, 25, 40, 63, 80, 100, 125, + ]); + assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.LS, [ + 6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125, + ]); + assert.deepEqual( + allowedRatedCurrentsAByProtectionDeviceType.FI_LS, + allowedRatedCurrentsAByProtectionDeviceType.LS + ); + assert.deepEqual( + allowedRatedCurrentsAByProtectionDeviceType.AFDD, + allowedRatedCurrentsAByProtectionDeviceType.LS + ); + assert.deepEqual(fuseUtilizationCategories, ["gG", "gR"]); + assert.deepEqual(breakerTripCharacteristics, ["B", "C", "D", "Z"]); + assert.deepEqual(rcdTypes, ["A", "AC", "B", "B+"]); + assert.deepEqual(ratedResidualCurrentsMa, [10, 30, 100, 300, 500]); + }); +}); + +describe("protection device validation", () => { + it("accepts valid fuse, breaker, RCD, combined and AFDD configurations", () => { + const configurations = [ + { + type: "D02", + ratedCurrentA: 20, + fuseUtilizationCategory: "gG", + }, + { + type: "NH3", + ratedCurrentA: 800, + fuseUtilizationCategory: "gR", + }, + { + type: "LS", + ratedCurrentA: 13, + tripCharacteristic: "Z", + }, + { + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + { + type: "FI_LS", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + { + type: "AFDD", + ratedCurrentA: 16, + tripCharacteristic: "B", + }, + ]; + + for (const configuration of configurations) { + const result = + protectionDeviceConfigurationSchema.safeParse(configuration); + assert.equal( + result.success, + true, + result.success + ? undefined + : `${configuration.type}: ${result.error.message}` + ); + } + }); + + it("rejects rated currents that do not belong to the selected type", () => { + assert.equal( + protectionDeviceConfigurationSchema.safeParse({ + type: "D02", + ratedCurrentA: 16, + fuseUtilizationCategory: "gG", + }).success, + false + ); + }); + + it("requires and forbids fields according to device function", () => { + assert.equal( + protectionDeviceConfigurationSchema.safeParse({ + type: "LS", + ratedCurrentA: 16, + }).success, + false + ); + assert.equal( + protectionDeviceConfigurationSchema.safeParse({ + type: "FI", + ratedCurrentA: 40, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }).success, + false + ); + assert.equal( + protectionDeviceConfigurationSchema.safeParse({ + type: "AFDD", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }).success, + false + ); + }); +}); + +describe("protection device defaults", () => { + it("creates the agreed circuit defaults", () => { + assert.deepEqual(createDefaultCircuitProtection("lighting"), { + type: "LS", + ratedCurrentA: 10, + tripCharacteristic: "B", + }); + assert.deepEqual(createDefaultCircuitProtection("single_phase"), { + type: "FI_LS", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }); + assert.deepEqual(createDefaultCircuitProtection("three_phase"), { + type: "FI_LS", + ratedCurrentA: 16, + tripCharacteristic: "B", + rcdType: "A", + ratedResidualCurrentMa: 30, + }); + }); + + it("creates a 40 A, type A, 30 mA group RCD", () => { + assert.deepEqual(createDefaultGroupRcd(), { + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, + }); + }); + + it("returns independent default objects", () => { + const first = createDefaultCircuitProtection("lighting"); + const second = createDefaultCircuitProtection("lighting"); + assert.notEqual(first, second); + }); +});