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,316 @@
/**
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
*
* Used by the API (validation, comparison) and by the frontend (live totals),
* so this file has no imports: Turbopack resolves frontend imports without
* the `.js` suffix the API build needs.
*/
export type EstimateRowUnit = "piece" | "area";
export type EstimateRowConnection = "fixed" | "socket";
export interface EstimateRowSnapshot {
id: string;
sortOrder: number;
/** Versorgung, e.g. "ELT" or "ISP". */
supply: string | null;
/** Optional circuit reference, free text. */
circuitRef: string | null;
/** Verbraucher / Fläche. May be empty while a row is being typed. */
name: string;
/** Anzahl (unit "piece") or area in m² (unit "area"). */
quantity: number;
unit: EstimateRowUnit;
connection: EstimateRowConnection | null;
/** W per piece or W/m². */
powerPerUnitW: number;
simultaneityFactor: number;
cosPhi: number;
/** Also needed on the safety power supply (SV). */
safetySupply: boolean;
remark: string | null;
}
export interface EstimateGroupSnapshot {
id: string;
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
costGroup: string;
title: string;
/** Trade or person responsible for the entries ("bearbeitet von"). */
editor: string | null;
sortOrder: number;
rows: EstimateRowSnapshot[];
}
export interface EstimateBalanceSnapshot {
id: string;
projectId: string;
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
name: string;
workStage: string | null;
/** ISO date YYYY-MM-DD. */
dateIso: string | null;
reserveAvPercent: number;
reserveSvPercent: number;
sortOrder: number;
groups: EstimateGroupSnapshot[];
}
/** Default structure of the office template "Leistungsbilanz - KG400". */
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
{ costGroup: "410", title: "Sanitärtechnik" },
{ costGroup: "420", title: "Wärmeversorgung" },
{ costGroup: "430", title: "Lufttechnische Anlagen" },
{ costGroup: "434", title: "Kälteanlagen" },
{ costGroup: "440", title: "Starkstromanlagen" },
{ costGroup: "450", title: "Schwachstromanlagen" },
{ costGroup: "460", title: "Fördertechnik" },
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
{ costGroup: "480", title: "Gebäudeautomation" },
{ costGroup: "", title: "Allgemein" },
];
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
piece: "St.",
area: "m²",
};
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
fixed: "Fest",
socket: "Steckd.",
};
export interface EstimatePower {
kw: number;
kva: number;
}
export interface EstimateRowResult {
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateGroupResult {
groupId: string;
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateBalanceResult {
groups: EstimateGroupResult[];
total: { av: EstimatePower; sv: EstimatePower };
reserve: { av: EstimatePower; sv: EstimatePower };
withReserve: { av: EstimatePower; sv: EstimatePower };
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
maxCurrentA: { av: number; sv: number };
}
/**
* Rounds a calculated value for display like the spreadsheet does: binary
* floating point stores 40.175 as 40.17499…, so the scaled value is first
* cut to 6 decimals and only then rounded half away from zero
* (40.175 -> 40.18, -1.825 -> -1.83).
*/
export function roundEstimateValue(value: number, digits = 2): number {
const factor = 10 ** digits;
const scaled = Number((Math.abs(value) * factor).toFixed(6));
return (Math.sign(value) * Math.round(scaled)) / factor;
}
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
}
function scalePower(power: EstimatePower, factor: number): EstimatePower {
return { kw: power.kw * factor, kva: power.kva * factor };
}
/**
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
* AV covers every consumer (normal supply); SV additionally lists the rows
* that must also be supplied by the safety power supply.
*/
export function calculateEstimateRow(
row: Pick<
EstimateRowSnapshot,
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
>
): EstimateRowResult {
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
const av = { kw, kva };
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
}
export function calculateEstimateBalance(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
threePhaseVoltageV: number
): EstimateBalanceResult {
const groups = balance.groups.map((group) =>
group.rows.reduce<EstimateGroupResult>(
(sum, row) => {
const result = calculateEstimateRow(row);
return {
groupId: group.id,
av: addPower(sum.av, result.av),
sv: addPower(sum.sv, result.sv),
};
},
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
)
);
const total = groups.reduce(
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
{ av: zeroPower(), sv: zeroPower() }
);
const reserve = {
av: scalePower(total.av, balance.reserveAvPercent / 100),
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
};
const withReserve = {
av: addPower(total.av, reserve.av),
sv: addPower(total.sv, reserve.sv),
};
const currentFactor =
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
return {
groups,
total,
reserve,
withReserve,
maxCurrentA: {
av: withReserve.av.kva * currentFactor,
sv: withReserve.sv.kva * currentFactor,
},
};
}
/**
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
* estimate group: a group code with trailing zeros removed is a prefix
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
* no group matches.
*/
export function matchEstimateCostGroup(
detailedCostGroup: string | null,
groupCostGroups: readonly string[]
): string | null {
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
if (!digits) {
return null;
}
let best: { code: string; length: number } | null = null;
for (const code of groupCostGroups) {
const codeDigits = code.replace(/\D/g, "");
const prefix = codeDigits.replace(/0+$/, "");
if (!prefix || !digits.startsWith(prefix)) {
continue;
}
if (!best || prefix.length > best.length) {
best = { code, length: prefix.length };
}
}
return best?.code ?? null;
}
export interface EstimateComparisonGroup {
groupId: string;
costGroup: string;
title: string;
estimatedAv: EstimatePower;
detailed: EstimatePower;
detailedRowCount: number;
deltaKw: number;
/** Relative deviation of the detailed value from the estimate, null without estimate. */
deltaPercent: number | null;
}
export interface EstimateComparison {
groups: EstimateComparisonGroup[];
unassigned: EstimatePower & { rowCount: number };
total: {
estimatedAv: EstimatePower;
detailed: EstimatePower;
deltaKw: number;
deltaPercent: number | null;
};
}
export interface DetailedDeviceRowPower {
costGroup: string | null;
/** Total power of the device row in kW (quantity · power · device GZF). */
kw: number;
cosPhi: number | null;
}
function delta(estimated: number, detailed: number) {
return {
deltaKw: detailed - estimated,
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
};
}
/**
* Compares the estimate per cost group with the detailed device rows of all
* circuit lists. The distribution-board simultaneity factor is not applied
* on either side; a missing cos φ counts as 1.
*/
export function compareEstimateWithDetailed(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
detailedRows: readonly DetailedDeviceRowPower[]
): EstimateComparison {
const estimate = calculateEstimateBalance(balance, 0);
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
for (const row of detailedRows) {
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
const code = matchEstimateCostGroup(row.costGroup, codes);
const target =
code === null
? unassigned
: detailedByCode.get(code) ??
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
target.kw += row.kw;
target.kva += kva;
target.rowCount += 1;
}
// A cost group code can appear in several groups; the detailed value is
// shown once, on the first group with that code.
const shownCodes = new Set<string>();
const groups = balance.groups.map((group, index) => {
const estimatedAv = estimate.groups[index]!.av;
const detailedEntry =
group.costGroup && !shownCodes.has(group.costGroup)
? detailedByCode.get(group.costGroup)
: undefined;
if (group.costGroup) {
shownCodes.add(group.costGroup);
}
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
return {
groupId: group.id,
costGroup: group.costGroup,
title: group.title,
estimatedAv,
detailed,
detailedRowCount: detailedEntry?.rowCount ?? 0,
...delta(estimatedAv.kw, detailed.kw),
};
});
const detailedTotal = groups.reduce(
(sum, group) => addPower(sum, group.detailed),
{ kw: unassigned.kw, kva: unassigned.kva }
);
return {
groups,
unassigned,
total: {
estimatedAv: estimate.total.av,
detailed: detailedTotal,
...delta(estimate.total.av.kw, detailedTotal.kw),
},
};
}