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

@ -70,6 +70,9 @@ See `docs/current-architecture.md` for the complete module and request flow.
- Equipment identifiers belong to circuits, not to every device row.
- Device rows inside a circuit do not have their own equipment identifiers.
- Protection and cable data belong to the circuit, not to individual devices.
- A circuit protection record of type `NONE` means "deliberately no protection
device" (shown as "–"); a circuit without any protection record is still
open. `NONE` is circuit-only, never a component protection.
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
- Circuit total power is the sum of all device rows in that circuit plus the
read-only loads of logically linked downstream boards on that feeder circuit.

View file

@ -460,6 +460,13 @@ Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
wiederherstellen.
Der Typ `NONE` („Kein Schutzgerät“) hält die bewusste Entscheidung fest, dass
ein Stromkreis kein eigenes Schutzgerät erhält; Bemessungsstrom und alle
abhängigen Felder sind dann `null` (Migration `0017` macht
`rated_current_a` nullable). Editor und Seitenansicht zeigen „–“. Ein
Stromkreis ganz ohne Datensatz gilt dagegen weiter als offen (Seitenansicht
„offen“). `NONE` gibt es nur für Stromkreise, nicht für
Verteilerkomponenten; die Leitungsberechnung übernimmt dafür kein Schutzgerät.
Der vollständige `CircuitSnapshot` enthält den zugehörigen
`protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und
durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten

View file

@ -0,0 +1,16 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_circuit_protection_devices` (
`circuit_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_circuit_protection_devices`("circuit_id", "type", "rated_current_a", "fuse_utilization_category", "trip_characteristic", "rcd_type", "rated_residual_current_ma") SELECT "circuit_id", "type", "rated_current_a", "fuse_utilization_category", "trip_characteristic", "rcd_type", "rated_residual_current_ma" FROM `circuit_protection_devices`;--> statement-breakpoint
DROP TABLE `circuit_protection_devices`;--> statement-breakpoint
ALTER TABLE `__new_circuit_protection_devices` RENAME TO `circuit_protection_devices`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,13 @@
"when": 1793277078601,
"tag": "0016_single_device_circuit_names",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1793359753805,
"tag": "0017_circuit_protection_none",
"breakpoints": true
}
]
}

View file

