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,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<string, typeof rows>();
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<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(
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<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,
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<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,
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",