From dfeb0e3cb61b7bb420976ed36fcb29bbb2615be4 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 27 Sep 2026 22:31:56 +0200 Subject: [PATCH] Add recursive distribution board load projection --- .../circuit-section.repository.ts | 11 +- src/db/repositories/circuit.repository.ts | 11 +- ...distribution-board-component.repository.ts | 12 + .../distribution-board-load-calculation.ts | 217 +++++++++++ src/domain/models/circuit-tree.model.ts | 36 ++ src/frontend/types.ts | 33 ++ src/frontend/utils/api.ts | 7 + .../controllers/circuit-tree.controller.ts | 366 ++++++++++++------ src/server/routes/project.routes.ts | 6 +- tests/circuit-tree.controller.test.ts | 51 ++- ...istribution-board-load-calculation.test.ts | 89 +++++ 11 files changed, 701 insertions(+), 138 deletions(-) create mode 100644 src/domain/calculations/distribution-board-load-calculation.ts create mode 100644 tests/distribution-board-load-calculation.test.ts diff --git a/src/db/repositories/circuit-section.repository.ts b/src/db/repositories/circuit-section.repository.ts index d7817ec..79a3704 100644 --- a/src/db/repositories/circuit-section.repository.ts +++ b/src/db/repositories/circuit-section.repository.ts @@ -1,5 +1,5 @@ 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 type { AppDatabase } from "../database-context.js"; import { circuitSections } from "../schema/circuit-sections.js"; @@ -30,6 +30,15 @@ export class CircuitSectionRepository { .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) { const db = this.database; for (const entry of createDefaultCircuitSectionValues(circuitListId)) { diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index ecbaba0..7d40fa1 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -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 { circuits } from "../schema/circuits.js"; @@ -14,6 +14,15 @@ export class CircuitRepository { .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) { const db = this.database; return db diff --git a/src/db/repositories/distribution-board-component.repository.ts b/src/db/repositories/distribution-board-component.repository.ts index ab60603..eedb4af 100644 --- a/src/db/repositories/distribution-board-component.repository.ts +++ b/src/db/repositories/distribution-board-component.repository.ts @@ -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[]) { if (componentIds.length === 0) { return []; diff --git a/src/domain/calculations/distribution-board-load-calculation.ts b/src/domain/calculations/distribution-board-load-calculation.ts new file mode 100644 index 0000000..4c7bad6 --- /dev/null +++ b/src/domain/calculations/distribution-board-load-calculation.ts @@ -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; + circuits: Map; +} + +/** + * 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(); + 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(); + const circuits = new Map(); + const activePath = new Set(); + + 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, + circuitById: Map +) { + 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(values: T[], keyOf: (value: T) => string) { + const groups = new Map(); + for (const value of values) { + const key = keyOf(value); + const group = groups.get(key) ?? []; + group.push(value); + groups.set(key, group); + } + return groups; +} diff --git a/src/domain/models/circuit-tree.model.ts b/src/domain/models/circuit-tree.model.ts index 71fb743..2e1aa72 100644 --- a/src/domain/models/circuit-tree.model.ts +++ b/src/domain/models/circuit-tree.model.ts @@ -40,7 +40,10 @@ export interface CircuitTreeCircuit { isReserve: boolean; remark?: string; circuitTotalPower: number; + circuitApparentPower: number; + circuitCosPhi: number; deviceRows: CircuitTreeDeviceRow[]; + downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[]; protectionDevice?: CircuitTreeProtectionDevice; } @@ -59,6 +62,11 @@ export interface CircuitTreeSectionBlock { export interface CircuitTreeResponse { circuitListId: string; + distributionBoardId: string; + distributionBoardName: string; + distributionBoardNumber?: string; + distributionBoardSupplyType?: DistributionBoardSupplyType; + feedingReference?: CircuitTreeFeedingReference; currentRevision: number; singlePhaseVoltageV: number; threePhaseVoltageV: number; @@ -70,7 +78,35 @@ export interface CircuitTreeResponse { 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 { + DistributionBoardSupplyType, +} from "../../shared/constants/distribution-board.js"; import type { DistributionBoardComponentPlacement, DistributionBoardComponentRole, diff --git a/src/frontend/types.ts b/src/frontend/types.ts index a1cc7bc..8a2dec4 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -444,7 +444,10 @@ export interface CircuitTreeCircuitDto { isReserve: boolean; remark?: string; circuitTotalPower: number; + circuitApparentPower: number; + circuitCosPhi: number; deviceRows: CircuitTreeDeviceRowDto[]; + downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[]; protectionDevice?: CircuitTreeProtectionDeviceDto; } @@ -463,6 +466,11 @@ export interface CircuitTreeSectionDto { export interface CircuitTreeResponseDto { circuitListId: string; + distributionBoardId: string; + distributionBoardName: string; + distributionBoardNumber?: string; + distributionBoardSupplyType?: DistributionBoardSupplyType; + feedingReference?: CircuitTreeFeedingReferenceDto; currentRevision: number; singlePhaseVoltageV: number; threePhaseVoltageV: number; @@ -474,6 +482,31 @@ export interface CircuitTreeResponseDto { 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 { id: string; ifcGuid: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 5e814c9..8473bfa 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -33,6 +33,7 @@ import type { ProjectDto, RoomDto, CircuitTreeResponseDto, + ProjectCircuitTreesResponseDto, ExternalCsvConfigurationSnapshotDto, ExternalCsvPreviewDto, ExternalInitialImportPlanDto, @@ -529,6 +530,12 @@ export function getCircuitTree(projectId: string, circuitListId: string) { return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); } +export function getProjectCircuitTrees(projectId: string) { + return request( + `/api/projects/${projectId}/circuit-trees` + ); +} + export function insertDistributionBoardComponentCommand( projectId: string, expectedRevision: number, diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts index b7c86bf..22b4a73 100644 --- a/src/server/controllers/circuit-tree.controller.ts +++ b/src/server/controllers/circuit-tree.controller.ts @@ -1,13 +1,11 @@ import type { Request, Response } from "express"; -import { - applyDistributionBoardSimultaneityFactor, - calculateCircuitTotalPower, - calculateDistributionBoardTotalPower, - calculateRowTotalPower, - calculateSectionTotalPower, -} from "../../domain/calculations/circuit-power-calculation.js"; -import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js"; -import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js"; +import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js"; +import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js"; +import type { + CircuitTreeProtectionDevice, + CircuitTreeResponse, + ProjectCircuitTreesResponse, +} from "../../domain/models/circuit-tree.model.js"; import { circuitDeviceRowRepository, circuitListRepository, @@ -20,9 +18,7 @@ import { } from "../composition/application-repositories.js"; export function isMissingCircuitTreeSchemaError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } + if (!(error instanceof Error)) return false; return ( error.message.includes("no such table: circuit_sections") || 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") { return res.status(400).json({ error: "Invalid parameters" }); } - const list = await circuitListRepository.findById(projectId, circuitListId); - if (!list) { - return res.status(404).json({ error: "Circuit list not found" }); - } - const project = await projectRepository.findById(projectId); - if (!project) { - return res.status(404).json({ error: "Project not found" }); - } - const distributionBoard = - await distributionBoardRepository.findById( - projectId, - list.distributionBoardId - ); - if (!distributionBoard) { - return res.status(404).json({ - error: "Distribution board not found", - }); - } + if (!list) return res.status(404).json({ error: "Circuit list not found" }); try { - const sections = await circuitSectionRepository.listByCircuitList(circuitListId); - const circuits = await circuitRepository.listByCircuitList(circuitListId); - const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)); - const components = - await distributionBoardComponentRepository.listByCircuitList( - circuitListId - ); - const [circuitProtections, componentProtections] = - await Promise.all([ - circuitProtectionDeviceRepository.listByCircuitIds( - circuits.map((entry) => entry.id) - ), - distributionBoardComponentRepository.listProtectionByComponentIds( - components.map((entry) => entry.id) - ), - ]); - - const sectionById = new Map(sections.map((section) => [section.id, section])); - const rowsByCircuitId = new Map(); - const circuitProtectionById = new Map( - circuitProtections.map((entry) => [entry.circuitId, entry]) - ); - const componentProtectionById = new Map( - 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 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 board = await distributionBoardRepository.findById( + projectId, + list.distributionBoardId + ); + if (!project || !board) { + return res.status(404).json({ error: "Project structure 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 { + const result = await buildProjectCircuitTrees(projectId); + if (!result) return res.status(404).json({ error: "Project not found" }); + return res.json(result); + } catch (error) { + if (isMissingCircuitTreeSchemaError(error)) { + const project = await projectRepository.findById(projectId); + if (!project) return res.status(404).json({ error: "Project not found" }); + return res.json({ currentRevision: project.currentRevision, trees: [] }); + } + throw error; + } +} + +export async function buildProjectCircuitTrees( + projectId: string +): Promise { + 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( + 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 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( + circuitProtections.map((entry) => [entry.circuitId, entry]) + ); + const componentProtectionById = new Map( + componentProtections.map((entry) => [entry.componentId, entry]) + ); + + 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 = { - 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, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV, - distributionBoardSimultaneityFactor: - distributionBoard.simultaneityFactor, - distributionBoardTotalPower: 0, - distributionBoardTotalPowerWithSimultaneityFactor: 0, + distributionBoardSimultaneityFactor: board.simultaneityFactor, + distributionBoardTotalPower: boardLoad.activePowerBeforeSimultaneityKw, + distributionBoardTotalPowerWithSimultaneityFactor: + boardLoad.demandActivePowerKw, headerComponents: [], - sections: sections.map((section) => ({ + sections: (sectionsByListId.get(list.id) ?? []).map((section) => ({ id: section.id, key: section.key, displayName: section.displayName, @@ -115,9 +184,8 @@ export async function getCircuitTree(req: Request, res: Response) { footerComponents: [], }; const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section])); - for (const component of components) { - const protection = - componentProtectionById.get(component.id); + for (const component of componentsByListId.get(list.id) ?? []) { + const protection = componentProtectionById.get(component.id); const dto = { id: component.id, circuitListId: component.circuitListId, @@ -127,24 +195,18 @@ export async function getCircuitTree(req: Request, res: Response) { role: component.role, placement: component.placement, sortOrder: component.sortOrder, - protectionDevice: protection - ? toProtectionDeviceDto(protection) - : undefined, + protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined, }; - if (component.placement === "header") { - tree.headerComponents.push(dto); - } else if (component.placement === "footer") { - tree.footerComponents.push(dto); - } else if (component.sectionId !== null) { + if (component.placement === "header") tree.headerComponents.push(dto); + else if (component.placement === "footer") tree.footerComponents.push(dto); + else if (component.sectionId !== null) { sectionBlocks.get(component.sectionId)?.components.push(dto); } } - for (const circuit of circuits) { - const section = sectionById.get(circuit.sectionId); - if (!section || section.circuitListId !== circuit.circuitListId) { - continue; - } + for (const circuit of circuitsByListId.get(list.id) ?? []) { + const block = sectionBlocks.get(circuit.sectionId); + if (!block) continue; const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, @@ -166,12 +228,16 @@ export async function getCircuitTree(req: Request, res: Response) { cosPhi: row.cosPhi ?? undefined, remark: row.remark ?? 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); - - sectionBlocks.get(section.id)?.circuits.push({ + block.circuits.push({ id: circuit.id, circuitListId: circuit.circuitListId, sectionId: circuit.sectionId, @@ -188,48 +254,89 @@ export async function getCircuitTree(req: Request, res: Response) { status: circuit.status ?? undefined, isReserve: Boolean(circuit.isReserve), remark: circuit.remark ?? undefined, - circuitTotalPower, + circuitTotalPower: circuitLoad.activePowerKw, + circuitApparentPower: circuitLoad.apparentPowerKva, + circuitCosPhi: circuitLoad.cosPhi, deviceRows, - protectionDevice: protection - ? toProtectionDeviceDto(protection) - : undefined, + downstreamBoardLoads: circuitLoad.downstreamBoards + .map((load) => ({ + 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) { - section.sectionTotalPower = calculateSectionTotalPower( - section.circuits + section.sectionTotalPower = section.circuits.reduce( + (sum, circuit) => sum + circuit.circuitTotalPower, + 0 ); } - tree.distributionBoardTotalPower = - calculateDistributionBoardTotalPower(tree.sections); - tree.distributionBoardTotalPowerWithSimultaneityFactor = - applyDistributionBoardSimultaneityFactor( - tree.distributionBoardTotalPower, - tree.distributionBoardSimultaneityFactor - ); + return tree; + }); - return res.json(tree); - } catch (error) { - if (isMissingCircuitTreeSchemaError(error)) { - return res.json({ - circuitListId, - currentRevision: project.currentRevision, - singlePhaseVoltageV: project.singlePhaseVoltageV, - threePhaseVoltageV: project.threePhaseVoltageV, - distributionBoardSimultaneityFactor: - distributionBoard.simultaneityFactor, - distributionBoardTotalPower: 0, - distributionBoardTotalPowerWithSimultaneityFactor: 0, - headerComponents: [], - sections: [], - footerComponents: [], - warning: - "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", - }); - } - throw error; - } + return { currentRevision: project.currentRevision, trees }; +} + +function resolveFeedingReference( + board: Awaited>[number], + boardById: Map>[number]>, + circuitById: Map>[number]>, + sectionById: Map>[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>>, + board: NonNullable>>, + circuitListId: string +): CircuitTreeResponse & { warning: string } { + return { + circuitListId, + distributionBoardId: board.id, + distributionBoardName: board.name, + distributionBoardNumber: board.boardNumber ?? undefined, + distributionBoardSupplyType: board.supplyType ?? undefined, + currentRevision: project.currentRevision, + singlePhaseVoltageV: project.singlePhaseVoltageV, + threePhaseVoltageV: project.threePhaseVoltageV, + distributionBoardSimultaneityFactor: board.simultaneityFactor, + distributionBoardTotalPower: 0, + distributionBoardTotalPowerWithSimultaneityFactor: 0, + headerComponents: [], + sections: [], + footerComponents: [], + warning: + "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", + }; } function toProtectionDeviceDto(value: { @@ -243,11 +350,20 @@ function toProtectionDeviceDto(value: { return { type: value.type, ratedCurrentA: value.ratedCurrentA, - fuseUtilizationCategory: - value.fuseUtilizationCategory ?? undefined, + fuseUtilizationCategory: value.fuseUtilizationCategory ?? undefined, tripCharacteristic: value.tripCharacteristic ?? undefined, rcdType: value.rcdType ?? undefined, - ratedResidualCurrentMa: - value.ratedResidualCurrentMa ?? undefined, + ratedResidualCurrentMa: value.ratedResidualCurrentMa ?? undefined, }; } + +function groupBy(values: T[], keyOf: (value: T) => string) { + const groups = new Map(); + for (const value of values) { + const key = keyOf(value); + const group = groups.get(key) ?? []; + group.push(value); + groups.set(key, group); + } + return groups; +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 529def2..927223e 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -29,7 +29,10 @@ import { listEstimateBalances, updateEstimateBalance, } 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 { getProjectHistory, @@ -109,6 +112,7 @@ projectRouter.delete( deleteDistributionBoard ); projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); +projectRouter.get("/:projectId/circuit-trees", getProjectCircuitTrees); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get( "/:projectId/circuit-sections/:sectionId/next-identifier", diff --git a/tests/circuit-tree.controller.test.ts b/tests/circuit-tree.controller.test.ts index 7fcf884..ea20da2 100644 --- a/tests/circuit-tree.controller.test.ts +++ b/tests/circuit-tree.controller.test.ts @@ -38,13 +38,15 @@ describe("circuit tree controller", () => { it("returns header, grouped and footer components with both protection layers", async () => { const originals = { findList: circuitListRepository.findById, + listLists: circuitListRepository.listByProject, findProject: projectRepository.findById, findBoard: distributionBoardRepository.findById, - listSections: circuitSectionRepository.listByCircuitList, - listCircuits: circuitRepository.listByCircuitList, + listBoards: distributionBoardRepository.listByProject, + listSections: circuitSectionRepository.listByCircuitLists, + listCircuits: circuitRepository.listByCircuitLists, listRows: circuitDeviceRowRepository.listByCircuitList, listComponents: - distributionBoardComponentRepository.listByCircuitList, + distributionBoardComponentRepository.listByCircuitLists, listComponentProtection: distributionBoardComponentRepository.listProtectionByComponentIds, listCircuitProtection: @@ -57,6 +59,13 @@ describe("circuit tree controller", () => { distributionBoardId: "board-1", name: "UV-01", }) as never; + circuitListRepository.listByProject = async () => + [{ + id: "list-1", + projectId: "project-1", + distributionBoardId: "board-1", + name: "UV-01", + }] as never; projectRepository.findById = async () => ({ id: "project-1", @@ -66,8 +75,19 @@ describe("circuit tree controller", () => { threePhaseVoltageV: 400, }) as never; distributionBoardRepository.findById = async () => - ({ id: "board-1", simultaneityFactor: 0.8 }) as never; - circuitSectionRepository.listByCircuitList = async () => + ({ id: "board-1", name: "UV-01", simultaneityFactor: 0.8 }) as never; + 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", @@ -80,7 +100,7 @@ describe("circuit tree controller", () => { groupNumber: 1, }, ] as never; - circuitRepository.listByCircuitList = async () => + circuitRepository.listByCircuitLists = async () => [ { id: "circuit-1", @@ -90,10 +110,19 @@ describe("circuit tree controller", () => { displayName: "Licht", sortOrder: 10, isReserve: 0, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + remark: null, }, ] as never; circuitDeviceRowRepository.listByCircuitList = async () => []; - distributionBoardComponentRepository.listByCircuitList = + distributionBoardComponentRepository.listByCircuitLists = async () => [ { @@ -170,13 +199,15 @@ describe("circuit tree controller", () => { ); } finally { circuitListRepository.findById = originals.findList; + circuitListRepository.listByProject = originals.listLists; projectRepository.findById = originals.findProject; distributionBoardRepository.findById = originals.findBoard; - circuitSectionRepository.listByCircuitList = + distributionBoardRepository.listByProject = originals.listBoards; + circuitSectionRepository.listByCircuitLists = originals.listSections; - circuitRepository.listByCircuitList = originals.listCircuits; + circuitRepository.listByCircuitLists = originals.listCircuits; circuitDeviceRowRepository.listByCircuitList = originals.listRows; - distributionBoardComponentRepository.listByCircuitList = + distributionBoardComponentRepository.listByCircuitLists = originals.listComponents; distributionBoardComponentRepository.listProtectionByComponentIds = originals.listComponentProtection; diff --git a/tests/distribution-board-load-calculation.test.ts b/tests/distribution-board-load-calculation.test.ts new file mode 100644 index 0000000..f6c9a95 --- /dev/null +++ b/tests/distribution-board-load-calculation.test.ts @@ -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/ + ); + }); +});