Update print view and documentation for logical feeds

This commit is contained in:
Julian Appel 2026-09-28 20:57:58 +02:00
parent 042a050876
commit b74b495fab
10 changed files with 278 additions and 158 deletions

View file

@ -52,7 +52,8 @@ See `docs/current-architecture.md` for the complete module and request flow.
- Device rows inside a circuit do not have their own equipment identifiers. - Device rows inside a circuit do not have their own equipment identifiers.
- Protection and cable data belong to the circuit, not to individual devices. - Protection and cable data belong to the circuit, not to individual devices.
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category. - Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
- Circuit total power is the sum of all device rows in that circuit. - Circuit total power is the sum of all device rows in that circuit plus the
read-only loads of logically linked downstream boards on that feeder circuit.
- Existing equipment identifiers must never be changed automatically. - Existing equipment identifiers must never be changed automatically.
- Renumbering is always an explicit user action. - Renumbering is always an explicit user action.
@ -284,16 +285,19 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 8 additionally stores the optional net room Current snapshot schema version 9 additionally stores
area `rooms[].areaM2` (see `docs/building-structure.md`); versions 1 to 7 are `feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
upgraded with `areaM2 = null`. Version 7 stores the project buildings and versions 1 to 8 are upgraded with both references set to `null`. Version 8
stores the optional net room area `rooms[].areaM2` (see
`docs/building-structure.md`); versions 1 to 7 are upgraded with
`areaM2 = null`. Version 7 stores the project buildings and
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and `floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
`buildingId = null`. Version 6 stores the `buildingId = null`. Version 6 stores the
distribution-board info fields `boardNumber`, `location`, `fedFrom` and distribution-board info fields `boardNumber`, `location`, `fedFrom` and
`remark` and the project cover fields `siteAddress`, `workStage`, `remark` and the project cover fields `siteAddress`, `workStage`,
`projectLead` and `projectEditor`; all older supported versions are upgraded `projectLead` and `projectEditor`; all older supported versions are upgraded
with `null` for these fields. with `null` for these fields.
Stored named and automatic snapshots of every supported version (1 to 8) Stored named and automatic snapshots of every supported version (1 to 9)
remain restorable; they are upgraded while parsing. Version 5 stores remain restorable; they are upgraded while parsing. Version 5 stores
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed `manualQuantity = quantity`; they already contain the complete confirmed
@ -309,7 +313,7 @@ project graph.
Project-wide Revit CSV configuration uses Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is `external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update, persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 5-8, restore and portable delete, Undo and Redo are revision-safe. Snapshot schema 5-9, restore and portable
project duplication preserve the configuration; duplication remaps its internal project duplication preserve the configuration; duplication remaps its internal
id and project link. id and project link.
The project page exposes the Revit CSV modal. It edits the complete The project page exposes the Revit CSV modal. It edits the complete
@ -323,7 +327,7 @@ immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-8 to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-9
capture the complete external state and remap every internal UUID and link while capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix. preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration Import batches additionally store the monotonic project CSV configuration
@ -463,22 +467,34 @@ snapshot contains the group, optional component protections, complete circuits,
circuit protections and all device-row link/override metadata. Delete circuit protections and all device-row link/override metadata. Delete
re-captures and compares the subtree before cascading; restore preserves every re-captures and compares the subtree before cascading; restore preserves every
UUID in foreign-key-safe order. UUID in foreign-key-safe order.
The circuit-tree read model exposes fixed `headerComponents`, group-owned The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
`components`, `footerComponents`, optional group category/number and optional `components`, `footerComponents`, optional group category/number and optional
one-to-one protection DTOs for circuits and group components. These are one-to-one protection DTOs for circuits and group components. These are
the only supported circuit-protection source; no flat compatibility fields remain. the only supported circuit-protection source; no flat compatibility fields remain.
It resolves logical board feeds once for the complete project and adds each
downstream board's demand after its own board simultaneity factor to the chosen
feeding circuit as a read-only virtual row. Recursive chains and several boards
on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected.
Distribution-board floor assignment and a project-enabled supply type use Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.
The same command (still schema version 1, additively extended) carries the The same command uses schema version 2 for the logical
`feedingDistributionBoardId` and optional matching `feedingCircuitId` together
with the existing fields. Version-1 history commands remain executable and do
not change logical feed references. The command rejects self-links, cycles and
circuits from another board. The
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark` board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
(migration `0007`). They are nullable, trimmed planner values; the PUT route (migration `0007`). They are nullable, trimmed planner values; the PUT route
only includes submitted info fields in the command and its inverse. An empty only includes submitted info fields in the command and its inverse. An empty
board with info values is no longer an unchanged setup, so history deletion of board with info values is no longer an unchanged setup, so history deletion of
it is rejected like a changed simultaneity factor. Populated-subtree commands it is rejected like a changed simultaneity factor. Populated-subtree commands
stored before these fields existed are normalized to `null` values stored before these fields existed are normalized to `null` values
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free (`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` remains free text for
text: the logical link to the feeding board/circuit is an open design decision. external feeds and notes; project-board feeds use the two UUID references.
Deleting a referenced board or feeding circuit is blocked in every direct,
subtree and history-only removal path. Board copies retain their incoming feed,
and project duplication remaps both references.
These fields are intended to be prefillable from Revit parameters later, but These fields are intended to be prefillable from Revit parameters later, but
only through the Phase 14 rules (preview, explicit confirmation, no silent only through the Phase 14 rules (preview, explicit confirmation, no silent
overwrite). overwrite).
@ -521,9 +537,10 @@ Required operations:
`<thead>` so it repeats on printed pages. PDF export uses the browser print `<thead>` so it repeats on printed pages. PDF export uses the browser print
dialog; page size, margins and "Seite x von y" come from injected named dialog; page size, margins and "Seite x von y" come from injected named
`@page` rules (`lb-portrait`, `lb-landscape`). `@page` rules (`lb-portrait`, `lb-landscape`).
- The board hierarchy and "Σ inkl. unterlagert" in the overview are - The board hierarchy follows logical board ids. The overview shows power
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum, before board GZF and demand including linked downstream boards; it does not
no upstream simultaneity factor). Replace this once the feed link is modeled. add a second cumulative column. Board plans render downstream-board loads as
read-only rows. `fedFrom` groups only external root feeds.
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in - Colors/fonts follow the kbp corporate design (`--pd-*` variables in
`print-document.css`). Empty cover values are shown as `print-document.css`). Empty cover values are shown as
"[ergänzen]"; the Planstände editor column shows the project's current "[ergänzen]"; the Planstände editor column shows the project's current
@ -701,9 +718,9 @@ for the early-phase power balance by DIN 276 cost group (see
- full electrical sizing - full electrical sizing
- multi-user/PostgreSQL operation - multi-user/PostgreSQL operation
- supported production deployment - supported production deployment
- logical feed link between distribution boards (`fedFrom` stays free text - second/replacement board feeds, including changeover semantics for SV/SiBe
until the project owner decides whether it references a board, a feeding - Revit prefill proposals for logical board feeds through the Phase 14 preview
circuit or both) and the resulting hierarchical power summation and explicit-confirmation workflow
Do not implement these while working on an unrelated phase. Do not implement these while working on an unrelated phase.

View file

@ -67,10 +67,13 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
importierbar. importierbar.
Das aktuelle Projekt-Snapshot-Schema 8 enthält außerdem die optionale Das aktuelle Projekt-Snapshot-Schema 9 enthält die logischen Einspeisungsverweise
Netto-Raumfläche `rooms[].areaM2`. Snapshot-Versionen 1–7 werden dafür mit der Verteilungen; Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
Snapshot-Versionen 1–7 werden dafür mit
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene `areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
Flächen; eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den Flächen und Einspeisungsverweise; beim Duplizieren werden Board- und Circuit-IDs
neu zugeordnet. Eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
versionierten Projektsnapshot einbettet. versionierten Projektsnapshot einbettet.
The public dispatcher supports the Circuit and CircuitDeviceRow commands The public dispatcher supports the Circuit and CircuitDeviceRow commands
@ -254,23 +257,30 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
- stale revisions return `409 PROJECT_REVISION_CONFLICT` - stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId` - `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body: - body:
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }` `{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
- optional info-card fields: `boardNumber` (max. 50), `location`, - optional info-card fields: `boardNumber` (max. 50), `location`,
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed `fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
and an empty string is stored as `null` and an empty string is stored as `null`
- executes `distribution-board.update`; all submitted values, including the - `feedingDistributionBoardId` and `feedingCircuitId` are optional nullable
fields. A feeding circuit requires a feeding board and must belong to it;
self-links and cycles are rejected. A board-only link creates hierarchy
without assigning its load to an upstream circuit.
- executes `distribution-board.update` schema version 2; all submitted values, including the
submitted info fields, are restored together by persistent Undo/Redo submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` is free text until the logical feed link is modeled - `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs - duplicates the complete distribution-board subtree with new owning UUIDs
while preserving project-device and room links while preserving project-device, room and incoming feeding links
- response includes the new `distributionBoard`, its `circuitList`, the - response includes the new `distributionBoard`, its `circuitList`, the
revision and current history state revision and current history state
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId` - `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
- body: `{ "expectedRevision": 15 }` - body: `{ "expectedRevision": 15 }`
- removes the complete, exactly captured distribution-board subtree in one - removes the complete, exactly captured distribution-board subtree in one
transaction transaction
- rejects deletion while another board references this board or one of its
circuits as a feeder; circuit and populated-group deletion use the same guard
- response includes `distributionBoardId`, the revision and current history - response includes `distributionBoardId`, the revision and current history
state state
- Copy and deletion use `distribution-board.insert-subtree` and - Copy and deletion use `distribution-board.insert-subtree` and
@ -327,12 +337,17 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
### Tree Endpoint ### Tree Endpoint
- `GET /projects/:projectId/circuit-trees`
- returns all circuit trees for one project from one project-wide read and is
used by the print view and feeding-board selection
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree` - `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
- Purpose: returns fixed header components, grouped sections with optional - Purpose: returns fixed header components, grouped sections with optional
protection components, complete circuit/device-row blocks and auxiliary protection components, complete circuit/device-row blocks and auxiliary
footer components. Circuit and component protection devices use their footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included. remain included. Circuit totals contain the demand of linked downstream
boards. `downstreamBoardLoads` exposes those contributions as read-only rows;
`feedingReference` resolves the current board/circuit labels by id.
Response sketch: Response sketch:

View file

@ -33,10 +33,13 @@
- Protection and cable values are planner-owned manual selections. The later - Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or sizing/warning engine does not yet validate them against load, length or
voltage-drop rules. voltage-drop rules.
- Distribution-board `fedFrom` is free text. The print overview derives its - One logical incoming feed per distribution board is supported. A second or
supply structure and "Σ inkl. unterlagert" only from exact text matches with replacement feed with changeover semantics (for example SV/SiBe) is still a
board numbers/names and adds values without an upstream simultaneity factor follow-up. `fedFrom` remains the free-text field for external sources and
(see `docs/print-view.md`). supplementary notes. Revit does not yet propose logical feed links.
- A logical link without a selected feeding circuit appears in the hierarchy,
but deliberately contributes no load to an upstream circuit until the planner
selects that circuit (see `docs/print-view.md`).
- The print view uses a kbp corporate design derived from logo and - The print view uses a kbp corporate design derived from logo and
presentation material. Snapshots do not record an editor, so the cover's presentation material. Snapshots do not record an editor, so the cover's
Planstände list shows the project's current editor for every entry. On screen, long Planstände list shows the project's current editor for every entry. On screen, long

View file

@ -468,8 +468,22 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die (`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche externe Einspeisungen und Hinweise.
`distribution-board.update` verwendet Schema-Version 2 für die optionalen
logischen Verweise `feedingDistributionBoardId` und `feedingCircuitId`.
Version-1-Befehle aus der Historie bleiben ausführbar und verändern diese
Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören;
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt.
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft. `rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen; Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen;
ältere Versionen werden ohne Gebäude hochgestuft. ältere Versionen werden ohne Gebäude hochgestuft.
@ -580,7 +594,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details:
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 8. Persistente Transfer-Integration verwenden aktuell Snapshot-Schema 9. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur

View file

@ -5,8 +5,9 @@ Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
über Projektbefehle mit Undo/Redo gepflegt. über Projektbefehle mit Undo/Redo gepflegt.
Beim Laden werden die Projektrevisionen aller Stromkreislisten und ein Beim Laden werden alle Stromkreislisten in einer projektweiten Abfrage gelesen;
abschließend erneut gelesener Projektstand verglichen. Ändert sich das Projekt deren Projektrevision und ein abschließend erneut gelesener Projektstand werden
verglichen. Ändert sich das Projekt
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
automatisch bei späteren Änderungen in einem anderen Tab. automatisch bei späteren Änderungen in einem anderen Tab.
@ -31,12 +32,14 @@ automatisch bei späteren Änderungen in einem anderen Tab.
erscheinen als orangefarbenes „[ergänzen]“. erscheinen als orangefarbenes „[ergänzen]“.
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je 2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF, Stück, nur lokale Geräte), Leistung vor Verteiler-GZF einschließlich
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“. gespeister Unterverteilungen, Verteiler-GZF, P<sub>gl</sub> einschließlich
Unterverteilungen und I<sub>B</sub>. Die Hierarchie folgt den logischen IDs.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen), (mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und
schreibgeschützten Unterverteilungszeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten. Drucken auf Folgeseiten.
@ -46,19 +49,25 @@ Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken. Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
## Vorläufige Punkte ## Berechnung und offene Punkte
- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet - **Einspeisung / Hierarchie:** `feedingDistributionBoardId` bestimmt die
einen Verteiler nur dann unter, wenn der Text eindeutig der Nummer oder Hierarchie. Ist zusätzlich `feedingCircuitId` gesetzt, fließt die Leistung
Bezeichnung eines anderen Verteilers entspricht (ohne Beachtung von der Unterverteilung nach deren eigenem Verteiler-GZF als virtuelle Last in
Groß-/Kleinschreibung und äußeren Leerzeichen). Mehrdeutige Treffer bleiben genau diesen Abgang ein. Ketten werden rekursiv berechnet; mehrere
ohne Zuordnung; „Σ inkl. unterlagert“ ist Unterverteilungen an einem Abgang sind erlaubt. Ohne Abgang bleibt es bei der
eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung Hierarchie ohne Leistungsaufschlag. `fedFrom` bezeichnet nur externe Quellen
(Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind oder ergänzende Hinweise.
offen. - **Keine Doppelzählung:** Die Abgangs- und Verteilersummen enthalten die
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert Unterverteilungsleistung bereits. Deshalb gibt es keine zusätzliche Spalte
S / (√3 · U<sub>3~</sub>) mit S aus P / cos φ je Gerätezeile „Σ inkl. unterlagert“. Die Summenzeile verwendet für hierarchische Leistungen
(fehlendes cos φ = 1) und Verteiler-GZF. nur Wurzelverteilungen; P<sub>inst</sub> summiert weiterhin die lokalen Geräte.
- **I<sub>B</sub>:** S entsteht aus Σ(P / cos φ) für Geräte und den wirksamen
cos φ der Unterverteilungen, jeweils mit dem fachlich zugehörigen GZF. Bei
einem 1-phasigen speisenden Abgang gilt I = S / U<sub>1~</sub>, sonst
I = S / (√3 · U<sub>3~</sub>). Für externe oder nur bis zur Verteilung
bekannte Einspeisungen bleibt die bisherige Darstellung als symmetrischer
Drehstromwert bestehen.
- **Corporate Design:** Farben (`--pd-*` in - **Corporate Design:** Farben (`--pd-*` in
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo `src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
@ -118,10 +127,11 @@ ihren Wert.
## Bestehende Datenbanken ## Bestehende Datenbanken
Vor dem Anwenden der additiven Migrationen `0007` und `0008` eine verifizierte Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`). Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
Snapshot-Schema 6 enthält beide Feldgruppen; die unterstützten Versionen 1–5 Snapshot-Schema 9 enthält zusätzlich die logischen Einspeisungsverweise; die
werden beim Lesen hochgestuft. Restore und Projektkopie erhalten die neuen unterstützten Versionen 1–8 werden mit leeren Verweisen hochgestuft. Restore und
Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
zum logischen Projektsnapshot oder Projekttransfer. zum logischen Projektsnapshot oder Projekttransfer.

View file

@ -149,9 +149,10 @@ The pre-release cleanup established the following compatibility boundary:
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison - [x] Rough power balance for LPH 2/3 by cost group, including the comparison
with the detailed balance and the explicit copy of one row as a with the detailed balance and the explicit copy of one row as a
ProjectDevice. ProjectDevice.
- [x] Logical distribution-board feed link with an optional feeding circuit,
recursive load rollup and deletion guards (#3).
Open follow-ups (Gitea issues): logical feed link between distribution boards Open follow-ups (Gitea issues): bill of quantities export (#5), area-based loads in W/m² for
(#3), bill of quantities export (#5), area-based loads in W/m² for
ProjectDevices and device rows (#20). ProjectDevices and device rows (#20).
## Recommended Sequence ## Recommended Sequence

View file

@ -7,10 +7,9 @@ import "../../../../frontend/print/print-document.css";
import { import {
deleteDocumentLogo, deleteDocumentLogo,
documentLogoUrl, documentLogoUrl,
getCircuitTree, getProjectCircuitTrees,
getDocumentBranding, getDocumentBranding,
getProject, getProject,
listCircuitLists,
listDistributionBoards, listDistributionBoards,
listFloors, listFloors,
listProjectSnapshots, listProjectSnapshots,
@ -31,7 +30,7 @@ import {
import { PrintDocument } from "../../../../frontend/print/print-document"; import { PrintDocument } from "../../../../frontend/print/print-document";
import { import {
assertPrintRevisionConsistency, assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy, buildBoardHierarchy,
summarizePrintBoard, summarizePrintBoard,
type PrintBoardSummary, type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model"; } from "../../../../frontend/print/print-document-model";
@ -89,17 +88,20 @@ export default function ProjectPrintPage() {
} }
let cancelled = false; let cancelled = false;
async function load() { async function load() {
const [project, boards, circuitLists, floors, snapshots, branding] = const [project, boards, projectTrees, floors, snapshots, branding] =
await Promise.all([ await Promise.all([
getProject(projectId), getProject(projectId),
listDistributionBoards(projectId), listDistributionBoards(projectId),
listCircuitLists(projectId), getProjectCircuitTrees(projectId),
listFloors(projectId), listFloors(projectId),
listProjectSnapshots(projectId), listProjectSnapshots(projectId),
getDocumentBranding(), getDocumentBranding(),
]); ]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name])); const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const summaries = await Promise.all( const treeByBoardId = new Map(
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
);
const summaries =
[...boards] [...boards]
.sort( .sort(
(left, right) => (left, right) =>
@ -107,20 +109,17 @@ export default function ProjectPrintPage() {
boardSortCollator.compare(left.name, right.name) boardSortCollator.compare(left.name, right.name)
) )
.flatMap((board) => { .flatMap((board) => {
const circuitList = circuitLists.find( const tree = treeByBoardId.get(board.id);
(list) => list.distributionBoardId === board.id return tree ? [{ board, tree }] : [];
);
return circuitList ? [{ board, circuitList }] : [];
}) })
.map(async ({ board, circuitList }) => .map(({ board, tree }) =>
summarizePrintBoard({ summarizePrintBoard({
board, board,
circuitListId: circuitList.id, circuitListId: tree.circuitListId,
tree: await getCircuitTree(projectId, circuitList.id), tree,
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null, floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
}) })
) );
);
const finalProject = await getProject(projectId); const finalProject = await getProject(projectId);
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision); assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
if (cancelled) { if (cancelled) {
@ -148,7 +147,7 @@ export default function ProjectPrintPage() {
}, [projectId]); }, [projectId]);
const entries = useMemo( const entries = useMemo(
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []), () => (data ? buildBoardHierarchy(data.summaries) : []),
[data] [data]
); );
const stand = useMemo( const stand = useMemo(

View file

@ -19,14 +19,14 @@ export interface PrintBoardSummary {
circuitCount: number; circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */ /** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number; installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */ /** Device-row demand plus downstream-board demand before this board's GZF. */
devicePowerKw: number; powerBeforeBoardSimultaneityKw: number;
simultaneityFactor: number; simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */ /** Total active power with this board's simultaneity factor applied. */
demandPowerKw: number; demandPowerKw: number;
/** /**
* Symmetrical three-phase design current from the apparent power * Design current from the apparent power including downstream boards.
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1. * The selected feeder circuit determines whether one or three phases apply.
*/ */
designCurrentA: number; designCurrentA: number;
} }
@ -46,8 +46,13 @@ export function summarizePrintBoard(input: {
circuitCount += 1; circuitCount += 1;
for (const row of circuit.deviceRows) { for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit; installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
} }
apparentPowerKva +=
circuit.circuitApparentPower ??
circuit.deviceRows.reduce(
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
0
);
} }
} }
const simultaneityFactor = tree.distributionBoardSimultaneityFactor; const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
@ -55,12 +60,13 @@ export function summarizePrintBoard(input: {
...input, ...input,
circuitCount, circuitCount,
installedPowerKw, installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower, powerBeforeBoardSimultaneityKw: tree.distributionBoardTotalPower,
simultaneityFactor, simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor, demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA: designCurrentA: calculateDesignCurrent(
(apparentPowerKva * simultaneityFactor * 1000) / apparentPowerKva * simultaneityFactor,
(Math.sqrt(3) * tree.threePhaseVoltageV), tree
),
}; };
} }
@ -68,8 +74,6 @@ export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary; summary: PrintBoardSummary;
depth: number; depth: number;
parentBoardId: string | null; parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
} }
export function assertPrintRevisionConsistency( export function assertPrintRevisionConsistency(
@ -82,31 +86,21 @@ export function assertPrintRevisionConsistency(
} }
} }
/** export function buildBoardHierarchy(
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
summaries: PrintBoardSummary[] summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] { ): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) => const summaryById = new Map(
value?.trim().toLocaleLowerCase("de") || null; summaries.map((summary) => [summary.board.id, summary])
);
const parentById = new Map<string, string | null>(); const parentById = new Map<string, string | null>();
for (const summary of summaries) { for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom); const parentId = summary.board.feedingDistributionBoardId;
const candidates = fedFrom parentById.set(
? summaries.filter( summary.board.id,
(candidate) => parentId && parentId !== summary.board.id && summaryById.has(parentId)
candidate !== summary && ? parentId
(normalize(candidate.board.boardNumber) === fedFrom || : null
normalize(candidate.board.name) === fedFrom) );
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
} }
for (const summary of summaries) { for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]); const visited = new Set<string>([summary.board.id]);
@ -125,20 +119,12 @@ export function buildPreliminaryBoardHierarchy(
summaries.filter( summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId (summary) => parentById.get(summary.board.id) === boardId
); );
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = []; const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => { const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({ entries.push({
summary, summary,
depth, depth,
parentBoardId: parentById.get(summary.board.id) ?? null, parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
}); });
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1)); childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
}; };
@ -146,6 +132,25 @@ export function buildPreliminaryBoardHierarchy(
return entries; return entries;
} }
export function formatFeedingReference(summary: PrintBoardSummary): string {
const reference = summary.tree.feedingReference;
if (!reference) return summary.board.fedFrom ?? "–";
return `${
reference.distributionBoardNumber ?? reference.distributionBoardName
}${reference.circuitEquipmentIdentifier ?? ""}`;
}
function calculateDesignCurrent(
apparentPowerKva: number,
tree: CircuitTreeResponseDto
) {
const category = tree.feedingReference?.circuitCategory;
return category === "lighting" || category === "single_phase"
? (apparentPowerKva * 1000) / tree.singlePhaseVoltageV
: (apparentPowerKva * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV);
}
const kilowattFormat = new Intl.NumberFormat("de-DE", { const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,

View file

@ -13,6 +13,7 @@ import {
formatCircuitCable, formatCircuitCable,
formatCurrent, formatCurrent,
formatFactor, formatFactor,
formatFeedingReference,
formatKilowatt, formatKilowatt,
formatProtectionDevice, formatProtectionDevice,
formatUnitPower, formatUnitPower,
@ -269,8 +270,8 @@ function OverviewSheet({
stand: string; stand: string;
}) { }) {
const roots = entries.filter((entry) => entry.parentBoardId === null); const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped // Root boards may still carry an external feed description such as
// below that text as an external feed (e.g. "HAK / Zähleranlage"). // "HAK / Zähleranlage"; logical board links determine the actual tree.
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>(); const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) { for (const root of roots) {
const feed = root.summary.board.fedFrom ?? ""; const feed = root.summary.board.fedFrom ?? "";
@ -320,11 +321,10 @@ function OverviewSheet({
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th> <th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th> <th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th> <th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th> <th className="pd-r">P vor Vert.-GZF<br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th> <th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th> <th className="pd-r">P<sub>gl</sub> inkl. UV<br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th> <th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -340,41 +340,45 @@ function OverviewSheet({
<td>{board.location ?? "–"}</td> <td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td> <td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td> <td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td> <td>{formatFeedingReference(entry.summary)}</td>
<td className="pd-r">{entry.summary.circuitCount}</td> <td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td> <td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td> <td className="pd-r">{formatKilowatt(entry.summary.powerBeforeBoardSimultaneityKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td> <td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td> <td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td> <td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td> <td colSpan={7}>Summe der Einspeisungen</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td> <td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td> <td className="pd-r">{formatKilowatt(sum((entry) =>
<td /> entry.parentBoardId === null
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td> ? entry.summary.powerBeforeBoardSimultaneityKw
: 0
))}</td>
<td /> <td />
<td className="pd-r">{formatKilowatt(sum((entry) =>
entry.parentBoardId === null ? entry.summary.demandPowerKw : 0
))}</td>
<td /> <td />
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
<p className="pd-note"> <p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und Die Struktur folgt den logischen Verknüpfungen. Eine Unterverteilung
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw. wird im gewählten Abgang mit ihrer Leistung nach eigenem
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF). Verteiler-GZF berücksichtigt. Ohne gewählten Abgang erscheint sie
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche nur in der Hierarchie.
Verknüpfung und Summierung folgt.
</p> </p>
<p className="pd-legend"> <p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF · P<sub>inst</sub> = lokale Geräte ohne GZF · P vor Vert.-GZF =
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V), Geräte-GZF einschließlich gespeister Unterverteilungen · P<sub>gl</sub> =
S aus cos φ je Gerätezeile zusätzlich mit Verteiler-GZF · I<sub>B</sub> aus S und der Phasenart
des speisenden Abgangs
</p> </p>
</div> </div>
</div> </div>
@ -404,6 +408,7 @@ function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) { function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows; const rows = circuit.deviceRows;
const downstreamBoards = circuit.downstreamBoardLoads ?? [];
const circuitCells = ( const circuitCells = (
<> <>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td> <td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
@ -414,7 +419,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td>{circuit.remark ?? ""}</td> <td>{circuit.remark ?? ""}</td>
</> </>
); );
if (rows.length === 0) { if (rows.length === 0 && downstreamBoards.length === 0) {
return ( return (
<tr className="pd-circuit pd-reserve"> <tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td> <td className="pd-bmk">{circuit.equipmentIdentifier}</td>
@ -426,7 +431,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr> </tr>
); );
} }
if (rows.length === 1) { if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0]; const row = rows[0];
const title = circuit.displayName ?? row.displayName; const title = circuit.displayName ?? row.displayName;
return ( return (
@ -468,6 +473,16 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td colSpan={4} /> <td colSpan={4} />
</tr> </tr>
))} ))}
{downstreamBoards.map((load) => (
<tr className="pd-device" key={`downstream:${load.distributionBoardId}`}>
<td />
<td>Unterverteilung {load.boardNumber ?? load.name}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">{formatKilowatt(load.activePowerKw)}</td>
<td colSpan={4}>Automatisch aus der verknüpften Verteilung</td>
</tr>
))}
</> </>
); );
} }
@ -501,7 +516,7 @@ function BoardPlanSheet({
rows={[ rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"], ["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"], ["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"], ["Gespeist von", formatFeedingReference(summary)],
[ [
<>P<sub>gl</sub></>, <>P<sub>gl</sub></>,
<> <>
@ -542,7 +557,7 @@ function BoardPlanSheet({
<td colSpan={5}> <td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW) Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td> </td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td> <td className="pd-r">{formatKilowatt(summary.powerBeforeBoardSimultaneityKw)}</td>
<td colSpan={4} /> <td colSpan={4} />
</tr> </tr>
<tr> <tr>

View file

@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
assertPrintRevisionConsistency, assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy, buildBoardHierarchy,
formatFeedingReference,
formatCircuitCable, formatCircuitCable,
formatProtectionDevice, formatProtectionDevice,
summarizePrintBoard, summarizePrintBoard,
@ -27,6 +28,8 @@ function board(
location: null, location: null,
fedFrom: null, fedFrom: null,
remark: null, remark: null,
feedingDistributionBoardId: null,
feedingCircuitId: null,
...info, ...info,
}; };
} }
@ -52,6 +55,8 @@ function tree(
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0); const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
return { return {
circuitListId: "list", circuitListId: "list",
distributionBoardId: "board",
distributionBoardName: "Board",
currentRevision: 1, currentRevision: 1,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
@ -79,7 +84,13 @@ function tree(
sortOrder: 1, sortOrder: 1,
isReserve: false, isReserve: false,
circuitTotalPower: total, circuitTotalPower: total,
circuitApparentPower: deviceRows.reduce(
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
0
),
circuitCosPhi: 1,
deviceRows, deviceRows,
downstreamBoardLoads: [],
}, },
], ],
}, },
@ -117,16 +128,15 @@ describe("print document model", () => {
assert.equal(formatCircuitCable(circuit), null); assert.equal(formatCircuitCable(circuit), null);
}); });
it("does not guess a parent when feed text matches more than one board", () => { it("does not derive hierarchy from the external free text", () => {
const summaries = [ const summaries = [
summary("a", { boardNumber: "HV" }, 2), summary("a", { boardNumber: "HV" }, 2),
summary("b", { name: "HV" }, 3), summary("b", { name: "HV" }, 3),
summary("uv", { fedFrom: " hv " }, 1), summary("uv", { fedFrom: " hv " }, 1),
]; ];
for (const ordered of [summaries, [...summaries].reverse()]) { for (const ordered of [summaries, [...summaries].reverse()]) {
const entries = buildPreliminaryBoardHierarchy(ordered); const entries = buildBoardHierarchy(ordered);
assert.ok(entries.every((entry) => entry.parentBoardId === null)); assert.ok(entries.every((entry) => entry.parentBoardId === null));
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
} }
}); });
@ -145,17 +155,39 @@ describe("print document model", () => {
}); });
assert.equal(result.circuitCount, 1); assert.equal(result.circuitCount, 1);
assert.equal(result.installedPowerKw, 14); assert.equal(result.installedPowerKw, 14);
assert.equal(result.devicePowerKw, 10); assert.equal(result.powerBeforeBoardSimultaneityKw, 10);
assert.equal(result.demandPowerKw, 5); assert.equal(result.demandPowerKw, 5);
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400) // S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9); assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
}); });
it("nests boards by matching free-text feed and sums downstream demand", () => { it("uses the selected one-phase feeder circuit for the design current", () => {
const entries = buildPreliminaryBoardHierarchy([ const inputTree = tree([
{ quantity: 1, powerPerUnit: 2.3, simultaneityFactor: 1, cosPhi: 1 },
]);
inputTree.feedingReference = {
distributionBoardId: "hv",
circuitListId: "hv-list",
distributionBoardName: "Hauptverteilung",
distributionBoardNumber: "NSHV",
circuitId: "out",
circuitEquipmentIdentifier: "-2F1.1",
circuitCategory: "single_phase",
};
const result = summarizePrintBoard({
board: board("uv"),
circuitListId: "uv",
floorName: null,
tree: inputTree,
});
assert.ok(Math.abs(result.designCurrentA - 10) < 1e-9);
});
it("nests boards by their logical feeding references", () => {
const entries = buildBoardHierarchy([
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10), summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4), summary("uv-a", { boardNumber: "UV 1.1", feedingDistributionBoardId: "hv" }, 4),
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2), summary("uv-b", { boardNumber: "UV 1.2", feedingDistributionBoardId: "uv-a" }, 2),
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1), summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
]); ]);
assert.deepEqual( assert.deepEqual(
@ -163,28 +195,37 @@ describe("print document model", () => {
entry.summary.board.id, entry.summary.board.id,
entry.depth, entry.depth,
entry.parentBoardId, entry.parentBoardId,
entry.cumulatedDemandPowerKw,
]), ]),
[ [
["hv", 0, null, 16], ["hv", 0, null],
["uv-a", 1, "hv", 6], ["uv-a", 1, "hv"],
["uv-b", 2, "uv-a", 2], ["uv-b", 2, "uv-a"],
["uv-c", 0, null, 1], ["uv-c", 0, null],
] ]
); );
}); });
it("keeps boards with cyclic feed texts at the top level", () => { it("keeps malformed cyclic logical links visible at the top level", () => {
const entries = buildPreliminaryBoardHierarchy([ const entries = buildBoardHierarchy([
summary("a", { boardNumber: "A", fedFrom: "B" }, 1), summary("a", { boardNumber: "A", feedingDistributionBoardId: "b" }, 1),
summary("b", { boardNumber: "B", fedFrom: "A" }, 1), summary("b", { boardNumber: "B", feedingDistributionBoardId: "a" }, 1),
]); ]);
assert.equal(entries.length, 2); assert.equal(entries.length, 2);
assert.ok(entries.some((entry) => entry.parentBoardId === null)); assert.ok(entries.some((entry) => entry.parentBoardId === null));
assert.equal( });
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
2 it("formats the resolved feeding board and circuit instead of free text", () => {
); const value = summary("uv", { fedFrom: "Alttext" }, 1);
value.tree.feedingReference = {
distributionBoardId: "hv",
circuitListId: "hv-list",
distributionBoardName: "Hauptverteilung",
distributionBoardNumber: "NSHV",
circuitId: "out",
circuitEquipmentIdentifier: "-3F1.1",
circuitCategory: "three_phase",
};
assert.equal(formatFeedingReference(value), "NSHV-3F1.1");
}); });
it("formats protection devices for the plan table", () => { it("formats protection devices for the plan table", () => {