Files
sps_skel/doc/Klassenhierarchie.md
T

21 KiB

Klassenhierarchie

Bestandsaufnahme aller Python-Klassen in lib/ (Stand 2026-10-10). Kurzes Ergebnis vorweg: es gibt praktisch keine Vererbung in diesem Code (eine Ausnahme: DerivedTro(TroBase), plus die Standard-Exception RegistrationError(RuntimeError)). Alles andere sind flache, eigenständige Klassen — entweder @dataclass (veränderlicher Laufzeit-/Analysezustand) oder Pydantic BaseModel (validierte Daten, die auch als JSON ein-/ausgehen). Eine klassische "ist-ein"-Hierarchie zu suchen wäre hier der falsche Blickwinkel; interessanter ist, wer wen enthält/referenziert und in welchem Modul-Layer eine Klasse sitzt (siehe Layer-Kommentar oben in model.py).

Modul-Layer (wer darf wen importieren)

tro_catalog.py   (Typ-Ebene: was ein TRO-Typ ist)
      |
      v
   model.py      (Instanz-Ebene: ein konkreter TRO, die TRO-JSON)
      |
      v
 tro_flow.py      (Ableitung: CSV-Graph -> Analysis -> TroDocument)
      |
      +----------------------+
      v                      v
scl_context.py          tro_annotate.py   (Ausgabe: annotierte Zeichnung)
(TroDocument -> SclFile, Render-Kontext)
      |
      v
scl_skeleton.py         (Ausgabe: SclFile -> FB_Main.scl, generisch oder per Jinja-Template)

material_flow.py  dxf_registration.py   (eigenständig, ohne Abhängigkeit auf obige)
tro_overrides.py  (importiert model.Connection / model.TroCandidate)

tro_flow.py und tro_annotate.py definieren keine eigenen Klassen (nur Funktionen). scl_skeleton.py hat genau eine (SclTemplates, Jinja-Anbindung). material_flow.py hat Graph-Dataclasses — das ist CSV-Rohmodell, nicht TRO-Modell.

Vererbung (ASCII)

RuntimeError (stdlib)                    BaseModel (pydantic, stdlib: pydantic)
   |                                         |
   +-- RegistrationError                    +-- _Positioned  (model.py, Mixin)
       (dxf_registration.py)                     |
                                                  +-- TroBase
                                                  |    +-- DerivedTro
                                                  +-- TroCandidate

Das ist die gesamte class X(Y)-Liste im Projekt — alle anderen Klassen unten haben object/BaseModel direkt als einzige Basis und erscheinen hier nur noch als Gruppen nach Datei.

Klassen nach Datei, mit Komposition ("enthält")

lib/tro_catalog.py                          [Typ-Katalog, keine Instanzdaten]
├── TroItem            (Konstanten-Klasse: Bauteilarten wie SEPARATOR, SWITCH)
├── TroSymbol           (Konstanten-Klasse: Zeichensymbole wie CIRCLE, TRIANGLE)
├── TroStyle(BaseModel)   Farbe/Darstellung einer Typgruppe
└── TroDefinition(BaseModel)
    └─ style: TroStyle                      (1 TroDefinition hat genau 1 TroStyle)

lib/model.py                                 [Instanz-Ebene / TRO-JSON]
├── TroBase(BaseModel)
│   └─ definition() --> TroDefinition         (gelesen aus tro_catalog, nicht gespeichert)
│   └── DerivedTro(TroBase)                   [einzige echte Vererbung im Projekt]
├── _Positioned(BaseModel)                    (Mixin: position {x,y,z}, describe_position)
│   ├── TroBase, TroCandidate
├── Connection(BaseModel)                     (from/to/kind zwischen zwei TRO-IDs)
├── TroCandidate(_Positioned)                 (gemeldete Stelle, an der ein TRO erwartet wird)
├── Analysis(BaseModel)
│   ├─ tros: list[DerivedTro]
│   ├─ isolated: list[DerivedTro]
│   └─ boundaries: list[DerivedTro]
└── TroDocument(BaseModel)                    (= <csv>_tro.json)
    ├─ tros: list[DerivedTro]
    ├─ boundaries: list[DerivedTro]
    ├─ connections: list[Connection]
    └─ candidates: list[TroCandidate]

