Files
leistungsbilanz-ts/src/domain/models/project-state-snapshot.model.ts
T

1068 lines
33 KiB
TypeScript

import { z } from "zod";
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
import {
circuitGroupCategories,
} from "../../shared/constants/circuit-group.js";
import {
distributionBoardComponentPlacements,
distributionBoardComponentRoles,
} from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
breakerTripCharacteristics,
fuseUtilizationCategories,
protectionDeviceTypes,
rcdTypes,
} from "../../shared/constants/protection-device.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
} from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import {
externalImportKinds,
externalModelSourceTypes,
externalObjectPresenceStatuses,
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1);
const externalIfcGuidSchema = z.string().min(1).refine(
(value) => value === value.trim(),
"Snapshot IFCGUID must not contain outer whitespace."
);
const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite();
const previousProjectSchema = z
.object({
id: idSchema,
name: z.string().trim().min(1),
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
buildingOwner: z.string().trim().min(1).max(200).nullable(),
description: z.string().trim().min(1).max(2000).nullable(),
singlePhaseVoltageV: finiteNumberSchema.positive(),
threePhaseVoltageV: finiteNumberSchema.positive(),
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length),
})
.strict();
const projectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() })
.strict();
const distributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
floorId: idSchema.nullable(),
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
projectId: idSchema,
distributionBoardId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
key: z.string().trim().min(1),
displayName: z.string().trim().min(1),
prefix: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
category: z.enum(circuitGroupCategories).nullable(),
groupNumber: z.number().int().positive().nullable(),
})
.strict()
.superRefine((section, context) => {
if ((section.category === null) !== (section.groupNumber === null)) {
context.addIssue({
code: "custom",
message:
"Circuit-section category and group number must both be set or both be null.",
});
}
});
const circuitDeviceRowSchema = z.preprocess(
(value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
const row = value as Record<string, unknown>;
return Object.prototype.hasOwnProperty.call(row, "manualQuantity")
? row
: { ...row, manualQuantity: row.quantity };
},
z.object({
id: idSchema,
circuitId: idSchema,
linkedProjectDeviceId: idSchema.nullable(),
sortOrder: finiteNumberSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: z.enum(["single_phase", "three_phase"]).nullable(),
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
level: nullableStringSchema,
roomId: idSchema.nullable(),
roomNumberSnapshot: nullableStringSchema,
roomNameSnapshot: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,
})
.strict()
);
const circuitSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema,
equipmentIdentifier: z.string().trim().min(1),
displayName: nullableStringSchema,
sortOrder: finiteNumberSchema,
cableType: nullableStringSchema,
cableCrossSection: nullableStringSchema,
cableLength: finiteNumberSchema.nonnegative().nullable(),
rcdAssignment: nullableStringSchema,
terminalDesignation: nullableStringSchema,
voltage: finiteNumberSchema.positive().nullable(),
controlRequirement: nullableStringSchema,
status: nullableStringSchema,
isReserve: z.boolean(),
remark: nullableStringSchema,
deviceRows: z.array(circuitDeviceRowSchema),
})
.strict();
const projectDeviceSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
remark: nullableStringSchema,
voltageV: finiteNumberSchema.positive().nullable(),
})
.strict();
const floorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const roomSchema = z
.object({
id: idSchema,
projectId: idSchema,
floorId: idSchema.nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
})
.strict();
const distributionBoardComponentSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema.nullable(),
equipmentIdentifier: z.string().trim().min(1),
name: z.string().trim().min(1),
role: z.enum(distributionBoardComponentRoles),
placement: z.enum(distributionBoardComponentPlacements),
sortOrder: finiteNumberSchema,
})
.strict();
const persistedProtectionDeviceFields = {
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
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) {
context.addIssue({
code: "custom",
message: "Snapshot protection-device configuration is invalid.",
});
}
}
const circuitProtectionDeviceSchema = z
.object({
circuitId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const componentProtectionDeviceSchema = z
.object({
componentId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const externalCsvConfigurationSnapshotSchema = z
.object({
id: idSchema,
projectId: idSchema,
configurationVersion: z.number().int().positive(),
configuration: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
})
.strict();
const externalCsvDialectSchema = z.object({
encoding: z.literal("utf-8"),
hasBom: z.boolean(),
delimiter: z.enum([";", ",", "\t"]),
lineEnding: z.enum(["\r\n", "\n", "\r"]),
quoteCharacter: z.literal('"'),
quoteAllFields: z.boolean(),
hasTrailingLineEnding: z.boolean(),
}).strict();
const externalCsvDocumentSchema = z.object({
dialect: externalCsvDialectSchema,
headerRowIndex: z.number().int().nonnegative(),
rows: z.array(z.object({
index: z.number().int().nonnegative(),
cells: z.array(z.object({
value: z.string(),
wasQuoted: z.boolean(),
}).strict()),
classification: z.enum([
"metadata",
"header",
"passthrough",
"object",
"suspect-object",
]),
}).strict()).min(1),
}).strict().superRefine((document, context) => {
if (document.rows[document.headerRowIndex]?.classification !== "header") {
context.addIssue({
code: "custom",
message: "Snapshot external CSV document header is invalid.",
});
}
document.rows.forEach((row, index) => {
if (row.index !== index) {
context.addIssue({
code: "custom",
message: "Snapshot external CSV row indexes must be contiguous.",
});
}
});
});
const externalModelSourceSchema = z.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
}).strict();
const externalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
importKind: z.enum(externalImportKinds),
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
appliedProjectRevision: z.number().int().nonnegative(),
configurationVersion: z.number().int().positive(),
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
originalContentBase64: z.string().min(1).regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
),
document: externalCsvDocumentSchema,
}).strict();
const externalRoomMappingSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
normalizedSourceRoomKey: z.string().trim().min(1),
sourceFloorName: nullableStringSchema,
sourceRoomNumber: z.string(),
sourceRoomName: z.string(),
roomId: idSchema.nullable(),
defaultDistributionBoardId: idSchema.nullable(),
}).strict();
const externalPlanningFieldNames = [
"displayName",
"internalDeviceType",
"category",
"connectionKind",
"effectiveQuantity",
"powerPerUnitW",
"simultaneityFactor",
"cosPhi",
"costGroup",
"remark",
] as const;
const externalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
ifcGuid: externalIfcGuidSchema,
lastSeenImportBatchId: idSchema,
lastAcceptedImportBatchId: idSchema,
acceptedSourceValues: z.object({
rowNumber: z.number().int().positive(),
roomNumber: z.string(),
roomName: z.string(),
familyAndType: z.string(),
selectionMarker: z.string(),
circuitIdentifier: z.string(),
power: z.string(),
quantity: z.string().nullable(),
additionalSourceValues: z.record(z.string(), z.string()),
}).strict(),
planningValues: z.object({
displayName: nullableStringSchema,
internalDeviceType: nullableStringSchema,
category: z.enum(circuitGroupCategories).nullable(),
connectionKind: nullableStringSchema,
effectiveQuantity: z.number().int().positive(),
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).strict(),
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length
),
externalRoomMappingId: idSchema.nullable(),
distributionBoardId: idSchema.nullable(),
linkedProjectDeviceId: idSchema.nullable(),
circuitDeviceRowId: idSchema.nullable(),
presenceStatus: z.enum(externalObjectPresenceStatuses),
}).strict();
const externalModelStateSchema = z.object({
source: externalModelSourceSchema.nullable(),
importBatches: z.array(externalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(externalModelObjectSchema),
}).strict();
export function parseExternalModelStateSnapshot(
value: unknown
): ExternalModelStateSnapshot {
return externalModelStateSchema.parse(value);
}
const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
distributionBoardComponentProtectionDevices: z.array(
componentProtectionDeviceSchema
),
};
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const externalModelProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
})
.strict();
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
const baselineProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
const parsedSnapshot = projectStateSnapshotSchema.parse(
upgradePreviousProjectStateSnapshot(value)
);
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
if (
value === null ||
typeof value !== "object" ||
Array.isArray(value) ||
typeof (value as { schemaVersion?: unknown }).schemaVersion !== "number"
) {
return value;
}
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
};
}
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
const previous = externalModelProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
externalModel: emptyExternalModelState(),
};
}
if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) {
const legacy = legacyProjectStateSnapshotSchema.parse(value);
return {
...legacy,
schemaVersion: projectStateSnapshotSchemaVersion,
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
const baseline = baselineProjectStateSnapshotSchema.parse(value);
return {
...baseline,
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
return value;
}
function emptyExternalModelState() {
return {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
};
}
function normalizeSnapshotPhaseTypes(
snapshot: ProjectStateSnapshot
): ProjectStateSnapshot {
const sectionById = new Map(
snapshot.circuitSections.map((section) => [section.id, section])
);
const projectDeviceById = new Map(
snapshot.projectDevices.map((device) => [device.id, device])
);
return {
...snapshot,
circuits: snapshot.circuits.map((circuit) => {
const section = sectionById.get(circuit.sectionId);
const deviceRows = circuit.deviceRows.map((row) => {
const linkedPhaseType = row.linkedProjectDeviceId
? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType
: undefined;
return {
...row,
phaseType:
row.phaseType ??
linkedPhaseType ??
(section?.key === "three_phase"
? "three_phase"
: "single_phase"),
};
});
return {
...circuit,
voltage: section
? resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
deviceRows.map((row) => row.phaseType)
),
snapshot.project
)
: circuit.voltage,
deviceRows,
};
}),
};
}
export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot
): string {
return JSON.stringify(parseProjectStateSnapshot(snapshot));
}
export function deserializeProjectStateSnapshot(
serialized: string
): ProjectStateSnapshot {
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
}
function assertProjectStateSnapshotRelations(
snapshot: ProjectStateSnapshot
) {
const projectId = snapshot.project.id;
if (
snapshot.externalCsvConfiguration !== null &&
snapshot.externalCsvConfiguration.projectId !== projectId
) {
throw new Error("Snapshot external CSV configuration belongs to a different project.");
}
const boardIds = uniqueIds(
snapshot.distributionBoards,
"distribution board"
);
const circuitListIds = uniqueIds(
snapshot.circuitLists,
"circuit list"
);
const sectionIds = uniqueIds(
snapshot.circuitSections,
"circuit section"
);
const projectDeviceIds = uniqueIds(
snapshot.projectDevices,
"project device"
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
const componentIds = uniqueIds(
snapshot.distributionBoardComponents,
"distribution board component"
);
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
const componentById = new Map(
snapshot.distributionBoardComponents.map((entry) => [
entry.id,
entry,
])
);
const groupKeys = new Set<string>();
const equipmentIdentifiersByList = new Map<string, Set<string>>();
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
if (board.floorId !== null) {
assertReference(
floorIds,
board.floorId,
"distribution board floor"
);
}
if (
board.supplyType !== null &&
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
board.supplyType
)
) {
throw new Error(
"Snapshot distribution board uses a disabled supply type."
);
}
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
assertReference(
boardIds,
list.distributionBoardId,
"circuit list distribution board"
);
}
for (const section of snapshot.circuitSections) {
assertReference(
circuitListIds,
section.circuitListId,
"circuit section list"
);
if (section.category !== null && section.groupNumber !== null) {
const groupKey = [
section.circuitListId,
section.category,
section.groupNumber,
].join(":");
if (groupKeys.has(groupKey)) {
throw new Error(
"Snapshot contains a duplicate circuit group number."
);
}
groupKeys.add(groupKey);
}
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
if (
device.voltageV !==
resolveProjectVoltage(device.phaseType, snapshot.project)
) {
throw new Error(
"Snapshot project device voltage must match project settings."
);
}
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");
if (room.floorId !== null) {
assertReference(floorIds, room.floorId, "room floor");
}
}
const deviceRowIds = new Set<string>();
for (const circuit of snapshot.circuits) {
assertReference(
circuitListIds,
circuit.circuitListId,
"circuit list"
);
assertReference(sectionIds, circuit.sectionId, "circuit section");
const section = sectionById.get(circuit.sectionId)!;
if (section.circuitListId !== circuit.circuitListId) {
throw new Error(
"Snapshot circuit section belongs to a different circuit list."
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Snapshot circuit reserve state must match its device rows."
);
}
const expectedVoltage = resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
circuit.deviceRows.map((row) => row.phaseType)
),
snapshot.project
);
if (circuit.voltage !== expectedVoltage) {
throw new Error(
"Snapshot circuit voltage must match project settings."
);
}
registerEquipmentIdentifier(
equipmentIdentifiersByList,
circuit.circuitListId,
circuit.equipmentIdentifier
);
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
"Snapshot device row belongs to a different circuit."
);
}
if (deviceRowIds.has(row.id)) {
throw new Error("Snapshot contains duplicate device row ids.");
}
deviceRowIds.add(row.id);
if (row.linkedProjectDeviceId !== null) {
assertReference(
projectDeviceIds,
row.linkedProjectDeviceId,
"device row project device"
);
}
if (row.roomId !== null) {
assertReference(roomIds, row.roomId, "device row room");
}
if (row.manualQuantity > row.quantity) {
throw new Error(
"Snapshot device row manual quantity must not exceed total quantity."
);
}
}
}
const fixedComponentRolesByList = new Set<string>();
const groupComponentRolesBySection = new Set<string>();
for (const component of snapshot.distributionBoardComponents) {
assertReference(
circuitListIds,
component.circuitListId,
"distribution board component circuit list"
);
if (component.sectionId !== null) {
assertReference(
sectionIds,
component.sectionId,
"distribution board component section"
);
if (
sectionById.get(component.sectionId)?.circuitListId !==
component.circuitListId
) {
throw new Error(
"Snapshot distribution board component section belongs to a different circuit list."
);
}
}
assertComponentPlacement(component);
registerEquipmentIdentifier(
equipmentIdentifiersByList,
component.circuitListId,
component.equipmentIdentifier
);
if (
component.role === "main_switch" ||
component.role === "surge_protective_device"
) {
assertUniqueKey(
fixedComponentRolesByList,
`${component.circuitListId}:${component.role}`,
"Snapshot contains duplicate fixed distribution board components."
);
}
if (
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection"
) {
assertUniqueKey(
groupComponentRolesBySection,
`${component.sectionId}:${component.role}`,
"Snapshot contains duplicate group protection components."
);
}
}
const protectedCircuitIds = new Set<string>();
for (const protection of snapshot.circuitProtectionDevices) {
assertReference(
circuitIds,
protection.circuitId,
"circuit protection device circuit"
);
assertUniqueKey(
protectedCircuitIds,
protection.circuitId,
"Snapshot contains duplicate circuit protection devices."
);
}
const protectedComponentIds = new Set<string>();
for (const protection of snapshot.distributionBoardComponentProtectionDevices) {
assertReference(
componentIds,
protection.componentId,
"component protection device component"
);
assertUniqueKey(
protectedComponentIds,
protection.componentId,
"Snapshot contains duplicate component protection devices."
);
const component = componentById.get(protection.componentId)!;
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection"
) {
throw new Error(
"Snapshot protection data belongs to a non-protection component."
);
}
}
assertExternalModelRelations(snapshot, {
boardIds,
deviceRowIds,
projectDeviceIds,
projectId,
roomIds,
});
}
function assertExternalModelRelations(
snapshot: ProjectStateSnapshot,
references: {
boardIds: ReadonlySet<string>;
deviceRowIds: ReadonlySet<string>;
projectDeviceIds: ReadonlySet<string>;
projectId: string;
roomIds: ReadonlySet<string>;
}
) {
const { source, importBatches, roomMappings, objects } = snapshot.externalModel;
if (source === null) {
if (importBatches.length || roomMappings.length || objects.length) {
throw new Error("Snapshot external model entries require a source.");
}
return;
}
assertProjectOwnership(source.projectId, references.projectId, "external model source");
const batchIds = uniqueIds(importBatches, "external import batch");
const mappingIds = uniqueIds(roomMappings, "external room mapping");
uniqueIds(objects, "external model object");
for (const batch of importBatches) {
assertProjectOwnership(batch.projectId, references.projectId, "external import batch");
if (batch.sourceId !== source.id) {
throw new Error("Snapshot external import batch belongs to a different source.");
}
}
const roomKeys = new Set<string>();
for (const mapping of roomMappings) {
assertProjectOwnership(mapping.projectId, references.projectId, "external room mapping");
if (mapping.sourceId !== source.id) {
throw new Error("Snapshot external room mapping belongs to a different source.");
}
assertUniqueKey(
roomKeys,
mapping.normalizedSourceRoomKey,
"Snapshot contains duplicate external room keys."
);
if (mapping.roomId !== null) {
assertReference(references.roomIds, mapping.roomId, "external room mapping room");
}
if (mapping.defaultDistributionBoardId !== null) {
assertReference(
references.boardIds,
mapping.defaultDistributionBoardId,
"external room mapping distribution board"
);
}
}
const ifcGuids = new Set<string>();
for (const object of objects) {
assertProjectOwnership(object.projectId, references.projectId, "external model object");
if (object.sourceId !== source.id) {
throw new Error("Snapshot external model object belongs to a different source.");
}
assertUniqueKey(
ifcGuids,
object.ifcGuid,
"Snapshot contains duplicate external IFCGUIDs."
);
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
if (object.externalRoomMappingId !== null) {
assertReference(mappingIds, object.externalRoomMappingId, "external object room mapping");
}
if (object.distributionBoardId !== null) {
assertReference(references.boardIds, object.distributionBoardId, "external object distribution board");
}
if (object.linkedProjectDeviceId !== null) {
assertReference(references.projectDeviceIds, object.linkedProjectDeviceId, "external object project device");
}
if (object.circuitDeviceRowId !== null) {
assertReference(references.deviceRowIds, object.circuitDeviceRowId, "external object device row");
}
}
}
function assertComponentPlacement(
component: ProjectStateSnapshot["distributionBoardComponents"][number]
) {
const isFixedHeader =
component.role === "main_switch" ||
component.role === "surge_protective_device";
const isGroupProtection =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection";
if (
(isFixedHeader &&
(component.placement !== "header" ||
component.sectionId !== null)) ||
(isGroupProtection &&
(component.placement !== "group" ||
component.sectionId === null)) ||
(component.role === "auxiliary" &&
(component.placement !== "footer" ||
component.sectionId !== null))
) {
throw new Error(
"Snapshot distribution board component placement is invalid."
);
}
}
function registerEquipmentIdentifier(
identifiersByList: Map<string, Set<string>>,
circuitListId: string,
equipmentIdentifier: string
) {
const identifiers =
identifiersByList.get(circuitListId) ?? new Set<string>();
const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de");
if (identifiers.has(normalized)) {
throw new Error(
"Snapshot contains duplicate equipment identifiers in one circuit list."
);
}
identifiers.add(normalized);
identifiersByList.set(circuitListId, identifiers);
}
function assertUniqueKey(
keys: Set<string>,
key: string,
message: string
) {
if (keys.has(key)) {
throw new Error(message);
}
keys.add(key);
}
function uniqueIds(
entries: ReadonlyArray<{ id: string }>,
label: string
) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`Snapshot contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function assertProjectOwnership(
actualProjectId: string,
expectedProjectId: string,
label: string
) {
if (actualProjectId !== expectedProjectId) {
throw new Error(`Snapshot ${label} belongs to a different project.`);
}
}
function assertReference(
ids: ReadonlySet<string>,
id: string,
label: string
) {
if (!ids.has(id)) {
throw new Error(`Snapshot ${label} reference is invalid.`);
}
}