Add the rough power balance module for early design phases

In LPH 2/3 there are no circuits yet, but the office needs a power
balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz -
KG400": each trade enters consumers by piece or by area (W/m²) with GLF
and cos phi; the balance sums AV and SV per cost group, adds a reserve and
derives the maximum current.

The module is separate from the circuit structure: own tables
(migration 0010), own folder src/estimate-balance and an import-free
shared calculation used by API and UI. Writes go through project commands
(estimate-balance.insert/.update/.delete with complete documents), so
revision checks and Undo/Redo apply; it is not part of project snapshots
or transfer yet. A comparison endpoint sums the device rows of all circuit
lists by cost group (longest prefix) against the estimate.

The UI adds a sidebar entry, a list of Stände (template KG 400, empty or
copy) and a spreadsheet-like editor with autosave, AMEV picker for the
GLF, totals, reserve, maximum current, comparison table and print layout.
The tests reproduce the template example exactly (143.24 kW, 148.48 kVA,
157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See
docs/estimate-balance.md and issue #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 14:50:48 +02:00
parent 80dbc73230
commit 37a70b6b36
37 changed files with 8537 additions and 4 deletions

View file

@ -0,0 +1,459 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { estimateRows } from "../src/db/schema/estimate-balances.js";
import { projects } from "../src/db/schema/projects.js";
import {
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceSnapshot,
createEstimateBalanceUpdateProjectCommand,
parseEstimateBalanceSnapshot,
type EstimateBalanceProjectCommand,
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
import {
calculateEstimateBalance,
calculateEstimateRow,
compareEstimateWithDetailed,
estimateDefaultGroups,
matchEstimateCostGroup,
roundEstimateValue,
type EstimateBalanceSnapshot,
type EstimateRowSnapshot,
} from "../src/shared/estimate-balance/estimate-balance.js";
const round2 = (value: number) => roundEstimateValue(value, 2);
let nextId = 0;
const id = () => `id-${++nextId}`;
function row(
name: string,
quantity: number,
powerPerUnitW: number,
simultaneityFactor: number,
cosPhi: number,
extra: Partial<EstimateRowSnapshot> = {}
): EstimateRowSnapshot {
return {
id: id(),
sortOrder: 0,
supply: "ELT",
circuitRef: null,
name,
quantity,
unit: "piece",
connection: null,
powerPerUnitW,
simultaneityFactor,
cosPhi,
safetySupply: false,
remark: null,
...extra,
};
}
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
function templateExample(): EstimateBalanceSnapshot {
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
"410": [
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
],
"420": [
row("Fernwärmestation", 1, 1000, 0.8, 1),
row("Pumpen", 4, 200, 0.8, 0.9),
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
],
"430": [
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
],
"434": [
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
],
"440": [
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
],
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
"480": [],
"": [row("Allgemein", 1, 2000, 1, 0.95)],
};
return {
id: "balance-1",
projectId: "project-1",
name: "Entwurfsplanung",
workStage: "LPH 3",
dateIso: "2023-06-30",
reserveAvPercent: 10,
reserveSvPercent: 0,
sortOrder: 0,
groups: estimateDefaultGroups.map((group, index) => ({
id: `group-${group.costGroup || "allg"}`,
costGroup: group.costGroup,
title: group.title,
editor: null,
sortOrder: index,
rows: rowsByGroup[group.costGroup] ?? [],
})),
};
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Foreign project" },
])
.run();
return context;
}
describe("estimate balance calculation", () => {
it("reproduces the office template totals", () => {
const result = calculateEstimateBalance(templateExample(), 400);
const groupTotals = result.groups.map((group) => [
round2(group.av.kw),
round2(group.av.kva),
]);
assert.deepEqual(groupTotals, [
[35.52, 35.52],
[3.04, 3.11],
[6.4, 7.11],
[3.2, 3.2],
[40.18, 43.31],
[12, 12.63],
[11.2, 11.79],
[29.7, 29.7],
[0, 0],
[2, 2.11],
]);
assert.equal(round2(result.total.av.kw), 143.24);
assert.equal(round2(result.total.av.kva), 148.48);
assert.equal(round2(result.reserve.av.kw), 14.32);
assert.equal(round2(result.reserve.av.kva), 14.85);
assert.equal(round2(result.withReserve.av.kw), 157.56);
assert.equal(round2(result.withReserve.av.kva), 163.33);
assert.equal(round2(result.maxCurrentA.av), 235.75);
assert.equal(result.total.sv.kw, 0);
assert.equal(result.maxCurrentA.sv, 0);
});
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
const area = calculateEstimateRow({
quantity: 250,
powerPerUnitW: 12,
simultaneityFactor: 0.8,
cosPhi: 0.95,
safetySupply: true,
});
assert.equal(round2(area.av.kw), 2.4);
assert.equal(round2(area.av.kva), 2.53);
assert.deepEqual(area.sv, area.av);
const balance = templateExample();
balance.reserveSvPercent = 20;
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
const result = calculateEstimateBalance(balance, 400);
assert.equal(round2(result.total.sv.kw), 1.6);
assert.equal(round2(result.withReserve.sv.kw), 1.92);
assert.equal(round2(result.total.av.kw), 143.24);
});
it("rounds displayed values like the spreadsheet", () => {
assert.equal(roundEstimateValue(40.175), 40.18);
assert.equal(roundEstimateValue(143.235), 143.24);
assert.equal(roundEstimateValue(-1.825), -1.83);
assert.equal(roundEstimateValue(0.711111), 0.71);
assert.equal(roundEstimateValue(235.749, 1), 235.7);
});
it("matches detailed cost groups to the most specific estimate group", () => {
const codes = ["410", "430", "434", "440", "480", ""];
assert.equal(matchEstimateCostGroup("444", codes), "440");
assert.equal(matchEstimateCostGroup("434", codes), "434");
assert.equal(matchEstimateCostGroup("431", codes), "430");
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
assert.equal(matchEstimateCostGroup("4", codes), null);
assert.equal(matchEstimateCostGroup("", codes), null);
assert.equal(matchEstimateCostGroup(null, codes), null);
assert.equal(matchEstimateCostGroup("450", codes), null);
});
it("compares the estimate with detailed device rows per cost group", () => {
const comparison = compareEstimateWithDetailed(templateExample(), [
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
{ costGroup: "445", kw: 12, cosPhi: null },
{ costGroup: "412", kw: 40, cosPhi: 1 },
{ costGroup: null, kw: 5, cosPhi: 1 },
{ costGroup: "399", kw: 1, cosPhi: 1 },
]);
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
assert.equal(round2(kg440.detailed.kw), 42);
assert.equal(round2(kg440.detailed.kva), 45.33);
assert.equal(kg440.detailedRowCount, 2);
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
assert.equal(round2(kg440.deltaPercent!), 4.54);
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
assert.equal(round2(kg410.deltaKw), 4.48);
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
assert.equal(kg480.deltaPercent, null);
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
assert.equal(round2(comparison.total.detailed.kw), 88);
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
});
});
describe("estimate balance snapshots and commands", () => {
it("validates snapshots strictly", () => {
const valid = templateExample();
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
(balance) => (balance.groups[0]!.title = ""),
(balance) => (balance.dateIso = "30.06.2023"),
(balance) => (balance.reserveAvPercent = 101),
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
];
for (const mutate of invalid) {
const balance = structuredClone(valid);
mutate(balance);
assert.throws(() => parseEstimateBalanceSnapshot(balance));
}
assert.equal(
createEstimateBalanceRequestSchema.safeParse({
expectedRevision: 0,
name: "Entwurf",
template: "copy",
}).success,
false
);
});
it("creates the KG 400 template and copies a Stand with new ids", () => {
const created = createEstimateBalanceSnapshot({
projectId: "project-1",
name: " Vorplanung ",
workStage: "LPH 2",
dateIso: "2026-09-26",
sortOrder: 0,
template: "kg400",
});
assert.equal(created.name, "Vorplanung");
assert.deepEqual(
created.groups.map((group) => group.costGroup),
estimateDefaultGroups.map((group) => group.costGroup)
);
assert.equal(created.reserveAvPercent, 10);
const source = templateExample();
const copy = createEstimateBalanceSnapshot({
projectId: "project-1",
name: "Entwurfsplanung Kopie",
workStage: null,
dateIso: null,
sortOrder: 1,
template: { copyFrom: source },
});
assert.notEqual(copy.id, source.id);
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
assert.equal(
round2(calculateEstimateBalance(copy, 400).total.av.kw),
143.24
);
});
it("inserts, updates and deletes balances with persisted undo and redo", () => {
const context = createTestDatabase();
try {
const store = new EstimateBalanceProjectCommandRepository(context.db);
const reader = new EstimateBalanceRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
let revision = 0;
const run = (
command: EstimateBalanceProjectCommand,
source: "user" | "undo" | "redo" = "user"
) => {
const step = source === "user" ? null : history.getNextCommand("project-1", source);
const result = store.execute({
projectId: "project-1",
expectedRevision: revision,
source,
historyTargetChangeSetId: step?.changeSetId,
command,
});
revision += 1;
return result;
};
const balance = templateExample();
const insert = createEstimateBalanceInsertProjectCommand(balance);
const inserted = run(insert);
const stored = reader.findById("project-1", balance.id);
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
assert.equal(reader.listByProject("project-2").length, 0);
const target = structuredClone(stored!);
target.groups[0]!.rows.splice(1, 1);
target.groups[0]!.rows[0]!.quantity = 10;
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
target.groups[8]!.editor = "MSR";
target.reserveAvPercent = 15;
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
const updated = run(update);
assert.deepEqual(
reader.findById("project-1", balance.id),
parseEstimateBalanceSnapshot(target)
);
run(updated.inverse, "undo");
assert.deepEqual(reader.findById("project-1", balance.id), stored);
run(update, "redo");
assert.deepEqual(
reader.findById("project-1", balance.id),
parseEstimateBalanceSnapshot(target)
);
// A stale expected document is rejected.
assert.throws(
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
/changed before update/
);
assert.throws(
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
/must change/
);
const current = reader.findById("project-1", balance.id)!;
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
assert.equal(reader.findById("project-1", balance.id), null);
assert.equal(context.db.select().from(estimateRows).all().length, 0);
run(deleted.inverse, "undo");
assert.deepEqual(reader.findById("project-1", balance.id), current);
// The original insert's inverse no longer matches the edited balance.
assert.throws(() => run(inserted.inverse), /changed before deletion/);
run(createEstimateBalanceDeleteProjectCommand(current));
assert.equal(reader.listByProject("project-1").length, 0);
assert.throws(
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
/does not belong to project/
);
} finally {
context.close();
}
});
it("sums detailed device rows of all circuit lists of the project", () => {
const context = createTestDatabase();
try {
context.db
.insert(distributionBoards)
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
.run();
context.db
.insert(circuitLists)
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
.run();
context.db
.insert(circuitSections)
.values({
id: "section-1",
circuitListId: "list-1",
key: "lighting-1",
displayName: "Beleuchtung 1",
prefix: "1F1",
sortOrder: 0,
})
.run();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Licht",
sortOrder: 0,
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
name: "Leuchte",
displayName: "Leuchte",
costGroup: "445",
quantity: 10,
powerPerUnit: 0.05,
simultaneityFactor: 0.8,
cosPhi: 0.95,
},
{
id: "row-2",
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
costGroup: null,
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
},
])
.run();
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
"project-1"
);
assert.deepEqual(
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
[
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
{ costGroup: null, kw: 0.2, cosPhi: null },
]
);
assert.equal(
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
0
);
} finally {
context.close();
}
});
});