lib/material_flow.py                         [CSV-Rohmodell -> Graph, @dataclass]
├── Element                                   (1 CSV-Zeile)
├── Node
│   └─ element: Element
├── Edge
├── Graph
│   ├─ nodes: dict[str, Node]
│   ├─ edges: list[Edge]
│   └─ unassigned: list[Element]
├── TrackNode
│   └─ element: Element | None
├── TrackEdge
└── TrackGraph
    ├─ nodes: dict[str, TrackNode]
    └─ edges: list[TrackEdge]

lib/tro_overrides.py                         [manuelle Korrekturen aus cfg/tro_overrides.ini, @dataclass]
├── TroOverride         (Typkorrektur an einem abgeleiteten TRO: tro_id ODER separator)
├── SplitSpec           (TRO aus einem bestehenden heraustrennen)
├── MergeSpec           (mehrere TROs zu einem zusammenfassen)
└── Overrides            (Sammelcontainer für eine CSV-Datei)
    ├─ tros: dict[str, TroOverride]
    ├─ splits: list[SplitSpec]
    ├─ merges: list[MergeSpec]
    ├─ connections: list[model.Connection]
    └─ candidates: list[model.TroCandidate]

lib/dxf_registration.py                      [CSV<->DXF Koordinatentransform]
├── RegistrationError(RuntimeError)
└── Transform(BaseModel)

lib/scl_context.py                           [Render-Kontext für die SCL, BaseModel]
├── SclName              (Wert + Merker todo: noch aus der E-Planung offen?)
├── SclSeparator         (ein stInSeparator<n>-Block)
├── SclSwitch            (ein stInSwitch<n>-Block + FC_Direction-Aufruf)
├── SclSwitchExit        (Weichenabgang + darüber erreichbare TROs)
├── SclScanner           (ein stInBarcodeReader-Block)
├── SclJam               (Staubereich an Ein-/Ausgang)
├── SclTro               (alles für eine REGION)
│   ├─ separators: list[SclSeparator]
│   ├─ switches: list[SclSwitch]
│   ├─ switch_exits: list[SclSwitchExit]
│   ├─ scanners: list[SclScanner]
│   ├─ jam_entries / jam_exits: list[SclJam]
│   └─ main_tro, safety, ... : SclName
└── SclFile              (die ganze Datei)
    └─ tros: list[SclTro]                     (in Fliessrichtung)

lib/scl_skeleton.py                          [SCL-Ausgabe]
└── SclTemplates         (Ordner mit <fbType>.scl.j2, hält ein jinja2.Environment)

Auffälligkeiten für ein mögliches Refactoring

  1. OpenPoint existiert zweimal — erledigt 2026-10-10: zu model.TroCandidate zusammengefasst (expect → type_name, x/y/z → position); ConnectionSpec durch model.Connection ersetzt; tro_overrides.py importiert beide aus model.py.
  2. Zwei parallele Graph-Hierarchien in material_flow.py (Node/Edge/Graph vs. TrackNode/TrackEdge/TrackGraph) teilen sich keine Basisklasse, obwohl Struktur und Feldnamen sich stark ähneln (node_id, kind, ... / src, dst, ...). Eine gemeinsame leichte Basis (z. B. ein generisches GraphBase[NodeT, EdgeT]) wäre möglich, aber angesichts der wenigen Stellen, die beide Typen anfassen, vermutlich mehr Abstraktion als Nutzen.
  3. TroItem und TroSymbol sind reine Konstanten-Namespaces (keine Instanzen, keine Methoden außer TroItem.all()), kein Pydantic-Modell wie der Rest von tro_catalog.py. Das ist bewusst so (siehe Docstring: "damit Tippfehler auffallen") und funktioniert wie ein Enum-Ersatz — ein StrEnum wäre eine nahe liegende, idiomatischere Alternative, falls ein Refactoring ansteht.
  4. Die einzige echte Vererbung (DerivedTro(TroBase)) ist sauber: TroBase ist bereits so geschnitten, dass es für Systemgrenzen (boundaries) wiederverwendet wird, DerivedTro ergänzt nur das, was die CSV-Ableitung zusätzlich weiß. Kein Handlungsbedarf.

