Add explicit "no protection device" state for circuits

Circuits can now record that they deliberately have no protection device
(type NONE, shown as "–"), separate from circuits whose protection is
still open. Migration 0017 makes rated_current_a nullable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 13:11:28 +01:00
parent 1f3b903809
commit 38019fe087
27 changed files with 3673 additions and 132 deletions

View file

@ -1,10 +1,10 @@
import type {
BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import { isValidCircuitProtectionRecord } from "../../shared/validation/protection-device.schemas.js";
import {
assertCircuitGroupSnapshot,
type CircuitGroupSnapshot,
@ -22,8 +22,8 @@ import {
export interface CircuitProtectionDeviceSnapshot {
circuitId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
type: CircuitProtectionDeviceType;
ratedCurrentA: number | null;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
@ -143,21 +143,7 @@ function assertCircuitProtectionDeviceSnapshot(
) {
throw new Error("Circuit protection-device snapshot is invalid.");
}
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
if (!isValidCircuitProtectionRecord(value)) {
throw new Error(
"Circuit protection-device configuration is invalid."
);

View file

@ -1,8 +1,8 @@
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import { isValidCircuitProtectionRecord } from "../../shared/validation/protection-device.schemas.js";
import type {
BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
@ -11,10 +11,12 @@ export const circuitProtectionUpdateCommandType =
"circuit-protection.update" as const;
export const circuitProtectionUpdateCommandSchemaVersion = 1 as const;
// type "NONE" records an explicit "no protection device" decision; all
// values are then null. A missing snapshot (null) means "not yet planned".
export interface CircuitProtectionSnapshot {
circuitId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
type: CircuitProtectionDeviceType;
ratedCurrentA: number | null;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
@ -105,21 +107,7 @@ export function assertCircuitProtectionSnapshot(
) {
throw new Error("Circuit-protection snapshot is invalid.");
}
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
if (!isValidCircuitProtectionRecord(value)) {
throw new Error("Circuit-protection configuration is invalid.");
}
}

View file

@ -45,7 +45,7 @@ export interface CircuitTreeCircuit {
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRow[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
protectionDevice?: CircuitTreeProtectionDevice;
protectionDevice?: CircuitTreeCircuitProtectionDevice;
}
export interface CircuitTreeSectionBlock {
@ -116,6 +116,7 @@ import type {
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
NoCircuitProtectionDeviceType,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
@ -129,6 +130,15 @@ export interface CircuitTreeProtectionDevice {
ratedResidualCurrentMa?: number;
}
// Explicit "no protection device" on a circuit; shown as "–".
export interface CircuitTreeNoProtectionDevice {
type: NoCircuitProtectionDeviceType;
}
export type CircuitTreeCircuitProtectionDevice =
| CircuitTreeProtectionDevice
| CircuitTreeNoProtectionDevice;
export interface CircuitTreeComponent {
id: string;
circuitListId: string;

View file

@ -15,9 +15,13 @@ import {
distributionBoardComponentPlacements,
distributionBoardComponentRoles,
} from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
isValidCircuitProtectionRecord,
isValidProtectionRecord,
} from "../../shared/validation/protection-device.schemas.js";
import {
breakerTripCharacteristics,
circuitProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypes,
rcdTypes,
@ -311,45 +315,15 @@ const distributionBoardComponentSchema = z
})
.strict();
const persistedProtectionDeviceFields = {
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
const persistedProtectionDeviceOptionFields = {
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
rcdType: z.enum(rcdTypes).nullable(),
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
};
function validatePersistedProtectionDevice(
value: {
type: (typeof protectionDeviceTypes)[number];
ratedCurrentA: number;
fuseUtilizationCategory:
| (typeof fuseUtilizationCategories)[number]
| null;
tripCharacteristic:
| (typeof breakerTripCharacteristics)[number]
| null;
rcdType: (typeof rcdTypes)[number] | null;
ratedResidualCurrentMa: number | null;
},
context: z.RefinementCtx
) {
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
function addProtectionIssue(valid: boolean, context: z.RefinementCtx) {
if (!valid) {
context.addIssue({
code: "custom",
message: "Snapshot protection-device configuration is invalid.",
@ -357,21 +331,30 @@ function validatePersistedProtectionDevice(
}
}
// Circuits may additionally store the explicit "no protection device" state.
const circuitProtectionDeviceSchema = z
.object({
circuitId: idSchema,
...persistedProtectionDeviceFields,
type: z.enum(circuitProtectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive().nullable(),
...persistedProtectionDeviceOptionFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
.superRefine((value, context) =>
addProtectionIssue(isValidCircuitProtectionRecord(value), context)
);
const componentProtectionDeviceSchema = z
.object({
componentId: idSchema,
...persistedProtectionDeviceFields,
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
...persistedProtectionDeviceOptionFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
.superRefine((value, context) =>
addProtectionIssue(isValidProtectionRecord(value), context)
);
const externalCsvConfigurationSnapshotSchema = z
.object({