@ -1,8 +1,8 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { circuits } from "./circuits.js";
@ -13,8 +13,9 @@ export const circuitProtectionDevices = sqliteTable(
circuitId: text("circuit_id")
.primaryKey()
.references(() => circuits.id, { onDelete: "cascade" }),
type: text("type").$type<ProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a").notNull(),
// "NONE" stores an explicit "no protection device" with null values.
type: text("type").$type<CircuitProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a"),
fuseUtilizationCategory: text(
"fuse_utilization_category"
).$type<FuseUtilizationCategory>(),

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({

View file

@ -4,7 +4,11 @@ import type {
CircuitCategory,
ExistingProtection,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto } from "../types";
import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device";
import type {
CircuitTreeCircuitDto,
CircuitTreeCircuitProtectionDeviceDto,
} from "../types";
/** Three-phase if the circuit group or any device row is three-phase. */
export function deriveCircuitPhase(
@ -41,12 +45,17 @@ export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
* Maps the circuit's protective device to the overload-protection kind of the
* calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH
* fuse links "fuse"; a residual-current device without overcurrent release
* (FI) provides no overload protection and is ignored.
* (FI) provides no overload protection and is ignored, like an explicit
* "no protection device".
*/
export function toExistingProtection(
protection: CircuitTreeProtectionDeviceDto | undefined
protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): ExistingProtection | undefined {
if (!protection || protection.type === "FI") {
if (
!protection ||
protection.type === "FI" ||
protection.type === noCircuitProtectionDeviceType
) {
return undefined;
}
const kind =

View file

@ -4,23 +4,24 @@ import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
circuitProtectionDeviceTypeLabels,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
noCircuitProtectionDeviceType,
protectionDeviceTypes,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
type CircuitProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeProtectionDeviceDto } from "../types";
import type { CircuitTreeCircuitProtectionDeviceDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitProtectionModalProps {
equipmentIdentifier: string;
initialProtection: CircuitTreeProtectionDeviceDto;
initialProtection: CircuitTreeCircuitProtectionDeviceDto;
isSaving: boolean;
onClose: () => void;
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
onSave: (protection: CircuitTreeCircuitProtectionDeviceDto) => Promise<void>;
}
export function CircuitProtectionModal({
@ -30,35 +31,45 @@ export function CircuitProtectionModal({
onClose,
onSave,
}: CircuitProtectionModalProps) {
const [type, setType] = useState(initialProtection.type);
const initialDevice =
initialProtection.type === noCircuitProtectionDeviceType
? undefined
: initialProtection;
const [type, setType] = useState<CircuitProtectionDeviceType>(
initialProtection.type
);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection.ratedCurrentA
initialDevice?.ratedCurrentA ?? 0
);
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection.fuseUtilizationCategory ?? "gG"
initialDevice?.fuseUtilizationCategory ?? "gG"
);
const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection.tripCharacteristic ?? "B"
initialDevice?.tripCharacteristic ?? "B"
);
const [rcdType, setRcdType] = useState(
initialProtection.rcdType ?? "A"
initialDevice?.rcdType ?? "A"
);
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection.ratedResidualCurrentMa ?? 30
initialDevice?.ratedResidualCurrentMa ?? 30
);
const hasNoDevice = type === noCircuitProtectionDeviceType;
const usesFuseCategory = (
fuseProtectionDeviceTypes as readonly string[]
).includes(type);
const usesTripCharacteristic =
type === "LS" || type === "FI_LS" || type === "AFDD";
const usesResidualCurrent = type === "FI" || type === "FI_LS";
const allowedRatedCurrents =
allowedRatedCurrentsAByProtectionDeviceType[type];
const allowedRatedCurrents: readonly number[] = hasNoDevice
? []
: allowedRatedCurrentsAByProtectionDeviceType[type];
function handleTypeChange(nextType: ProtectionDeviceType) {
function handleTypeChange(nextType: CircuitProtectionDeviceType) {
setType(nextType);
setRatedCurrentA(
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
nextType === noCircuitProtectionDeviceType
? 0
: allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
@ -68,6 +79,10 @@ export function CircuitProtectionModal({
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (type === noCircuitProtectionDeviceType) {
await onSave({ type });
return;
}
await onSave({
type,
ratedCurrentA,
@ -86,9 +101,7 @@ export function CircuitProtectionModal({
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={
!(allowedRatedCurrents as readonly number[]).includes(
ratedCurrentA
)
!hasNoDevice && !allowedRatedCurrents.includes(ratedCurrentA)
}
submitLabel="Schutzgerät speichern"
title={`Schutzgerät bearbeiten – ${equipmentIdentifier}`}
@ -104,18 +117,29 @@ export function CircuitProtectionModal({
id="circuit-protection-type"
onChange={(event) =>
handleTypeChange(
event.target.value as ProtectionDeviceType
event.target.value as CircuitProtectionDeviceType
)
}
value={type}
>
<option value={noCircuitProtectionDeviceType}>
{circuitProtectionDeviceTypeLabels.NONE} (–)
</option>
{protectionDeviceTypes.map((entry) => (
<option key={entry} value={entry}>
{protectionDeviceTypeLabels[entry]}
{circuitProtectionDeviceTypeLabels[entry]}
</option>
))}
</select>
</div>
{hasNoDevice ? (
<div className="col-12">
<p className="text-secondary small mb-0">
Der Stromkreis erhält bewusst kein eigenes Schutzgerät und wird
in Liste und Seitenansicht mit „–“ geführt.
</p>
</div>
) : (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-current">
Bemessungsstrom
@ -135,6 +159,7 @@ export function CircuitProtectionModal({
))}
</select>
</div>
)}
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-fuse-category">

View file

@ -170,8 +170,8 @@ import {
} from "../utils/circuit-grid-clipboard";
import type {
CircuitTreeCircuitDto,
CircuitTreeCircuitProtectionDeviceDto,
CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
@ -1561,7 +1561,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto
protection: CircuitTreeCircuitProtectionDeviceDto
) {
const circuit = protectionEditorCircuit;
if (!circuit) {

View file

@ -1,8 +1,12 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import {
noCircuitProtectionDeviceText,
noCircuitProtectionDeviceType,
protectionDeviceTypeLabels,
} from "../../shared/constants/protection-device";
import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy";
import type {
CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeCircuitProtectionDeviceDto,
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../types";
@ -140,11 +144,14 @@ export const formatFactor = (value: number) => kilowattFormat.format(value);
export const formatCurrent = (value: number) => currentFormat.format(value);
export function formatProtectionDevice(
protection: CircuitTreeProtectionDeviceDto | undefined
protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): string | null {
if (!protection) {
return null;
}
if (protection.type === noCircuitProtectionDeviceType) {
return noCircuitProtectionDeviceText;
}
const parts = [
protectionDeviceTypeLabels[protection.type],
protection.tripCharacteristic,

View file

@ -7,6 +7,7 @@ import type {
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
NoCircuitProtectionDeviceType,
ProtectionDeviceType,
RcdType,
} from "../shared/constants/protection-device";
@ -517,6 +518,16 @@ export interface CircuitTreeProtectionDeviceDto {
ratedResidualCurrentMa?: number;
}
// Explicit "no protection device" on a circuit; shown as "–". A circuit
// without protectionDevice is still open for planning.
export interface CircuitTreeNoProtectionDeviceDto {
type: NoCircuitProtectionDeviceType;
}
export type CircuitTreeCircuitProtectionDeviceDto =
| CircuitTreeProtectionDeviceDto
| CircuitTreeNoProtectionDeviceDto;
export interface CircuitTreeComponentDto {
id: string;
circuitListId: string;
@ -551,7 +562,7 @@ export interface CircuitTreeCircuitDto {
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRowDto[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto;
protectionDevice?: CircuitTreeCircuitProtectionDeviceDto;
}
export interface CircuitTreeSectionDto {

View file

@ -1,7 +1,11 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import {
noCircuitProtectionDeviceText,
noCircuitProtectionDeviceType,
protectionDeviceTypeLabels,
} from "../../shared/constants/protection-device";
export type CellKey =
| "equipmentIdentifier"
@ -531,7 +535,12 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
}
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
const protection = circuit.protectionDevice;
const hasNoProtectionDevice =
circuit.protectionDevice?.type === noCircuitProtectionDeviceType;
const protection =
circuit.protectionDevice?.type === noCircuitProtectionDeviceType
? undefined
: circuit.protectionDevice;
const protectionCharacteristic = protection
? [
protection.fuseUtilizationCategory,
@ -549,6 +558,9 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType":
if (hasNoProtectionDevice) {
return noCircuitProtectionDeviceText;
}
return protection
? protectionDeviceTypeLabels[protection.type]
: undefined;
@ -557,6 +569,9 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
case "protectionCharacteristic":
return protectionCharacteristic || undefined;
case "protectionSummary": {
if (hasNoProtectionDevice) {
return noCircuitProtectionDeviceText;
}
if (!protection) {
return undefined;
}

View file

@ -22,6 +22,7 @@ import type {
import {
toDistributionBoardComponentSnapshot,
} from "./distribution-board-component-editing";
import { toCircuitProtectionSnapshot } from "./circuit-protection-editing";
export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto,
@ -159,21 +160,7 @@ export function buildCircuitGroupSubtreeSnapshot(
})),
},
protectionDevice: circuit.protectionDevice
? {
circuitId: circuit.id,
type: circuit.protectionDevice.type,
ratedCurrentA:
circuit.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
circuit.protectionDevice.fuseUtilizationCategory ??
null,
tripCharacteristic:
circuit.protectionDevice.tripCharacteristic ?? null,
rcdType: circuit.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
circuit.protectionDevice.ratedResidualCurrentMa ??
null,
}
? toCircuitProtectionSnapshot(circuit.id, circuit.protectionDevice)
: null,
}));
return {

View file

@ -3,14 +3,26 @@ import type {
} from "../../domain/models/circuit-protection-project-command.model";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device";
import type {
CircuitTreeProtectionDeviceDto,
CircuitTreeCircuitProtectionDeviceDto,
} from "../types";
export function toCircuitProtectionSnapshot(
circuitId: string,
protection: CircuitTreeProtectionDeviceDto
protection: CircuitTreeCircuitProtectionDeviceDto
): CircuitProtectionSnapshot {
if (protection.type === noCircuitProtectionDeviceType) {
return {
circuitId,
type: noCircuitProtectionDeviceType,
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
}
return {
circuitId,
type: protection.type,
@ -26,8 +38,8 @@ export function toCircuitProtectionSnapshot(
export function getCircuitProtectionEditorInitialValue(
category: CircuitGroupCategory | undefined,
protection: CircuitTreeProtectionDeviceDto | undefined
): CircuitTreeProtectionDeviceDto {
protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): CircuitTreeCircuitProtectionDeviceDto {
if (protection) {
return protection;
}

View file

@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CircuitTreeCircuitProtectionDevice,
CircuitTreeProtectionDevice,
CircuitTreeResponse,
ProjectCircuitTreesResponse,
@ -17,6 +18,7 @@ import {
externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device.js";
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
@ -289,7 +291,9 @@ export async function buildProjectCircuitTrees(
"de"
)
),
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
protectionDevice: protection
? toCircuitProtectionDeviceDto(protection)
: undefined,
});
}
for (const section of tree.sections) {
@ -358,6 +362,25 @@ function emptyCircuitTree(
};
}
function toCircuitProtectionDeviceDto(
value: Omit<Parameters<typeof toProtectionDeviceDto>[0], "type" | "ratedCurrentA"> & {
type: CircuitTreeCircuitProtectionDevice["type"];
ratedCurrentA: number | null;
}
): CircuitTreeCircuitProtectionDevice {
if (value.type === noCircuitProtectionDeviceType) {
return { type: value.type };
}
if (value.ratedCurrentA === null) {
throw new Error("Circuit protection device lacks a rated current.");
}
return toProtectionDeviceDto({
...value,
type: value.type,
ratedCurrentA: value.ratedCurrentA,
});
}
function toProtectionDeviceDto(value: {
type: CircuitTreeProtectionDevice["type"];
ratedCurrentA: number;

View file

@ -26,6 +26,24 @@ export const protectionDeviceTypes = [
export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number];
// A circuit may explicitly have no protection device ("–"). This differs from
// a circuit without any protection record, which is still open for planning.
// Distribution-board components always carry a real protection device.
export const noCircuitProtectionDeviceType = "NONE" as const;
export type NoCircuitProtectionDeviceType =
typeof noCircuitProtectionDeviceType;
// Display text of the explicit "no protection device" state.
export const noCircuitProtectionDeviceText = "–";
export const circuitProtectionDeviceTypes = [
...protectionDeviceTypes,
noCircuitProtectionDeviceType,
] as const;
export type CircuitProtectionDeviceType =
(typeof circuitProtectionDeviceTypes)[number];
export const fuseUtilizationCategories = ["gG", "gR"] as const;
export type FuseUtilizationCategory =
(typeof fuseUtilizationCategories)[number];
@ -91,6 +109,14 @@ export const protectionDeviceTypeLabels: Record<ProtectionDeviceType, string> =
AFDD: "AFDD",
};
export const circuitProtectionDeviceTypeLabels: Record<
CircuitProtectionDeviceType,
string
> = {
...protectionDeviceTypeLabels,
NONE: "Kein Schutzgerät",
};
export function isAllowedRatedCurrentA(
type: ProtectionDeviceType,
ratedCurrentA: number

View file

@ -5,6 +5,7 @@ import {
fuseUtilizationCategories,
isAllowedRatedCurrentA,
isAllowedRatedResidualCurrentMa,
noCircuitProtectionDeviceType,
protectionDeviceTypes,
rcdTypes,
} from "../constants/protection-device.js";
@ -93,3 +94,40 @@ export const protectionDeviceConfigurationSchema = z
export type ProtectionDeviceConfigurationInput = z.infer<
typeof protectionDeviceConfigurationSchema
>;
// Persisted circuit protection record: nullable columns instead of optional
// fields. Either the explicit "no protection device" state with every value
// null, or one valid protection device configuration.
export function isValidCircuitProtectionRecord(value: Readonly<
Record<string, unknown>
>): boolean {
if (value.type === noCircuitProtectionDeviceType) {
return (
value.ratedCurrentA === null &&
value.fuseUtilizationCategory === null &&
value.tripCharacteristic === null &&
value.rcdType === null &&
value.ratedResidualCurrentMa === null
);
}
return isValidProtectionRecord(value);
}
export function isValidProtectionRecord(value: Readonly<
Record<string, unknown>
>): boolean {
return 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 }),
}).success;
}

View file

@ -160,6 +160,47 @@ describe("circuit grid clipboard", () => {
);
});
it("copies an explicit no-protection decision onto other circuits", () => {
const none = makeCircuit("circuit-5", [makeDevice("row-5", 0.1)], {
protectionDevice: { type: "NONE" },
});
const noneRows = buildVisibleGridRowsWithStructure(
[{ ...sections[0], circuits: [none, target] }],
{ headerComponents: [], footerComponents: [] }
);
const [noneRow, targetRow] = ["circuitCompact:circuit-5", "circuitCompact:circuit-2"].map(
(rowKey) => noneRows.find((row) => row.rowKey === rowKey)!
);
const clipboard = buildCellClipboard([noneRow], ["protectionSummary"]);
assert.equal(clipboard.text, "–");
const plan = planCellWrites([
{ row: targetRow, cellKey: "protectionSummary", value: clipboard.cells[0][0] },
]);
assert.deepEqual(plan.updates.map((update) => update.payload), [
{
circuitId: "circuit-2",
expected: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
target: {
circuitId: "circuit-2",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
},
},
]);
});
it("fills one value into every selected row and skips rows that cannot take it", () => {
const selectedRowKeys = [
"circuitCompact:circuit-1",

View file

@ -197,6 +197,18 @@ describe("circuit grid model", () => {
),
"FI/LS · 16 A · B · Typ A · 30 mA"
);
const withoutDevice = {
...circuit,
protectionDevice: { type: "NONE" as const },
};
assert.equal(getCircuitValue(withoutDevice, "protectionSummary"), "–");
assert.equal(getCircuitValue(withoutDevice, "protectionType"), "–");
assert.equal(getCircuitValue(withoutDevice, "protectionRatedCurrent"), undefined);
// A circuit without any protection record is still open.
assert.equal(
getCircuitValue({ ...circuit, protectionDevice: undefined }, "protectionSummary"),
undefined
);
assert.equal(getCircuitValue(circuit, "voltage"), 230);
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");

View file

@ -135,6 +135,70 @@ describe("circuit-protection project command", () => {
}
});
it("stores an explicit no-protection decision and undoes it to the device", () => {
const context = createTestDatabase();
try {
const repository = new CircuitProtectionProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
context.db.insert(circuitProtectionDevices).values(protection).run();
const none: CircuitProtectionSnapshot = {
circuitId: "circuit-1",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
const executed = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
none
),
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
none
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: executed.inverse,
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
protection
);
for (const invalid of [
{ ...none, ratedCurrentA: 16 },
{ ...none, tripCharacteristic: "B" as const },
{ ...protection, ratedCurrentA: null },
]) {
assert.throws(
() =>
createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
invalid
),
/configuration is invalid/
);
}
} finally {
context.close();
}
});
it("rejects stale state, foreign ownership and user removal", () => {
const context = createTestDatabase();
try {

View file

@ -243,6 +243,7 @@ describe("print document model", () => {
}),
"FI/LS B 16 A, 30 mA Typ A"
);
assert.equal(formatProtectionDevice({ type: "NONE" }), "–");
assert.equal(formatProtectionDevice(undefined), null);
});
});

View file

@ -559,6 +559,44 @@ describe("project state snapshot model", () => {
() => parseProjectStateSnapshot(invalidProtection),
/protection-device configuration is invalid/
);
const protectedState = protectedSnapshot();
const noProtectionRecord = {
circuitId: protectedState.circuitProtectionDevices[0]!.circuitId,
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
assert.equal(
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [noProtectionRecord],
}).circuitProtectionDevices[0]?.type,
"NONE"
);
assert.throws(
() =>
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [
{ ...noProtectionRecord, ratedCurrentA: 16 },
],
}),
/protection-device configuration is invalid/
);
const componentWithoutDevice = {
...protectedState,
distributionBoardComponentProtectionDevices: [
{
...protectedState.distributionBoardComponentProtectionDevices[0]!,
type: "NONE",
},
],
};
assert.throws(() => parseProjectStateSnapshot(componentWithoutDevice));
});
it("rejects duplicate ids and invalid ownership", () => {

View file

@ -205,6 +205,27 @@ describe("protection device defaults", () => {
);
});
it("maps the explicit no-protection state to a record without values", () => {
const none = getCircuitProtectionEditorInitialValue("single_phase", {
type: "NONE",
});
assert.deepEqual(none, { type: "NONE" });
assert.deepEqual(toCircuitProtectionSnapshot("circuit-1", none), {
circuitId: "circuit-1",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
});
// Distribution-board components never take the circuit-only state.
assert.equal(
protectionDeviceConfigurationSchema.safeParse({ type: "NONE" }).success,
false
);
});
it("creates the agreed circuit defaults", () => {
assert.deepEqual(createDefaultCircuitProtection("lighting"), {
type: "LS",