Fazit: Die Code-Basis nutzt Vererbung bewusst kaum — die Architektur trennt stattdessen über Module (Typ-Katalog vs. Instanz vs. Ableitung vs. Ausgabe, siehe model.py-Kopfkommentar) und über Komposition. Ein Refactoring in Richtung "mehr Vererbung" wäre hier eher kontraproduktiv. Die doppelte OpenPoint-Definition (1) ist behoben; offen bleibt nur optional StrEnum statt Konstanten-Klassen (3).

Die komplexen Klassen im Einzelnen

Die folgenden Abschnitte beschreiben je Klasse, wozu sie da ist, was darin passiert und wer sie erzeugt bzw. liest. Einfache Datenhalter ohne eigene Logik — Connection, TroCandidate, Edge, TrackEdge, TroItem/TroSymbol und die kleinen Blöcke SclSeparator, SclSwitch, SclSwitchExit, SclScanner, SclJam — sind im Baum oben ausreichend beschrieben und bekommen keinen eigenen Abschnitt.

TroDefinition (+ TroStyle) — lib/tro_catalog.py

Wozu: Beschreibt einen TRO-Typ, nicht eine Stelle in der Anlage: Kurzname (1Sep1Swi), Siemens-FB (FB_ILS_MTRO_1Sep1Swi), Bauteile ({"Separator": 1, "Switch": 1}), Farbgruppe und CAD-Symbol. Der Katalog aller Definitionen ist die ausführbare Form von doc/TRO_Typen.md.

Was passiert darin: Pydantic mit validate_assignment und extra="forbid" — eine Definition kann nie ungültig werden, auch nicht über die set_*-Methoden. _clean_items wirft Bauteile mit Anzahl ≤ 0 heraus. Ein eigener __init__ erlaubt die alte Positionsform TroDefinition("1Sep", "FB_...", {...}). Dazu kommen Abfragen (get_items, has_item, get_item_fb, get_total_items) und Darstellungshelfer (get_dot_attrs, mermaid_class/_classdef, describe_items). TroStyle liefert nur die Farben einer Gruppe für Graphviz, Mermaid und CAD.

Wer nutzt sie: Über get_tro(name)/catalog() jeder, der vom Typnamen auf FB oder Bauteile kommen muss — vor allem TroBase.definition(); außerdem tro_flow (Typprüfung), tro_annotate (Farbe/Symbol, --emit-lisp) und die Doku-Grafiken.

TroBase / DerivedTro — lib/model.py

Wozu: Ein konkreter TRO in einer Anlage (Instanz). TroBase ist der Kern, den jeder TRO hat, egal woher er stammt: tro_id, type_name, separators, predecessors/successors, position. DerivedTro ergänzt, was tro_flow beim Ableiten aus dem CSV zusätzlich weiß: hosts, scanners, reason, colocated, position_from/position_points. Systemgrenzen (SYSn) sind ebenfalls DerivedTro.

Was passiert darin:

  • fb_block und items sind berechnete Felder: bei jedem Zugriff aus dem Katalog gelesen (definition() → get_tro(type_name)), nie gespeichert. In der JSON stehen sie trotzdem (fbType, items), damit sie lesbar ist.
  • _check_catalog_fields (Validator vor dem Einlesen): steht in einer JSON ein items/fbType, das nicht zum Typ passt, ist das ein Fehler — "den Typ ändern, nicht die Bauteile". Danach werden die Felder verworfen.
  • Aliase: in der JSON id/type, im Code tro_id/type_name (populate_by_name).
  • _Positioned (Mixin) wandelt position zwischen Tupel und {"x","y","z"} und liefert x/y/z und describe_position().
  • DerivedTro.connected: hat der TRO überhaupt Nachbarn?

