Add protection domain foundation
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, CircuitGroupCategory> = {
|
||||
"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<string>
|
||||
): 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>
|
||||
): 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;
|
||||
}
|
||||
@@ -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<CircuitGroupCategory, ProtectionDeviceConfiguration>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const circuitGroupCategories = [
|
||||
"lighting",
|
||||
"single_phase",
|
||||
"three_phase",
|
||||
] as const;
|
||||
|
||||
export type CircuitGroupCategory = (typeof circuitGroupCategories)[number];
|
||||
|
||||
export const circuitGroupCategoryNumbers: Record<CircuitGroupCategory, 1 | 2 | 3> =
|
||||
{
|
||||
lighting: 1,
|
||||
single_phase: 2,
|
||||
three_phase: 3,
|
||||
};
|
||||
|
||||
export const circuitGroupCategoryLabels: Record<CircuitGroupCategory, string> = {
|
||||
lighting: "Beleuchtung",
|
||||
single_phase: "1-phasig",
|
||||
three_phase: "3-phasig",
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<ProtectionDeviceType, readonly number[]>;
|
||||
|
||||
export const protectionDeviceTypeLabels: Record<ProtectionDeviceType, string> = {
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user