Add recursive distribution board load projection

This commit is contained in:
Julian Appel 2026-09-27 22:31:56 +02:00
parent 855a04e655
commit dfeb0e3cb6
11 changed files with 701 additions and 138 deletions

View file

@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq, inArray } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js"; import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
.orderBy(asc(circuitSections.sortOrder)); .orderBy(asc(circuitSections.sortOrder));
} }
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuitSections)
.where(inArray(circuitSections.circuitListId, circuitListIds))
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id));
}
async createDefaults(circuitListId: string) { async createDefaults(circuitListId: string) {
const db = this.database; const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) { for (const entry of createDefaultCircuitSectionValues(circuitListId)) {

View file

@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm"; import { asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
@ -14,6 +14,15 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
} }
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuits)
.where(inArray(circuits.circuitListId, circuitListIds))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async listBySection(sectionId: string) { async listBySection(sectionId: string) {
const db = this.database; const db = this.database;
return db return db

View file

@ -19,6 +19,18 @@ export class DistributionBoardComponentRepository {
); );
} }
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(distributionBoardComponents)
.where(inArray(distributionBoardComponents.circuitListId, circuitListIds))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
);
}
async listProtectionByComponentIds(componentIds: string[]) { async listProtectionByComponentIds(componentIds: string[]) {
if (componentIds.length === 0) { if (componentIds.length === 0) {
return []; return [];

View file

@ -0,0 +1,217 @@
export interface DistributionBoardLoadBoardInput {
id: string;
name: string;
boardNumber: string | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export interface DistributionBoardLoadCircuitInput {
id: string;
distributionBoardId: string;
rows: Array<{
activePowerKw: number;
cosPhi: number | null;
}>;
}
export interface DownstreamDistributionBoardLoad {
distributionBoardId: string;
name: string;
boardNumber: string | null;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CalculatedCircuitLoad {
circuitId: string;
deviceActivePowerKw: number;
deviceApparentPowerKva: number;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
downstreamBoards: DownstreamDistributionBoardLoad[];
}
export interface CalculatedDistributionBoardLoad {
distributionBoardId: string;
activePowerBeforeSimultaneityKw: number;
apparentPowerBeforeSimultaneityKva: number;
demandActivePowerKw: number;
demandApparentPowerKva: number;
cosPhi: number;
}
export interface DistributionBoardLoadCalculation {
boards: Map<string, CalculatedDistributionBoardLoad>;
circuits: Map<string, CalculatedCircuitLoad>;
}
/**
* Calculates one project graph. A child board enters exactly one feeder
* circuit with its demand after the child's own board simultaneity factor.
* Board-only relations form a hierarchy but intentionally add no load.
*/
export function calculateDistributionBoardLoads(input: {
boards: DistributionBoardLoadBoardInput[];
circuits: DistributionBoardLoadCircuitInput[];
}): DistributionBoardLoadCalculation {
const boardById = new Map(input.boards.map((board) => [board.id, board]));
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
const circuitsByBoardId = groupBy(
input.circuits,
(circuit) => circuit.distributionBoardId
);
const childrenByCircuitId = new Map<string, DistributionBoardLoadBoardInput[]>();
for (const board of input.boards) {
assertBoardInput(board, boardById, circuitById);
if (board.feedingCircuitId === null) continue;
const children = childrenByCircuitId.get(board.feedingCircuitId) ?? [];
children.push(board);
childrenByCircuitId.set(board.feedingCircuitId, children);
}
const boards = new Map<string, CalculatedDistributionBoardLoad>();
const circuits = new Map<string, CalculatedCircuitLoad>();
const activePath = new Set<string>();
const calculateBoard = (boardId: string): CalculatedDistributionBoardLoad => {
const cached = boards.get(boardId);
if (cached) return cached;
if (activePath.has(boardId)) {
throw new Error("Distribution-board feeding graph contains a cycle.");
}
const board = boardById.get(boardId);
if (!board) throw new Error("Distribution board is missing from load graph.");
activePath.add(boardId);
let activePowerBeforeSimultaneityKw = 0;
let apparentPowerBeforeSimultaneityKva = 0;
for (const circuit of circuitsByBoardId.get(boardId) ?? []) {
let deviceActivePowerKw = 0;
let deviceApparentPowerKva = 0;
for (const row of circuit.rows) {
assertNonNegativeFinite(row.activePowerKw, "row active power");
const cosPhi = row.cosPhi ?? 1;
if (!Number.isFinite(cosPhi) || cosPhi <= 0 || cosPhi > 1) {
throw new Error("Row cos phi must be greater than zero and at most one.");
}
deviceActivePowerKw += row.activePowerKw;
deviceApparentPowerKva += row.activePowerKw / cosPhi;
}
const downstreamBoards = (childrenByCircuitId.get(circuit.id) ?? [])
.map((child) => {
const childLoad = calculateBoard(child.id);
return {
distributionBoardId: child.id,
name: child.name,
boardNumber: child.boardNumber,
activePowerKw: childLoad.demandActivePowerKw,
apparentPowerKva: childLoad.demandApparentPowerKva,
cosPhi: effectiveCosPhi(
childLoad.demandActivePowerKw,
childLoad.demandApparentPowerKva
),
};
});
const activePowerKw =
deviceActivePowerKw +
downstreamBoards.reduce((sum, child) => sum + child.activePowerKw, 0);
const apparentPowerKva =
deviceApparentPowerKva +
downstreamBoards.reduce((sum, child) => sum + child.apparentPowerKva, 0);
circuits.set(circuit.id, {
circuitId: circuit.id,
deviceActivePowerKw,
deviceApparentPowerKva,
activePowerKw,
apparentPowerKva,
cosPhi: effectiveCosPhi(activePowerKw, apparentPowerKva),
downstreamBoards,
});
activePowerBeforeSimultaneityKw += activePowerKw;
apparentPowerBeforeSimultaneityKva += apparentPowerKva;
}
activePath.delete(boardId);
const result = {
distributionBoardId: boardId,
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva,
demandActivePowerKw:
activePowerBeforeSimultaneityKw * board.simultaneityFactor,
demandApparentPowerKva:
apparentPowerBeforeSimultaneityKva * board.simultaneityFactor,
cosPhi: effectiveCosPhi(
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva
),
};
boards.set(boardId, result);
return result;
};
for (const board of input.boards) calculateBoard(board.id);
return { boards, circuits };
}
function assertBoardInput(
board: DistributionBoardLoadBoardInput,
boardById: Map<string, DistributionBoardLoadBoardInput>,
circuitById: Map<string, DistributionBoardLoadCircuitInput>
) {
if (
!Number.isFinite(board.simultaneityFactor) ||
board.simultaneityFactor < 0 ||
board.simultaneityFactor > 1
) {
throw new Error("Board simultaneity factor must be between zero and one.");
}
if (board.feedingDistributionBoardId === board.id) {
throw new Error("Distribution board cannot feed itself.");
}
if (
board.feedingDistributionBoardId !== null &&
!boardById.has(board.feedingDistributionBoardId)
) {
throw new Error("Feeding distribution board is missing from load graph.");
}
if (
board.feedingCircuitId !== null &&
board.feedingDistributionBoardId === null
) {
throw new Error("Feeding circuit requires a feeding distribution board.");
}
if (
board.feedingCircuitId !== null &&
circuitById.get(board.feedingCircuitId)?.distributionBoardId !==
board.feedingDistributionBoardId
) {
throw new Error("Feeding circuit belongs to another distribution board.");
}
}
function effectiveCosPhi(activePowerKw: number, apparentPowerKva: number) {
return activePowerKw > 0 && apparentPowerKva > 0
? activePowerKw / apparentPowerKva
: 1;
}
function assertNonNegativeFinite(value: number, label: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a non-negative finite number.`);
}
}
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
const groups = new Map<string, T[]>();
for (const value of values) {
const key = keyOf(value);
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}

View file

@ -40,7 +40,10 @@ export interface CircuitTreeCircuit {
isReserve: boolean; isReserve: boolean;
remark?: string; remark?: string;
circuitTotalPower: number; circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRow[]; deviceRows: CircuitTreeDeviceRow[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
protectionDevice?: CircuitTreeProtectionDevice; protectionDevice?: CircuitTreeProtectionDevice;
} }
@ -59,6 +62,11 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse { export interface CircuitTreeResponse {
circuitListId: string; circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReference;
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
@ -70,7 +78,35 @@ export interface CircuitTreeResponse {
footerComponents: CircuitTreeComponent[]; footerComponents: CircuitTreeComponent[];
} }
export interface CircuitTreeDownstreamBoardLoad {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReference {
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponse {
currentRevision: number;
trees: CircuitTreeResponse[];
}
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { import type {
DistributionBoardComponentPlacement, DistributionBoardComponentPlacement,
DistributionBoardComponentRole, DistributionBoardComponentRole,

View file

@ -444,7 +444,10 @@ export interface CircuitTreeCircuitDto {
isReserve: boolean; isReserve: boolean;
remark?: string; remark?: string;
circuitTotalPower: number; circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRowDto[]; deviceRows: CircuitTreeDeviceRowDto[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto; protectionDevice?: CircuitTreeProtectionDeviceDto;
} }
@ -463,6 +466,11 @@ export interface CircuitTreeSectionDto {
export interface CircuitTreeResponseDto { export interface CircuitTreeResponseDto {
circuitListId: string; circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReferenceDto;
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
@ -474,6 +482,31 @@ export interface CircuitTreeResponseDto {
footerComponents: CircuitTreeComponentDto[]; footerComponents: CircuitTreeComponentDto[];
} }
export interface CircuitTreeDownstreamBoardLoadDto {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReferenceDto {
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponseDto {
currentRevision: number;
trees: CircuitTreeResponseDto[];
}
export interface ExternalCircuitListObjectDto { export interface ExternalCircuitListObjectDto {
id: string; id: string;
ifcGuid: string; ifcGuid: string;

View file

@ -33,6 +33,7 @@ import type {
ProjectDto, ProjectDto,
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto,
ExternalCsvConfigurationSnapshotDto, ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto, ExternalCsvPreviewDto,
ExternalInitialImportPlanDto, ExternalInitialImportPlanDto,
@ -529,6 +530,12 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
} }
export function getProjectCircuitTrees(projectId: string) {
return request<ProjectCircuitTreesResponseDto>(
`/api/projects/${projectId}/circuit-trees`
);
}
export function insertDistributionBoardComponentCommand( export function insertDistributionBoardComponentCommand(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,

View file

@ -1,13 +1,11 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js";
applyDistributionBoardSimultaneityFactor, import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
calculateCircuitTotalPower, import type {
calculateDistributionBoardTotalPower, CircuitTreeProtectionDevice,
calculateRowTotalPower, CircuitTreeResponse,
calculateSectionTotalPower, ProjectCircuitTreesResponse,
} from "../../domain/calculations/circuit-power-calculation.js"; } from "../../domain/models/circuit-tree.model.js";
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
import { import {
circuitDeviceRowRepository, circuitDeviceRowRepository,
circuitListRepository, circuitListRepository,
@ -20,9 +18,7 @@ import {
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
export function isMissingCircuitTreeSchemaError(error: unknown): boolean { export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) { if (!(error instanceof Error)) return false;
return false;
}
return ( return (
error.message.includes("no such table: circuit_sections") || error.message.includes("no such table: circuit_sections") ||
error.message.includes("no such table: circuits") || error.message.includes("no such table: circuits") ||
@ -37,70 +33,143 @@ export async function getCircuitTree(req: Request, res: Response) {
if (typeof projectId !== "string" || typeof circuitListId !== "string") { if (typeof projectId !== "string" || typeof circuitListId !== "string") {
return res.status(400).json({ error: "Invalid parameters" }); return res.status(400).json({ error: "Invalid parameters" });
} }
const list = await circuitListRepository.findById(projectId, circuitListId); const list = await circuitListRepository.findById(projectId, circuitListId);
if (!list) { if (!list) return res.status(404).json({ error: "Circuit list not found" });
return res.status(404).json({ error: "Circuit list not found" });
try {
const result = await buildProjectCircuitTrees(projectId);
if (!result) return res.status(404).json({ error: "Project not found" });
const tree = result.trees.find((entry) => entry.circuitListId === circuitListId);
if (!tree) {
return res.status(404).json({ error: "Distribution board not found" });
} }
return res.json(tree);
} catch (error) {
if (isMissingCircuitTreeSchemaError(error)) {
const project = await projectRepository.findById(projectId); const project = await projectRepository.findById(projectId);
if (!project) { const board = await distributionBoardRepository.findById(
return res.status(404).json({ error: "Project not found" });
}
const distributionBoard =
await distributionBoardRepository.findById(
projectId, projectId,
list.distributionBoardId list.distributionBoardId
); );
if (!distributionBoard) { if (!project || !board) {
return res.status(404).json({ return res.status(404).json({ error: "Project structure not found" });
error: "Distribution board not found",
});
} }
return res.json(emptyCircuitTree(project, board, circuitListId));
}
throw error;
}
}
export async function getProjectCircuitTrees(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
try { try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId); const result = await buildProjectCircuitTrees(projectId);
const circuits = await circuitRepository.listByCircuitList(circuitListId); if (!result) return res.status(404).json({ error: "Project not found" });
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)); return res.json(result);
const components = } catch (error) {
await distributionBoardComponentRepository.listByCircuitList( if (isMissingCircuitTreeSchemaError(error)) {
circuitListId const project = await projectRepository.findById(projectId);
); if (!project) return res.status(404).json({ error: "Project not found" });
const [circuitProtections, componentProtections] = return res.json({ currentRevision: project.currentRevision, trees: [] });
await Promise.all([ }
circuitProtectionDeviceRepository.listByCircuitIds( throw error;
circuits.map((entry) => entry.id) }
), }
export async function buildProjectCircuitTrees(
projectId: string
): Promise<ProjectCircuitTreesResponse | null> {
const project = await projectRepository.findById(projectId);
if (!project) return null;
const [lists, boards] = await Promise.all([
circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
]);
const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds),
circuitRepository.listByCircuitLists(listIds),
distributionBoardComponentRepository.listByCircuitLists(listIds),
]);
const [rows, circuitProtections, componentProtections] = await Promise.all([
circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)),
circuitProtectionDeviceRepository.listByCircuitIds(circuits.map((entry) => entry.id)),
distributionBoardComponentRepository.listProtectionByComponentIds( distributionBoardComponentRepository.listProtectionByComponentIds(
components.map((entry) => entry.id) components.map((entry) => entry.id)
), ),
]); ]);
const boardById = new Map(boards.map((board) => [board.id, board]));
const listById = new Map(lists.map((list) => [list.id, list]));
const sectionById = new Map(sections.map((section) => [section.id, section])); const sectionById = new Map(sections.map((section) => [section.id, section]));
const rowsByCircuitId = new Map<string, typeof rows>(); const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit]));
const rowsByCircuitId = groupBy(rows, (row) => row.circuitId);
const sectionsByListId = groupBy(sections, (section) => section.circuitListId);
const circuitsByListId = groupBy(circuits, (circuit) => circuit.circuitListId);
const componentsByListId = groupBy(components, (component) => component.circuitListId);
const circuitProtectionById = new Map( const circuitProtectionById = new Map(
circuitProtections.map((entry) => [entry.circuitId, entry]) circuitProtections.map((entry) => [entry.circuitId, entry])
); );
const componentProtectionById = new Map( const componentProtectionById = new Map(
componentProtections.map((entry) => [entry.componentId, entry]) componentProtections.map((entry) => [entry.componentId, entry])
); );
for (const row of rows) {
if (!rowsByCircuitId.has(row.circuitId)) {
rowsByCircuitId.set(row.circuitId, []);
}
rowsByCircuitId.get(row.circuitId)!.push(row);
}
const loads = calculateDistributionBoardLoads({
boards: boards.map((board) => ({
id: board.id,
name: board.name,
boardNumber: board.boardNumber,
simultaneityFactor: board.simultaneityFactor,
feedingDistributionBoardId: board.feedingDistributionBoardId,
feedingCircuitId: board.feedingCircuitId,
})),
circuits: circuits.map((circuit) => {
const list = listById.get(circuit.circuitListId);
if (!list) throw new Error("Circuit list is missing from project tree.");
return {
id: circuit.id,
distributionBoardId: list.distributionBoardId,
rows: (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
activePowerKw: calculateRowTotalPower(
row.quantity,
row.powerPerUnit,
row.simultaneityFactor
),
cosPhi: row.cosPhi,
})),
};
}),
});
const trees = lists.map((list): CircuitTreeResponse => {
const board = boardById.get(list.distributionBoardId);
if (!board) throw new Error("Distribution board is missing from project tree.");
const boardLoad = loads.boards.get(board.id);
if (!boardLoad) throw new Error("Distribution-board load is missing.");
const tree: CircuitTreeResponse = { const tree: CircuitTreeResponse = {
circuitListId, circuitListId: list.id,
distributionBoardId: board.id,
distributionBoardName: board.name,
distributionBoardNumber: board.boardNumber ?? undefined,
distributionBoardSupplyType: board.supplyType ?? undefined,
feedingReference: resolveFeedingReference(
board,
boardById,
circuitById,
sectionById
),
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV, singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor: distributionBoardSimultaneityFactor: board.simultaneityFactor,
distributionBoard.simultaneityFactor, distributionBoardTotalPower: boardLoad.activePowerBeforeSimultaneityKw,
distributionBoardTotalPower: 0, distributionBoardTotalPowerWithSimultaneityFactor:
distributionBoardTotalPowerWithSimultaneityFactor: 0, boardLoad.demandActivePowerKw,
headerComponents: [], headerComponents: [],
sections: sections.map((section) => ({ sections: (sectionsByListId.get(list.id) ?? []).map((section) => ({
id: section.id, id: section.id,
key: section.key, key: section.key,
displayName: section.displayName, displayName: section.displayName,
@ -115,9 +184,8 @@ export async function getCircuitTree(req: Request, res: Response) {
footerComponents: [], footerComponents: [],
}; };
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section])); const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
for (const component of components) { for (const component of componentsByListId.get(list.id) ?? []) {
const protection = const protection = componentProtectionById.get(component.id);
componentProtectionById.get(component.id);
const dto = { const dto = {
id: component.id, id: component.id,
circuitListId: component.circuitListId, circuitListId: component.circuitListId,
@ -127,24 +195,18 @@ export async function getCircuitTree(req: Request, res: Response) {
role: component.role, role: component.role,
placement: component.placement, placement: component.placement,
sortOrder: component.sortOrder, sortOrder: component.sortOrder,
protectionDevice: protection protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
? toProtectionDeviceDto(protection)
: undefined,
}; };
if (component.placement === "header") { if (component.placement === "header") tree.headerComponents.push(dto);
tree.headerComponents.push(dto); else if (component.placement === "footer") tree.footerComponents.push(dto);
} else if (component.placement === "footer") { else if (component.sectionId !== null) {
tree.footerComponents.push(dto);
} else if (component.sectionId !== null) {
sectionBlocks.get(component.sectionId)?.components.push(dto); sectionBlocks.get(component.sectionId)?.components.push(dto);
} }
} }
for (const circuit of circuits) { for (const circuit of circuitsByListId.get(list.id) ?? []) {
const section = sectionById.get(circuit.sectionId); const block = sectionBlocks.get(circuit.sectionId);
if (!section || section.circuitListId !== circuit.circuitListId) { if (!block) continue;
continue;
}
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({ const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
id: row.id, id: row.id,
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
@ -166,12 +228,16 @@ export async function getCircuitTree(req: Request, res: Response) {
cosPhi: row.cosPhi ?? undefined, cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined, remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined, overriddenFields: row.overriddenFields ?? undefined,
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), rowTotalPower: calculateRowTotalPower(
row.quantity,
row.powerPerUnit,
row.simultaneityFactor
),
})); }));
const circuitTotalPower = calculateCircuitTotalPower(deviceRows); const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing.");
const protection = circuitProtectionById.get(circuit.id); const protection = circuitProtectionById.get(circuit.id);
block.circuits.push({
sectionBlocks.get(section.id)?.circuits.push({
id: circuit.id, id: circuit.id,
circuitListId: circuit.circuitListId, circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId, sectionId: circuit.sectionId,
@ -188,37 +254,81 @@ export async function getCircuitTree(req: Request, res: Response) {
status: circuit.status ?? undefined, status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve), isReserve: Boolean(circuit.isReserve),
remark: circuit.remark ?? undefined, remark: circuit.remark ?? undefined,
circuitTotalPower, circuitTotalPower: circuitLoad.activePowerKw,
circuitApparentPower: circuitLoad.apparentPowerKva,
circuitCosPhi: circuitLoad.cosPhi,
deviceRows, deviceRows,
protectionDevice: protection downstreamBoardLoads: circuitLoad.downstreamBoards
? toProtectionDeviceDto(protection) .map((load) => ({
: undefined, distributionBoardId: load.distributionBoardId,
name: load.name,
boardNumber: load.boardNumber ?? undefined,
supplyType: boardById.get(load.distributionBoardId)?.supplyType ?? undefined,
activePowerKw: load.activePowerKw,
apparentPowerKva: load.apparentPowerKva,
cosPhi: load.cosPhi,
}))
.sort((left, right) =>
(left.boardNumber ?? left.name).localeCompare(
right.boardNumber ?? right.name,
"de"
)
),
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
}); });
} }
for (const section of tree.sections) { for (const section of tree.sections) {
section.sectionTotalPower = calculateSectionTotalPower( section.sectionTotalPower = section.circuits.reduce(
section.circuits (sum, circuit) => sum + circuit.circuitTotalPower,
0
); );
} }
tree.distributionBoardTotalPower = return tree;
calculateDistributionBoardTotalPower(tree.sections); });
tree.distributionBoardTotalPowerWithSimultaneityFactor =
applyDistributionBoardSimultaneityFactor(
tree.distributionBoardTotalPower,
tree.distributionBoardSimultaneityFactor
);
return res.json(tree); return { currentRevision: project.currentRevision, trees };
} catch (error) { }
if (isMissingCircuitTreeSchemaError(error)) {
return res.json({ function resolveFeedingReference(
board: Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number],
boardById: Map<string, Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number]>,
circuitById: Map<string, Awaited<ReturnType<typeof circuitRepository.listByCircuitLists>>[number]>,
sectionById: Map<string, Awaited<ReturnType<typeof circuitSectionRepository.listByCircuitLists>>[number]>
) {
if (board.feedingDistributionBoardId === null) return undefined;
const feedingBoard = boardById.get(board.feedingDistributionBoardId);
if (!feedingBoard) throw new Error("Feeding distribution board is missing.");
const feedingCircuit = board.feedingCircuitId === null
? undefined
: circuitById.get(board.feedingCircuitId);
return {
distributionBoardId: feedingBoard.id,
distributionBoardName: feedingBoard.name,
distributionBoardNumber: feedingBoard.boardNumber ?? undefined,
circuitId: feedingCircuit?.id,
circuitEquipmentIdentifier: feedingCircuit?.equipmentIdentifier,
circuitDisplayName: feedingCircuit?.displayName ?? undefined,
circuitCategory: feedingCircuit
? sectionById.get(feedingCircuit.sectionId)?.category ?? undefined
: undefined,
};
}
function emptyCircuitTree(
project: NonNullable<Awaited<ReturnType<typeof projectRepository.findById>>>,
board: NonNullable<Awaited<ReturnType<typeof distributionBoardRepository.findById>>>,
circuitListId: string
): CircuitTreeResponse & { warning: string } {
return {
circuitListId, circuitListId,
distributionBoardId: board.id,
distributionBoardName: board.name,
distributionBoardNumber: board.boardNumber ?? undefined,
distributionBoardSupplyType: board.supplyType ?? undefined,
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV, singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor: distributionBoardSimultaneityFactor: board.simultaneityFactor,
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0, distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0, distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [], headerComponents: [],
@ -226,10 +336,7 @@ export async function getCircuitTree(req: Request, res: Response) {
footerComponents: [], footerComponents: [],
warning: warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
}); };
}
throw error;
}
} }
function toProtectionDeviceDto(value: { function toProtectionDeviceDto(value: {
@ -243,11 +350,20 @@ function toProtectionDeviceDto(value: {
return { return {
type: value.type, type: value.type,
ratedCurrentA: value.ratedCurrentA, ratedCurrentA: value.ratedCurrentA,
fuseUtilizationCategory: fuseUtilizationCategory: value.fuseUtilizationCategory ?? undefined,
value.fuseUtilizationCategory ?? undefined,
tripCharacteristic: value.tripCharacteristic ?? undefined, tripCharacteristic: value.tripCharacteristic ?? undefined,
rcdType: value.rcdType ?? undefined, rcdType: value.rcdType ?? undefined,
ratedResidualCurrentMa: ratedResidualCurrentMa: value.ratedResidualCurrentMa ?? undefined,
value.ratedResidualCurrentMa ?? undefined,
}; };
} }
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
const groups = new Map<string, T[]>();
for (const value of values) {
const key = keyOf(value);
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}

View file

@ -29,7 +29,10 @@ import {
listEstimateBalances, listEstimateBalances,
updateEstimateBalance, updateEstimateBalance,
} from "../controllers/estimate-balance.controller.js"; } from "../controllers/estimate-balance.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import {
getCircuitTree,
getProjectCircuitTrees,
} from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js"; import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import { import {
getProjectHistory, getProjectHistory,
@ -109,6 +112,7 @@ projectRouter.delete(
deleteDistributionBoard deleteDistributionBoard
); );
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-trees", getProjectCircuitTrees);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get( projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier", "/:projectId/circuit-sections/:sectionId/next-identifier",

View file

@ -38,13 +38,15 @@ describe("circuit tree controller", () => {
it("returns header, grouped and footer components with both protection layers", async () => { it("returns header, grouped and footer components with both protection layers", async () => {
const originals = { const originals = {
findList: circuitListRepository.findById, findList: circuitListRepository.findById,
listLists: circuitListRepository.listByProject,
findProject: projectRepository.findById, findProject: projectRepository.findById,
findBoard: distributionBoardRepository.findById, findBoard: distributionBoardRepository.findById,
listSections: circuitSectionRepository.listByCircuitList, listBoards: distributionBoardRepository.listByProject,
listCircuits: circuitRepository.listByCircuitList, listSections: circuitSectionRepository.listByCircuitLists,
listCircuits: circuitRepository.listByCircuitLists,
listRows: circuitDeviceRowRepository.listByCircuitList, listRows: circuitDeviceRowRepository.listByCircuitList,
listComponents: listComponents:
distributionBoardComponentRepository.listByCircuitList, distributionBoardComponentRepository.listByCircuitLists,
listComponentProtection: listComponentProtection:
distributionBoardComponentRepository.listProtectionByComponentIds, distributionBoardComponentRepository.listProtectionByComponentIds,
listCircuitProtection: listCircuitProtection:
@ -57,6 +59,13 @@ describe("circuit tree controller", () => {
distributionBoardId: "board-1", distributionBoardId: "board-1",
name: "UV-01", name: "UV-01",
}) as never; }) as never;
circuitListRepository.listByProject = async () =>
[{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV-01",
}] as never;
projectRepository.findById = async () => projectRepository.findById = async () =>
({ ({
id: "project-1", id: "project-1",
@ -66,8 +75,19 @@ describe("circuit tree controller", () => {
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
}) as never; }) as never;
distributionBoardRepository.findById = async () => distributionBoardRepository.findById = async () =>
({ id: "board-1", simultaneityFactor: 0.8 }) as never; ({ id: "board-1", name: "UV-01", simultaneityFactor: 0.8 }) as never;
circuitSectionRepository.listByCircuitList = async () => distributionBoardRepository.listByProject = async () =>
[{
id: "board-1",
projectId: "project-1",
name: "UV-01",
boardNumber: "UV-01",
supplyType: "AV",
simultaneityFactor: 0.8,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}] as never;
circuitSectionRepository.listByCircuitLists = async () =>
[ [
{ {
id: "group-1", id: "group-1",
@ -80,7 +100,7 @@ describe("circuit tree controller", () => {
groupNumber: 1, groupNumber: 1,
}, },
] as never; ] as never;
circuitRepository.listByCircuitList = async () => circuitRepository.listByCircuitLists = async () =>
[ [
{ {
id: "circuit-1", id: "circuit-1",
@ -90,10 +110,19 @@ describe("circuit tree controller", () => {
displayName: "Licht", displayName: "Licht",
sortOrder: 10, sortOrder: 10,
isReserve: 0, isReserve: 0,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
remark: null,
}, },
] as never; ] as never;
circuitDeviceRowRepository.listByCircuitList = async () => []; circuitDeviceRowRepository.listByCircuitList = async () => [];
distributionBoardComponentRepository.listByCircuitList = distributionBoardComponentRepository.listByCircuitLists =
async () => async () =>
[ [
{ {
@ -170,13 +199,15 @@ describe("circuit tree controller", () => {
); );
} finally { } finally {
circuitListRepository.findById = originals.findList; circuitListRepository.findById = originals.findList;
circuitListRepository.listByProject = originals.listLists;
projectRepository.findById = originals.findProject; projectRepository.findById = originals.findProject;
distributionBoardRepository.findById = originals.findBoard; distributionBoardRepository.findById = originals.findBoard;
circuitSectionRepository.listByCircuitList = distributionBoardRepository.listByProject = originals.listBoards;
circuitSectionRepository.listByCircuitLists =
originals.listSections; originals.listSections;
circuitRepository.listByCircuitList = originals.listCircuits; circuitRepository.listByCircuitLists = originals.listCircuits;
circuitDeviceRowRepository.listByCircuitList = originals.listRows; circuitDeviceRowRepository.listByCircuitList = originals.listRows;
distributionBoardComponentRepository.listByCircuitList = distributionBoardComponentRepository.listByCircuitLists =
originals.listComponents; originals.listComponents;
distributionBoardComponentRepository.listProtectionByComponentIds = distributionBoardComponentRepository.listProtectionByComponentIds =
originals.listComponentProtection; originals.listComponentProtection;

View file

@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { calculateDistributionBoardLoads } from "../src/domain/calculations/distribution-board-load-calculation.js";
const board = (
id: string,
simultaneityFactor: number,
feedingDistributionBoardId: string | null = null,
feedingCircuitId: string | null = null
) => ({
id,
name: id,
boardNumber: id.toUpperCase(),
simultaneityFactor,
feedingDistributionBoardId,
feedingCircuitId,
});
describe("distribution-board load calculation", () => {
it("rolls recursive demand into the selected feeder circuit exactly once", () => {
const result = calculateDistributionBoardLoads({
boards: [
board("nshv", 0.8),
board("uv1", 0.5, "nshv", "nshv-out"),
board("uv2", 0.25, "uv1", "uv1-out"),
],
circuits: [
{ id: "nshv-out", distributionBoardId: "nshv", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
{ id: "uv1-out", distributionBoardId: "uv1", rows: [{ activePowerKw: 4, cosPhi: 0.8 }] },
{ id: "uv2-load", distributionBoardId: "uv2", rows: [{ activePowerKw: 8, cosPhi: 0.8 }] },
],
});
assert.deepEqual(result.boards.get("uv2"), {
distributionBoardId: "uv2",
activePowerBeforeSimultaneityKw: 8,
apparentPowerBeforeSimultaneityKva: 10,
demandActivePowerKw: 2,
demandApparentPowerKva: 2.5,
cosPhi: 0.8,
});
assert.equal(result.circuits.get("uv1-out")?.activePowerKw, 6);
assert.equal(result.boards.get("uv1")?.demandActivePowerKw, 3);
assert.equal(result.circuits.get("nshv-out")?.activePowerKw, 5);
assert.equal(result.boards.get("nshv")?.demandActivePowerKw, 4);
assert.equal(result.circuits.get("nshv-out")?.downstreamBoards.length, 1);
});
it("combines apparent power for multiple child boards on one circuit", () => {
const result = calculateDistributionBoardLoads({
boards: [board("main", 1), board("a", 1, "main", "out"), board("b", 1, "main", "out")],
circuits: [
{ id: "out", distributionBoardId: "main", rows: [] },
{ id: "a-load", distributionBoardId: "a", rows: [{ activePowerKw: 2, cosPhi: 0.5 }] },
{ id: "b-load", distributionBoardId: "b", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
],
});
const feeder = result.circuits.get("out")!;
assert.equal(feeder.activePowerKw, 4);
assert.equal(feeder.apparentPowerKva, 6);
assert.equal(feeder.cosPhi, 2 / 3);
assert.deepEqual(feeder.downstreamBoards.map((entry) => entry.distributionBoardId), ["a", "b"]);
});
it("keeps a board-only hierarchy relation out of load rollup", () => {
const result = calculateDistributionBoardLoads({
boards: [board("main", 1), board("child", 1, "main", null)],
circuits: [
{ id: "main-load", distributionBoardId: "main", rows: [{ activePowerKw: 1, cosPhi: 1 }] },
{ id: "child-load", distributionBoardId: "child", rows: [{ activePowerKw: 5, cosPhi: 1 }] },
],
});
assert.equal(result.boards.get("main")?.demandActivePowerKw, 1);
assert.equal(result.boards.get("child")?.demandActivePowerKw, 5);
});
it("rejects cycles defensively", () => {
assert.throws(
() => calculateDistributionBoardLoads({
boards: [board("a", 1, "b", "b-out"), board("b", 1, "a", "a-out")],
circuits: [
{ id: "a-out", distributionBoardId: "a", rows: [] },
{ id: "b-out", distributionBoardId: "b", rows: [] },
],
}),
/cycle/
);
});
});