Wer nutzt sie: tro_flow.derive_tros/derive_boundaries erzeugen sie, scl_context liest sie (als TroBase), tro_annotate zeichnet sie.

Analysis — lib/model.py

Wozu: Das Arbeitsergebnis eines Ableitungslaufs, bevor daraus eine Datei wird. Enthält mehr als die JSON: Kanten als Tupel, Kantenarten, isolierte TROs, unbekannte Typen, Befunde, die verwendete Methode.

Was passiert darin: Fast nur Daten; by_id() sucht über TROs und Systemgrenzen. Befüllt wird sie in tro_flow.analyse_tros in fester Reihenfolge: derive_tros → derive_boundaries → locate_tros (Position, nötig für die Reihenfolge auf einer Kreiselbahn) → link_tros (Kanten und Kantenarten) → Prüfung gegen den Katalog.

Wer nutzt sie: tro_flow (Bericht, Diagramm, --json) und tro_annotate (zeichnet direkt aus der Analysis, ohne Umweg über die JSON).

TroDocument — lib/model.py

Wozu: Die TRO-JSON <csv>_tro.json (Schema sps_skel-tro-v3) — die Schnittstelle zwischen Ableitung (tro_flow --json) und SCL-Ausgabe (scl_skeleton). Weil beide Seiten dieselbe Klasse benutzen, kann das Format nicht unbemerkt auseinanderlaufen.

Was passiert darin: from_analysis() macht aus einer Analysis ein Dokument (Kanten + Kantenarten → Connection-Liste). to_json() schreibt mit den JSON-Aliasen, from_json() liest und prüft. extra="forbid" sorgt dafür, dass ein umbenannter oder falscher Schlüssel als Fehler auffällt statt still einen Standardwert zu bekommen; scl_skeleton.main meldet bei falschem schema gezielt "mit tro_flow neu erzeugen".

Wer nutzt sie: geschrieben von tro_flow --json, gelesen von scl_skeleton.load_document → scl_context.build_context.

Element, Graph, TrackGraph — lib/material_flow.py

Wozu: Das Rohmodell aus dem CSV-Export (ILS 2.0), noch ohne TRO-Begriff.

  • Element = eine CSV-Zeile: Art, ID, Name, Nachbarn, track_ids, Merkmale, Position, Bounding-Box, Anschlusspunkte K1..K4.
  • Graph (Node/Edge) = grober Materialfluss-Graph: Transportobjekte (Strecke, Gefällestrecke, Kreisel-Bahn) als Knoten, Geräte (Separatoren, Weichen, Scanner …) an die Knoten gehängt (Node.devices), Stationen und Übergaben als Kanten.
  • TrackGraph (TrackNode/TrackEdge) = feiner Bahnzusammenhang: jedes Bahnelement (AS, SEP, ES, Weiche, BTMT …) ist ein Knoten, verkettet in Bau- bzw. Drehrichtung (siehe doc/track.md).

Was passiert darin: Element hat viele abgeleitete Properties: Klassifizierung (is_transport, is_circle, is_device, is_station, is_transfer, is_entry/is_exit), Geometrie (x/y/z, bbox_x/y, height, height_from/to, rise), Antrieb (drive_dir, rotation). Die Graphklassen selbst sind reine Container; die Logik steckt in den Funktionen build_graph() (Knoten bilden, Geräte zuordnen, Kanten sammeln, Fliessrichtung aus Gefälle/Antrieb, Stationen und Übergaben einhängen) und build_track_graph() (Ketten aus der Spalte TrackIds; ein Element in zwei Containern verbindet diese automatisch). TrackNode kann ohne element existieren (Platzhalter für eine nicht auflösbare Bahn, generisches Ein-/Ausgang-Symbol).

Wer nutzt sie: material_flow selbst (DOT/SVG/connect.ini/Bericht) und tro_flow.analyse_tros(graph, elements) als Eingang der TRO-Ableitung; tro_annotate für Ankerpunkte des Lagebezugs.

Overrides (+ TroOverride, SplitSpec, MergeSpec) — lib/tro_overrides.py

Wozu: Von Hand gepflegte, vor Ort bestätigte Korrekturen aus cfg/tro_overrides.ini für das, was die Ableitung nicht erkennen kann.

  • TroOverride: Typ eines TRO korrigieren. Adressiert über tro_id oder — stabiler — über einen separator, weil TRO-Nummern sich zwischen Läufen verschieben, Separatornummern aus dem Export nicht (matches()).
  • SplitSpec: Separatoren/Scanner aus einem TRO herauslösen und einen neuen TRO bilden (Übergabestelle, deren Zuordnung auf die Strecke statt auf die Kreiselbahn zeigt).
  • MergeSpec: mehrere TROs zu einem Steuerobjekt zusammenfassen (z. B. zwei Sperren beiderseits eines Kreisel-Berührpunkts).
  • Overrides: Sammelcontainer je CSV-Datei, dazu gesetzte Topologie (connections, ersetzt die abgeleiteten Kanten vollständig), erwartete TROs (candidates), Protokoll (applied) und warnings. bool(overrides) ist True, sobald irgendeine Korrektur vorhanden ist.

Was passiert darin: load_overrides(csv_name) liest den INI-Abschnitt der CSV-Datei und baut daraus die Objekte; Lesefehler landen in warnings statt den Lauf abzubrechen.

Wer nutzt sie: Stand heute nur material_flow --connect (render_connect_ini schreibt sie als [<Anlage>.manual]-Abschnitt). tro_flow wendet sie (noch) nicht an — sie fliessen also nicht in die TRO-JSON und nicht in die SCL.

Transform (+ RegistrationError) — lib/dxf_registration.py

Wozu: Der Lagebezug zwischen CSV-Export und BricsCAD-Zeichnung: eine starre Transformation csv = R(rotation) · dxf + (dx, dy). Ohne sie landen die TRO-Marker in tro_annotate an der falschen Stelle.

Was passiert darin: to_csv()/to_dxf() rechnen Punkte hin und zurück, describe() fasst Verschiebung, Drehung, Punktzahl und Restfehler zusammen. Die Ermittlung steckt in Funktionen: derive_transform() ordnet punktartige Objekte gleicher Rolle (Separator, Scanner) aus CSV und DXF zu, stimmt über Verschiebungen ab, gleicht aus und wählt den besten Kandidaten; verify_transform() rechnet die Restfehler nach und schreibt sie ins Objekt; load_transform()/save_transform() lesen/schreiben cfg/dxf_registration.json. RegistrationError heisst: der Bezug liess sich nicht nachweisbar bestimmen — dann lieber abbrechen als falsch zeichnen.

Wer nutzt sie: tro_annotate.resolve_transform (Reihenfolge: --offset → abgelegter Bezug → neu ermitteln, jeweils mit Prüfung), dazu das geparkte tro_extract.

SclFile, SclTro, SclName — lib/scl_context.py

Wozu: Der Render-Kontext für das FB_Main-Gerüst. Alles, was aus der TRO-JSON abgeleitet wird, steht hier fertig drin; die Ausgabe (emit_*-Funktionen oder Jinja-Templates) rechnet nichts mehr selbst, sie liest nur noch. So können generischer Aufbau und Templates nicht unterschiedlich ableiten.

Was passiert darin:

  • build_context(document, source, start_id) baut aus einem TroDocument ein SclFile: Kopfdaten plus die TROs in Fliessrichtung.
  • flow_order() bestimmt die Reihenfolge der REGIONs: Start bei --start, sonst beim TRO ohne Vorgänger, sonst beim TRO, den eine Station (SYSn) speist, sonst wird der Kreislauf willkürlich aufgetrennt — der Grund steht im SCL-Kopf (start_reason). Danach Breitensuche; nicht erreichte TROs werden hinten angehängt, damit keiner verloren geht.
  • _tro_context() baut je TRO ein SclTro: so viele Separator-, Weichen- und Scannerblöcke, wie der Katalogtyp Bauteile hat (items); je Vorgänger ein Staubereich am Eingang, je Nachfolger einer am Ausgang; to1_destinations nur bei genau einer Weiche (bei mehreren ist die Aufteilung eine Planungsentscheidung); die Zielliste je Weichenabgang über reachable_via() (Erreichbarkeit im TRO-Graphen, ohne über die Weiche zurückzulaufen).
  • SclName = Wert + Merker todo. Alles, was aus der E-Planung kommen muss (Separator-/Weichennummern, Sensor-/Aktoradressen, MainTro, Staubereichs-DB …), ist heute ein Platzhalter mit todo=True; die Ausgabe hängt daran den Kommentar TODO(E-Planung). Spätere Namensregeln ersetzen nur den Wert und löschen den Merker — die Ausgabe ändert sich dafür nicht.

Wer nutzt sie: erzeugt in scl_skeleton.render()/main(), gelesen von render_context(), den emit_*-Funktionen und den Templates (als t).

SclTemplates — lib/scl_skeleton.py

Wozu: Die Anbindung der parametrisierten SCL-Aufrufstellen (lib/scl_templates/*.scl.j2) an die Ausgabe. Ein Objekt steht für einen Template-Ordner.

Was passiert darin: Der Konstruktor baut ein jinja2.Environment mit StrictUndefined (ein Tippfehler im Template wird zum Fehler statt zu einer leeren SCL-Stelle), trim_blocks/lstrip_blocks und der globalen Variable TODO. has(fb) prüft, ob <fb>.scl.j2 existiert; render(tro) rendert das Template mit t = SclTro und fasst danach mehrfache Leerzeilen zusammen (statt das mit {%- -%} im Template zu tun, was die Einrückung bricht).

Template ↔ TRO: Zuordnung und Einstieg in die Produktion

Zuordnung — es gibt keine Tabelle, nur eine Namenskonvention: Dateiname = FB-Typ. Die Kette:

DerivedTro.type_name            "1Sep1Swi"                  (TRO-JSON, Feld "type")
  -> tro_catalog.get_tro()      TroDefinition
  -> TroDefinition.fb_block     "FB_ILS_MTRO_1Sep1Swi"
  -> TroBase.fb_block           (berechnet, nicht gespeichert)
  -> SclTro.fb                  (scl_context._tro_context)
  -> SclTemplates.has/render    lib/scl_templates/FB_ILS_MTRO_1Sep1Swi.scl.j2

Ein neues Template für einen weiteren Typ heisst also einfach <fbType>.scl.j2 und wird automatisch für alle TROs dieses Typs verwendet. Heute gibt es nur den Piloten FB_ILS_MTRO_1Sep1Swi.scl.j2 (plus _macros.scl.j2 mit gemeinsamen Makros). Alle anderen Typen laufen über den generischen Aufbau (emit_region()).

Einstieg — nur per Schalter (opt-in):

  • scl_skeleton --templates [DIR] (ohne DIR: lib/scl_templates). main() legt dann ein SclTemplates an; render_context() nimmt pro TRO templates.render(tro), wenn templates.has(tro.fb), sonst emit_region(). Der SCL-Kopf listet, welche FB-Typen aus Template bzw. generisch kamen.
  • --strict bricht ab (Exit 1, keine SCL), wenn ein FB-Typ der Anlage kein Template hat. Ein TemplateError (fehlende Variable, Syntaxfehler) bricht ebenfalls ab — lieber keine SCL als eine mit stillen Lücken.
  • Ohne --templates werden Templates gar nicht geladen; die Ausgabe ist die bisherige (Golden-Files tests/fixtures/scl/*_FB_Main.scl). Mit Templates pinnen *_FB_Main_templates.scl das Ergebnis.
  • bin/scl_skeleton.bat/.sh reichen die Schalter nur durch; der Standardaufruf ohne --templates nutzt also keine Templates.

Nicht Produktion: doc/TRO_Katalog/scl_templates/*.scl sind die realen Referenz-Aufrufstellen. Gegen sie prüft tests/test_scl_templates.py jedes Template im Round-Trip; in die erzeugte SCL fliessen sie nicht ein.