model: TroCandidate statt OpenPoint, tro_overrides nutzt model-Klassen (Schema v3)

- OpenPoint -> model.TroCandidate (type, position, belongsTo, note);
  TroDocument.candidates ersetzt openPoints, Schema sps_skel-tro-v3
- Positionsbehandlung von TroBase und TroCandidate im Mixin _Positioned
- tro_overrides.py: eigene ConnectionSpec/OpenPoint entfallen, stattdessen
  model.Connection / model.TroCandidate; einheitliche Feldnamen
  (tro_id/separator, hosts als Liste)
- Fixtures, Beispiel-JSON und Doku auf v3 nachgezogen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-10 11:31:05 +02:00
co-authored by Claude Opus 5.5
parent de8ecb9460
commit 29077caa81
10 changed files with 153 additions and 134 deletions
+2 -3
View File
@@ -152,7 +152,7 @@ extraction, not code to execute or modify.
Since 2026-10-09 the main path is **CSV → `tro_flow.py --json` → TRO-JSON → Since 2026-10-09 the main path is **CSV → `tro_flow.py --json` → TRO-JSON →
`scl_skeleton.py` → SCL**. Layer order (who may import whom): `tro_catalog.py` (TRO *types*) `scl_skeleton.py` → SCL**. Layer order (who may import whom): `tro_catalog.py` (TRO *types*)
← `lib/model.py` (TRO *instances* and the TRO-JSON: `TroBase` → `DerivedTro`, `Connection`, ← `lib/model.py` (TRO *instances* and the TRO-JSON: `TroBase` → `DerivedTro`, `Connection`,
`OpenPoint`, `Analysis`, `TroDocument`) ← derivation (`material_flow.py`, `tro_flow.py`) ← `TroCandidate`, `Analysis`, `TroDocument`) ← derivation (`material_flow.py`, `tro_flow.py`) ←
outputs (`scl_skeleton.py`, `tro_annotate.py`, reports). `items`/`fbType` are always read outputs (`scl_skeleton.py`, `tro_annotate.py`, reports). `items`/`fbType` are always read
from the catalog via the TRO type, never stored. See `doc/Python_Scripts.md` §6b. from the catalog via the TRO type, never stored. See `doc/Python_Scripts.md` §6b.
Inside the SCL output, `lib/scl_context.py` (TRO-JSON → render context, all derivations) Inside the SCL output, `lib/scl_context.py` (TRO-JSON → render context, all derivations)
@@ -165,7 +165,7 @@ and status: `doc/SCL_Templates.md`. Golden files in `tests/fixtures/scl/` pin th
byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended change. byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended change.
1. **`lib/tro_flow.py --json`** (implemented) — writes `<csv>_tro.json` (schema 1. **`lib/tro_flow.py --json`** (implemented) — writes `<csv>_tro.json` (schema
`sps_skel-tro-v2`, `model.TroDocument`): TROs, system boundaries `SYSn`, connections with `sps_skel-tro-v3`, `model.TroDocument`): TROs, system boundaries `SYSn`, connections with
their kind, findings. See `doc/Python_Scripts.md` §4. their kind, findings. See `doc/Python_Scripts.md` §4.
2. **`lib/scl_skeleton.py`** (implemented) — reads that TRO-JSON through `TroDocument` (a 2. **`lib/scl_skeleton.py`** (implemented) — reads that TRO-JSON through `TroDocument` (a
wrong/renamed key or `items` not matching the catalog stops the run) and emits an `FB_Main` SCL wrong/renamed key or `items` not matching the catalog stops the run) and emits an `FB_Main` SCL
@@ -194,7 +194,6 @@ What is **not** built out yet, relative to the original two-tool design:
standalone files) — only `FB_Main` is generated today; standalone files) — only `FB_Main` is generated today;
- **type-based timing defaults** from `doc/TRO_Typen.md` and the `OVERRIDE_TIMING_JSON` CAD - **type-based timing defaults** from `doc/TRO_Typen.md` and the `OVERRIDE_TIMING_JSON` CAD
attribute (timings are emitted as `TODO(E-Planung): pruefen` placeholders for now); attribute (timings are emitted as `TODO(E-Planung): pruefen` placeholders for now);
- `tro_overrides.py` using the shared `model.Connection` / `model.OpenPoint` classes;
- `Component` classes per part type (Separator, Switch, Scanner, VarioDrive, …) on the TRO, - `Component` classes per part type (Separator, Switch, Scanner, VarioDrive, …) on the TRO,
driving the per-part SCL emitters in `scl_skeleton.py`. driving the per-part SCL emitters in `scl_skeleton.py`.
+21 -8
View File
@@ -60,7 +60,7 @@ Supporting libraries (no CLI of their own, imported by the tools above):
name, Siemens FB block, sub-components and counts, color group, CAD marker name, Siemens FB block, sub-components and counts, color group, CAD marker
shape. shape.
- **`lib/model.py`** — the shared pydantic model of TRO *instances* and of the - **`lib/model.py`** — the shared pydantic model of TRO *instances* and of the
TRO-JSON (`TroBase`, `DerivedTro`, `Connection`, `OpenPoint`, `Analysis`, TRO-JSON (`TroBase`, `DerivedTro`, `Connection`, `TroCandidate`, `Analysis`,
`TroDocument`). Written by `tro_flow.py --json`, read and validated by `TroDocument`). Written by `tro_flow.py --json`, read and validated by
`scl_skeleton.py`. See §6b. `scl_skeleton.py`. See §6b.
- **`lib/dxf_registration.py`** — derives, verifies and persists the rigid - **`lib/dxf_registration.py`** — derives, verifies and persists the rigid
@@ -294,7 +294,7 @@ colors all come from `lib/tro_catalog.py` — nothing is hard-coded here.
| `--doc` | — | off | Also write a Markdown report (`<name>_tro_doc.md`) with the TRO list, connections, findings and the full type catalog. | | `--doc` | — | off | Also write a Markdown report (`<name>_tro_doc.md`) with the TRO list, connections, findings and the full type catalog. |
| `--connect` | — | off | Also write the TRO topology as `<name>_tro_connect.ini` (nodes, connections, system boundaries, predecessors/successors per TRO). Pure output, never read back. | | `--connect` | — | off | Also write the TRO topology as `<name>_tro_connect.ini` (nodes, connections, system boundaries, predecessors/successors per TRO). Pure output, never read back. |
| `--true-scale` | — | off | Only with `--use-cords`: draw to scale. By default empty stretches longer than 1500 mm between nodes are shortened (order and neighbourhood are kept). | | `--true-scale` | — | off | Only with `--use-cords`: draw to scale. By default empty stretches longer than 1500 mm between nodes are shortened (order and neighbourhood are kept). |
| `--json` | — | off | Also write the TRO-JSON `<name>_tro.json` (schema `sps_skel-tro-v2`, model `TroDocument` in `lib/model.py`): TROs, system boundaries (`SYSn`), connections with their kind, findings. Input for `scl_skeleton.py`. | | `--json` | — | off | Also write the TRO-JSON `<name>_tro.json` (schema `sps_skel-tro-v3`, model `TroDocument` in `lib/model.py`): TROs, system boundaries (`SYSn`), connections with their kind, findings. Input for `scl_skeleton.py`. |
TROs, types and connections always come from the layout alone; TROs, types and connections always come from the layout alone;
`cfg/tro_overrides.ini` is not read. Errors in the derivation are fixed in `cfg/tro_overrides.ini` is not read. Errors in the derivation are fixed in
@@ -533,7 +533,7 @@ TRO in a plant, and the whole TRO-JSON, look like. It imports only the catalog.
``` ```
tro_catalog.py type level TroDefinition (items, FB block, color, symbol) tro_catalog.py type level TroDefinition (items, FB block, color, symbol)
▲ get_tro() ▲ get_tro()
model.py instance level TroBase ─► DerivedTro, Connection, OpenPoint, model.py instance level TroBase ─► DerivedTro, Connection, TroCandidate,
Analysis, TroDocument Analysis, TroDocument
▲ ▲
tro_flow.py (writes) · scl_skeleton.py (reads) · tro_annotate.py (draws) tro_flow.py (writes) · scl_skeleton.py (reads) · tro_annotate.py (draws)
@@ -544,14 +544,21 @@ tro_flow.py (writes) · scl_skeleton.py (reads) · tro_annotate.py (draws)
| `TroBase` | Core of every TRO: `id`, `type`, `confidence`, `separators`, `predecessors`, `successors`, `position` (`{x,y,z}` in JSON). `definition()`, `fb_block` (JSON `fbType`) and `items` are **read from the catalog** on every access, never stored — so they follow a type change and cannot go stale. | | `TroBase` | Core of every TRO: `id`, `type`, `confidence`, `separators`, `predecessors`, `successors`, `position` (`{x,y,z}` in JSON). `definition()`, `fb_block` (JSON `fbType`) and `items` are **read from the catalog** on every access, never stored — so they follow a type change and cannot go stale. |
| `DerivedTro` | `TroBase` + what `tro_flow.py` knows from the derivation: `label`, `hosts`, `scanners`, `reason`, `colocated`, `positionFrom`, `positionPoints`. Also used for the system boundaries `SYSn`. | | `DerivedTro` | `TroBase` + what `tro_flow.py` knows from the derivation: `label`, `hosts`, `scanners`, `reason`, `colocated`, `positionFrom`, `positionPoints`. Also used for the system boundaries `SYSn`. |
| `Connection` | Directed edge `from` → `to` with its `kind` (transfer / umlauf / weiche / unbestimmt / normal / bahn). | | `Connection` | Directed edge `from` → `to` with its `kind` (transfer / umlauf / weiche / unbestimmt / normal / bahn). |
| `OpenPoint` | A reported spot that is not a TRO yet (empty in the JSON for now; meant to replace the class of the same name in `tro_overrides.py`). | | `TroCandidate` | A spot reported in a review where a TRO is **expected** but not derived (formerly `OpenPoint`): `name`, `type` (the expected catalog type), `position`, `belongsTo` (the TRO that holds the part today), `note`. Not a TRO — no separators, no connections, no SCL. Built by `tro_overrides.py` from `[<csv>.openpoint.<Name>]`; empty in the TRO-JSON for now. |
| `Analysis` | Working result of `tro_flow.analyse_tros` (moved here from `tro_flow.py`). | | `Analysis` | Working result of `tro_flow.analyse_tros` (moved here from `tro_flow.py`). |
| `TroDocument` | The TRO-JSON: `schema` (`sps_skel-tro-v2`), `source`, `created`, `note`, `tros`, `boundaries`, `connections`, `openPoints`, `findings`. `from_analysis()`, `to_json()`, `from_json()`. | | `TroDocument` | The TRO-JSON: `schema` (`sps_skel-tro-v3`), `source`, `created`, `note`, `tros`, `boundaries`, `connections`, `candidates`, `findings`. `from_analysis()`, `to_json()`, `from_json()`. |
`TroBase` and `TroCandidate` share the `position` handling (JSON `{x,y,z}`,
`x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`.
The same data has the same field name in every class, including
`tro_overrides.py`: `tro_id`, `type_name`, `separators`, `scanners`, `hosts`,
`label`, `reason`, `confidence`, `position`.
All models validate on creation and assignment and reject unknown keys All models validate on creation and assignment and reject unknown keys
(`extra="forbid"`). JSON keys keep the camelCase names of the TRO-JSON (`id`, (`extra="forbid"`). JSON keys keep the camelCase names of the TRO-JSON (`id`,
`type`, `fbType`, `openPoints` …); Python code uses the field names `type`, `fbType`, `belongsTo` …); Python code uses the field names
(`tro_id`, `type_name`, `fb_block`, `open_points`) — both are accepted on input. (`tro_id`, `type_name`, `fb_block`, `belongs_to`) — both are accepted on input.
Schema v3 differs from v2 only in `candidates` replacing `openPoints`.
Tests: `tests/test_model.py` (round trip, catalog-backed fields, rejection of Tests: `tests/test_model.py` (round trip, catalog-backed fields, rejection of
wrong keys/items, SCL rendering from a document) — run with wrong keys/items, SCL rendering from a document) — run with
@@ -634,7 +641,13 @@ Three kinds of entry, deliberately different in weight:
flow node is primary, the rest stay attached so the linking still sees both flow node is primary, the rest stay attached so the linking still sees both
sides of a transition. Like splits, this renumbers. sides of a transition. Like splits, this renumbers.
- **`openpoint`** — a spot reported in a review that is **not** turned into a - **`openpoint`** — a spot reported in a review that is **not** turned into a
TRO. The structure stays untouched. TRO. The structure stays untouched. Read as `model.TroCandidate` (`expect` →
`type_name`, `x`/`y`/`z` → `position`, missing `z` = 0).
In Python the entries are `TroOverride` (`tro_id` *or* `separator`, from
`TRO<n>` / `SEP<n>`), `SplitSpec`/`MergeSpec` (`hosts` is a list; the INI key
stays `host`, comma-separated), `model.Connection` for `[<csv>.connections]`,
and `model.TroCandidate` — field names as in `model.py`.
Consumer: `material_flow.py --connect` (writes the `[<plant>.manual]` section Consumer: `material_flow.py --connect` (writes the `[<plant>.manual]` section
into `connect.ini`). Keep in mind: **TRO IDs are running numbers of one run and into `connect.ini`). Keep in mind: **TRO IDs are running numbers of one run and
+2 -2
View File
@@ -12,7 +12,7 @@ It is written so that it fits the existing main path unchanged:
``` ```
export.csv ─► tro_flow --json ─► <name>_tro.json ─► scl_skeleton ─► <name>_FB_Main.scl export.csv ─► tro_flow --json ─► <name>_tro.json ─► scl_skeleton ─► <name>_FB_Main.scl
(dxfmakros (derive) (TroDocument, (render) (skeleton with (dxfmakros (derive) (TroDocument, (render) (skeleton with
EXPORTCSV) sps_skel-tro-v2) TODO(E-Planung)) EXPORTCSV) sps_skel-tro-v3) TODO(E-Planung))
``` ```
Nothing upstream of `scl_skeleton` changes. The TRO-JSON stays the only input, and the Nothing upstream of `scl_skeleton` changes. The TRO-JSON stays the only input, and the
@@ -451,7 +451,7 @@ bin\scl_skeleton.bat --json <name>_tro.json [--templates [DIR]] [--strict] [--na
| File | Where | Who writes it | | File | Where | Who writes it |
|---|---|---| |---|---|---|
| `<name>.csv` | `%SKEL_DATA%` | dxfmakros `EXPORTCSV` | | `<name>.csv` | `%SKEL_DATA%` | dxfmakros `EXPORTCSV` |
| `<name>_tro.json` | `%SKEL_RESULTS%` | `tro_flow --json` (schema `sps_skel-tro-v2`) | | `<name>_tro.json` | `%SKEL_RESULTS%` | `tro_flow --json` (schema `sps_skel-tro-v3`) |
| `scl_naming.ini`, `<name>_scl_naming.ini` | `%SKEL_CFG%` | you, once per project (optional) | | `scl_naming.ini`, `<name>_scl_naming.ini` | `%SKEL_CFG%` | you, once per project (optional) |
| `lib/scl_templates/*.scl.j2` | repo | maintained with the PLC engineers | | `lib/scl_templates/*.scl.j2` | repo | maintained with the PLC engineers |
+1 -1
View File
@@ -477,5 +477,5 @@
"to": "TRO05" "to": "TRO05"
} }
], ],
"openPoints": [] "candidates": []
} }
+5 -5
View File
@@ -2349,7 +2349,7 @@ def render_connect_ini(graph: Graph, section: str = "Anlage", overrides=None) ->
add("") add("")
if overrides is not None and (overrides.tros or overrides.splits if overrides is not None and (overrides.tros or overrides.splits
or overrides.open_points): or overrides.candidates):
add(f"[{section}.manual]") add(f"[{section}.manual]")
add("; Vor Ort bestaetigt, im CSV-Export nicht enthalten.") add("; Vor Ort bestaetigt, im CSV-Export nicht enthalten.")
add(f"; Quelle: {overrides.source}") add(f"; Quelle: {overrides.source}")
@@ -2358,16 +2358,16 @@ def render_connect_ini(graph: Graph, section: str = "Anlage", overrides=None) ->
for section_key, override in sorted(overrides.tros.items()): for section_key, override in sorted(overrides.tros.items()):
if not override.type_name: if not override.type_name:
continue continue
wie = f"Separator {override.key}" if override.by_separator else override.key wie = f"Separator {override.separator}" if override.separator else override.tro_id
add(f"{section_key} = {override.type_name} | {wie} | {override.reason}") add(f"{section_key} = {override.type_name} | {wie} | {override.reason}")
for spec in overrides.splits: for spec in overrides.splits:
add(f"{spec.name} = NEU {spec.type_name} " add(f"{spec.name} = NEU {spec.type_name} "
f"| Separator {', '.join(spec.separators)}" f"| Separator {', '.join(spec.separators)}"
+ (f" + Scanner {', '.join(spec.scanners)}" if spec.scanners else "") + (f" + Scanner {', '.join(spec.scanners)}" if spec.scanners else "")
+ (f" | Bahn {spec.host}" if spec.host else "") + (f" | Bahn {', '.join(spec.hosts)}" if spec.hosts else "")
+ f" | {spec.reason}") + f" | {spec.reason}")
for point in overrides.open_points: for point in overrides.candidates:
add(f"{point.name} = OFFEN {point.expect or '?'} " add(f"{point.name} = OFFEN {point.type_name or '?'} "
f"| x={point.x:.2f} y={point.y:.2f} " f"| x={point.x:.2f} y={point.y:.2f} "
f"| heute Teil von {point.belongs_to or '-'} " f"| heute Teil von {point.belongs_to or '-'} "
f"| {point.note}") f"| {point.note}")
+64 -49
View File
@@ -19,20 +19,23 @@ Klassen:
DerivedTro TroBase + was tro_flow bei der Ableitung zusaetzlich weiss DerivedTro TroBase + was tro_flow bei der Ableitung zusaetzlich weiss
(Hosts, Scanner, Begruendung, Positionsquelle ...) (Hosts, Scanner, Begruendung, Positionsquelle ...)
Connection eine gerichtete Verbindung zwischen zwei TROs/Stationen Connection eine gerichtete Verbindung zwischen zwei TROs/Stationen
OpenPoint eine gemeldete Stelle, die noch kein TRO ist TroCandidate eine im Review gemeldete Stelle, an der ein TRO erwartet
wird, die aber (noch) keiner ist
Analysis Arbeitsergebnis von tro_flow.analyse_tros Analysis Arbeitsergebnis von tro_flow.analyse_tros
TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json
geschrieben, von scl_skeleton gelesen und geprueft geschrieben, von scl_skeleton gelesen und geprueft
In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`, In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`,
`fbType`, `openPoints`, ...); im Python-Code heissen die Felder weiter `fbType`, `candidates`, ...); im Python-Code heissen die Felder weiter
`tro_id`/`type_name` (Aliase, `populate_by_name`). `tro_id`/`type_name` (Aliase, `populate_by_name`). Gleiche Daten heissen in
allen Klassen gleich (auch in tro_overrides.py): `type_name`, `separators`,
`hosts`, `position`, `reason`, `confidence`.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@@ -51,14 +54,15 @@ __all__ = [
"TroBase", "TroBase",
"DerivedTro", "DerivedTro",
"Connection", "Connection",
"OpenPoint", "TroCandidate",
"Analysis", "Analysis",
"TroDocument", "TroDocument",
] ]
# Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung), # Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung),
# v2 die von tro_flow --json (aus dem CSV, ueber dieses Modell). # v2 die von tro_flow --json (aus dem CSV, ueber dieses Modell), v3 wie v2 mit
SCHEMA = "sps_skel-tro-v2" # `candidates` statt `openPoints`.
SCHEMA = "sps_skel-tro-v3"
# Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte # Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt # Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
@@ -71,7 +75,48 @@ _CONFIG = ConfigDict(
) )
class TroBase(BaseModel): class _Positioned(BaseModel):
"""
Anlagenkoordinate (mm) als Feld ``position``, in der JSON {"x", "y", "z"}.
Das Feld selbst deklariert jede Unterklasse, damit es dort in der
JSON-Reihenfolge an seiner Stelle steht.
"""
if TYPE_CHECKING:
position: Any
@field_validator("position", mode="before", check_fields=False)
@classmethod
def _position_from_json(cls, value: Any) -> Any:
if isinstance(value, dict):
return (value["x"], value["y"], value.get("z", 0.0))
return value
@field_serializer("position", when_used="json-unless-none", check_fields=False)
def _position_to_json(self, value: tuple[float, float, float]) -> dict[str, float]:
return {"x": round(value[0], 2), "y": round(value[1], 2), "z": round(value[2], 2)}
@property
def x(self) -> float | None:
return self.position[0] if self.position else None
@property
def y(self) -> float | None:
return self.position[1] if self.position else None
@property
def z(self) -> float | None:
return self.position[2] if self.position else None
def describe_position(self) -> str:
"""Koordinate als Text, z. B. '1030, 4700'."""
if not self.position:
return "-"
return f"{self.position[0]:.0f}, {self.position[1]:.0f}"
class TroBase(_Positioned):
""" """
Kern eines TRO (Instanz in einer Anlage), unabhaengig von seiner Quelle. Kern eines TRO (Instanz in einer Anlage), unabhaengig von seiner Quelle.
@@ -145,37 +190,6 @@ class TroBase(BaseModel):
) )
return data return data
# -- Position ------------------------------------------------------------
@field_validator("position", mode="before")
@classmethod
def _position_from_json(cls, value: Any) -> Any:
if isinstance(value, dict):
return (value["x"], value["y"], value.get("z", 0.0))
return value
@field_serializer("position", when_used="json-unless-none")
def _position_to_json(self, value: tuple[float, float, float]) -> dict[str, float]:
return {"x": round(value[0], 2), "y": round(value[1], 2), "z": round(value[2], 2)}
@property
def x(self) -> float | None:
return self.position[0] if self.position else None
@property
def y(self) -> float | None:
return self.position[1] if self.position else None
@property
def z(self) -> float | None:
return self.position[2] if self.position else None
def describe_position(self) -> str:
"""Koordinate als Text, z. B. '1030, 4700'."""
if not self.position:
return "-"
return f"{self.position[0]:.0f}, {self.position[1]:.0f}"
class DerivedTro(TroBase): class DerivedTro(TroBase):
""" """
@@ -210,22 +224,23 @@ class Connection(BaseModel):
kind: str = "" kind: str = ""
class OpenPoint(BaseModel): class TroCandidate(_Positioned):
"""Eine im Review gemeldete Stelle, die noch kein eigener TRO ist.""" """
Eine im Review gemeldete Stelle, an der ein TRO erwartet wird.
Kein TRO: keine Separatoren, keine Verbindungen, keine SCL. Das Bauteil
steckt heute in einem anderen TRO (``belongs_to``). ``type_name`` ist der
hier erwartete Katalogtyp.
"""
model_config = _CONFIG model_config = _CONFIG
name: str name: str
x: float type_name: str = Field(default="", alias="type")
y: float position: tuple[float, float, float]
z: float | None = None
expect: str = ""
belongs_to: str = Field(default="", alias="belongsTo") belongs_to: str = Field(default="", alias="belongsTo")
note: str = "" note: str = ""
def describe_position(self) -> str:
return f"{self.x:.0f}, {self.y:.0f}"
class Analysis(BaseModel): class Analysis(BaseModel):
"""Ergebnis von tro_flow.analyse_tros: TROs, Kanten, Befunde.""" """Ergebnis von tro_flow.analyse_tros: TROs, Kanten, Befunde."""
@@ -272,7 +287,7 @@ class TroDocument(BaseModel):
# Systemgrenzen (SYSn): Materialein-/ausgang, kein TRO - keine SCL # Systemgrenzen (SYSn): Materialein-/ausgang, kein TRO - keine SCL
boundaries: list[DerivedTro] = Field(default_factory=list) boundaries: list[DerivedTro] = Field(default_factory=list)
connections: list[Connection] = Field(default_factory=list) connections: list[Connection] = Field(default_factory=list)
open_points: list[OpenPoint] = Field(default_factory=list, alias="openPoints") candidates: list[TroCandidate] = Field(default_factory=list)
findings: list[str] = Field(default_factory=list) findings: list[str] = Field(default_factory=list)
@classmethod @classmethod
+41 -60
View File
@@ -26,15 +26,19 @@ mehrere Anlagen in einer Datei stehen koennen):
reason = ... Begruendung (kommt in die Doku) reason = ... Begruendung (kommt in die Doku)
confidence = bestaetigt Vertrauensangabe (Default: bestaetigt) confidence = bestaetigt Vertrauensangabe (Default: bestaetigt)
[<csv>.openpoint.<Name>] offener Punkt - noch KEIN TRO [<csv>.openpoint.<Name>] offener Punkt - noch KEIN TRO (model.TroCandidate)
x = 529.95 Anlagenkoordinate in mm x = 529.95 Anlagenkoordinate in mm
y = -3895.27 y = -3895.27
z = 1468 z = 1468
expect = 1Sep1Swi hier erwarteter TRO-Typ expect = 1Sep1Swi hier erwarteter TRO-Typ (-> type_name)
belongs_to = TRO10 TRO, der das Bauteil heute enthaelt belongs_to = TRO10 TRO, der das Bauteil heute enthaelt
note = ... Erlaeuterung note = ... Erlaeuterung
Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**. Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**.
Die Felder heissen wie in lib/model.py (`type_name`, `separators`, `hosts`,
`reason`, `confidence`); Verbindungen und offene Punkte sind direkt
`model.Connection` bzw. `model.TroCandidate`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -44,6 +48,7 @@ import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from model import Connection, TroCandidate
from tro_catalog import get_tro from tro_catalog import get_tro
OVERRIDE_FILE = "tro_overrides.ini" OVERRIDE_FILE = "tro_overrides.ini"
@@ -63,16 +68,17 @@ class TroOverride:
wegfallen; die Separator-Nummer aus dem CSV-Export tut das nicht. wegfallen; die Separator-Nummer aus dem CSV-Export tut das nicht.
""" """
key: str # genau eins von beiden ist gesetzt
by_separator: bool = False tro_id: str = ""
separator: str = ""
type_name: str = "" type_name: str = ""
reason: str = "" reason: str = ""
confidence: str = "" confidence: str = ""
def matches(self, tro) -> bool: def matches(self, tro) -> bool:
if self.by_separator: if self.separator:
return self.key in tro.separators return self.separator in tro.separators
return self.key == tro.tro_id return self.tro_id == tro.tro_id
@dataclass @dataclass
@@ -90,21 +96,12 @@ class SplitSpec:
type_name: str type_name: str
separators: list[str] = field(default_factory=list) separators: list[str] = field(default_factory=list)
scanners: list[str] = field(default_factory=list) scanners: list[str] = field(default_factory=list)
host: str = "" hosts: list[str] = field(default_factory=list)
label: str = "" label: str = ""
reason: str = "" reason: str = ""
confidence: str = DEFAULT_CONFIDENCE confidence: str = DEFAULT_CONFIDENCE
@dataclass
class ConnectionSpec:
"""Eine vor Ort bestaetigte Verbindung zwischen zwei TROs."""
src: str
dst: str
kind: str = ""
@dataclass @dataclass
class MergeSpec: class MergeSpec:
"""Mehrere abgeleitete TROs zu einem Steuerobjekt zusammenfassen. """Mehrere abgeleitete TROs zu einem Steuerobjekt zusammenfassen.
@@ -119,28 +116,12 @@ class MergeSpec:
name: str name: str
type_name: str type_name: str
separators: list[str] = field(default_factory=list) separators: list[str] = field(default_factory=list)
host: str = "" hosts: list[str] = field(default_factory=list)
label: str = "" label: str = ""
reason: str = "" reason: str = ""
confidence: str = DEFAULT_CONFIDENCE confidence: str = DEFAULT_CONFIDENCE
@dataclass
class OpenPoint:
"""Eine im Review gemeldete Stelle, die noch kein eigener TRO ist."""
name: str
x: float
y: float
z: float | None = None
expect: str = ""
belongs_to: str = ""
note: str = ""
def describe_position(self) -> str:
return f"{self.x:.0f}, {self.y:.0f}"
@dataclass @dataclass
class Overrides: class Overrides:
"""Alle Korrekturen zu einer CSV-Datei.""" """Alle Korrekturen zu einer CSV-Datei."""
@@ -150,15 +131,15 @@ class Overrides:
splits: list[SplitSpec] = field(default_factory=list) splits: list[SplitSpec] = field(default_factory=list)
merges: list[MergeSpec] = field(default_factory=list) merges: list[MergeSpec] = field(default_factory=list)
# Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig # Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig
connections: list[ConnectionSpec] = field(default_factory=list) connections: list[Connection] = field(default_factory=list)
open_points: list[OpenPoint] = field(default_factory=list) candidates: list[TroCandidate] = field(default_factory=list)
# Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku) # Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku)
applied: list[str] = field(default_factory=list) applied: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
def __bool__(self) -> bool: def __bool__(self) -> bool:
return bool(self.tros or self.splits or self.merges or self.connections return bool(self.tros or self.splits or self.merges or self.connections
or self.open_points) or self.candidates)
def override_path(cfg_dir: Path | None = None) -> Path: def override_path(cfg_dir: Path | None = None) -> Path:
@@ -179,6 +160,10 @@ def _as_float(raw: str, where: str, warnings: list[str]) -> float | None:
return None return None
def _as_list(raw: str) -> list[str]:
return [s.strip() for s in raw.split(",") if s.strip()]
def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides: def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
"""Korrekturen zu `csv_name` laden (leeres Ergebnis, wenn es keine gibt).""" """Korrekturen zu `csv_name` laden (leeres Ergebnis, wenn es keine gibt)."""
path = override_path(cfg_dir) path = override_path(cfg_dir)
@@ -205,7 +190,7 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
) )
continue continue
result.connections.append( result.connections.append(
ConnectionSpec(src=src, dst=dst, kind=rest.strip()) Connection(src=src, dst=dst, kind=rest.strip())
) )
continue continue
@@ -230,8 +215,8 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
key = name.upper() key = name.upper()
by_sep = key.startswith("SEP") by_sep = key.startswith("SEP")
result.tros[key] = TroOverride( result.tros[key] = TroOverride(
key=key[3:] if by_sep else key, tro_id="" if by_sep else key,
by_separator=by_sep, separator=key[3:] if by_sep else "",
type_name=type_name, type_name=type_name,
reason=values.get("reason", "").strip(), reason=values.get("reason", "").strip(),
confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE,
@@ -244,7 +229,7 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
f"- neuer TRO nicht angelegt" f"- neuer TRO nicht angelegt"
) )
continue continue
seps = [s.strip() for s in values.get("separators", "").split(",") if s.strip()] seps = _as_list(values.get("separators", ""))
if not seps: if not seps:
result.warnings.append( result.warnings.append(
f"[{section}]: 'separators' fehlt - ohne Separator kein TRO" f"[{section}]: 'separators' fehlt - ohne Separator kein TRO"
@@ -255,9 +240,8 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
name=name, name=name,
type_name=type_name, type_name=type_name,
separators=seps, separators=seps,
scanners=[s.strip() for s in values.get("scanners", "").split(",") scanners=_as_list(values.get("scanners", "")),
if s.strip()], hosts=_as_list(values.get("hosts", values.get("host", ""))),
host=values.get("host", "").strip(),
label=values.get("label", "").strip(), label=values.get("label", "").strip(),
reason=values.get("reason", "").strip(), reason=values.get("reason", "").strip(),
confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE,
@@ -271,7 +255,7 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
f"- Zusammenfassung nicht durchgefuehrt" f"- Zusammenfassung nicht durchgefuehrt"
) )
continue continue
seps = [s.strip() for s in values.get("separators", "").split(",") if s.strip()] seps = _as_list(values.get("separators", ""))
if len(seps) < 2: if len(seps) < 2:
result.warnings.append( result.warnings.append(
f"[{section}]: 'separators' braucht mindestens zwei Nummern" f"[{section}]: 'separators' braucht mindestens zwei Nummern"
@@ -282,7 +266,7 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
name=name, name=name,
type_name=type_name, type_name=type_name,
separators=seps, separators=seps,
host=values.get("host", "").strip(), hosts=_as_list(values.get("hosts", values.get("host", ""))),
label=values.get("label", "").strip(), label=values.get("label", "").strip(),
reason=values.get("reason", "").strip(), reason=values.get("reason", "").strip(),
confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE,
@@ -294,30 +278,27 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
if x is None or y is None: if x is None or y is None:
continue continue
z_raw = values.get("z", "").strip() z_raw = values.get("z", "").strip()
expect = values.get("expect", "").strip() z = _as_float(z_raw, f"[{section}] z", result.warnings) if z_raw else None
if expect and get_tro(expect) is None: type_name = values.get("expect", "").strip()
if type_name and get_tro(type_name) is None:
result.warnings.append( result.warnings.append(
f"[{section}]: erwarteter Typ '{expect}' steht nicht im " f"[{section}]: erwarteter Typ '{type_name}' steht nicht im "
f"TRO-Katalog" f"TRO-Katalog"
) )
result.open_points.append( result.candidates.append(
OpenPoint( TroCandidate(
name=name, name=name,
x=x, type_name=type_name,
y=y, position=(x, y, z if z is not None else 0.0),
z=_as_float(z_raw, f"[{section}] z", result.warnings)
if z_raw
else None,
expect=expect,
belongs_to=values.get("belongs_to", "").strip(), belongs_to=values.get("belongs_to", "").strip(),
note=values.get("note", "").strip(), note=values.get("note", "").strip(),
) )
) )
else: else:
result.warnings.append( result.warnings.append(
f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro' oder " f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', "
f"'openpoint'" f"'merge' oder 'openpoint'"
) )
result.open_points.sort(key=lambda p: p.name) result.candidates.sort(key=lambda p: p.name)
return result return result
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"schema": "sps_skel-tro-v2", "schema": "sps_skel-tro-v3",
"source": "HundM05.csv", "source": "HundM05.csv",
"created": "2026-10-09 11:30", "created": "2026-10-09 11:30",
"note": "Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die Zeichnung ist nur Ansicht, keine Datenquelle.", "note": "Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die Zeichnung ist nur Ansicht, keine Datenquelle.",
@@ -917,7 +917,7 @@
"kind": "bahn" "kind": "bahn"
} }
], ],
"openPoints": [], "candidates": [],
"findings": [ "findings": [
"Scanner 0019 'Scanner :1': montiert an 0006-L, liest aber an Separator 0018 auf 0005", "Scanner 0019 'Scanner :1': montiert an 0006-L, liest aber an Separator 0018 auf 0005",
"TRO16 (VF_5): Scanner 0019 am Separator - als 1Sep eingestuft. Pruefen, ob 1Sep_SSCC (SSCC-Scanner, Endmessung, WCS-Telegramm) erforderlich ist; das ist aus dem mechanischen Layout nicht erkennbar.", "TRO16 (VF_5): Scanner 0019 am Separator - als 1Sep eingestuft. Pruefen, ob 1Sep_SSCC (SSCC-Scanner, Endmessung, WCS-Telegramm) erforderlich ist; das ist aus dem mechanischen Layout nicht erkennbar.",
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"schema": "sps_skel-tro-v2", "schema": "sps_skel-tro-v3",
"source": "mubea.csv", "source": "mubea.csv",
"created": "2026-10-09 11:38", "created": "2026-10-09 11:38",
"note": "Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die Zeichnung ist nur Ansicht, keine Datenquelle.", "note": "Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die Zeichnung ist nur Ansicht, keine Datenquelle.",
@@ -888,7 +888,7 @@
"kind": "normal" "kind": "normal"
} }
], ],
"openPoints": [], "candidates": [],
"findings": [ "findings": [
"Kreisel2: Ausschleuselement 0057 und ILS Weiche 0125 benachbart - als 2Sep2Swi zusammengefasst (FB_ILS_MTRO_2Sep2Swi fehlt noch in der ILSLib)", "Kreisel2: Ausschleuselement 0057 und ILS Weiche 0125 benachbart - als 2Sep2Swi zusammengefasst (FB_ILS_MTRO_2Sep2Swi fehlt noch in der ILSLib)",
"Scanner 0008 'Scanner :5': montiert an 0010, liest aber an Separator 0042 auf 0010-R", "Scanner 0008 'Scanner :5': montiert an 0010, liest aber an Separator 0042 auf 0010-R",
+13 -2
View File
@@ -6,7 +6,7 @@ import json
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from model import SCHEMA, Analysis, Connection, DerivedTro, TroDocument from model import SCHEMA, Analysis, Connection, DerivedTro, TroCandidate, TroDocument
from scl_skeleton import render from scl_skeleton import render
@@ -64,7 +64,18 @@ def test_json_uses_tro_json_keys(document):
assert first["items"] == {"Separator": 1, "Switch": 1} assert first["items"] == {"Separator": 1, "Switch": 1}
assert first["position"] == {"x": 1030.0, "y": 4700.0, "z": 1500.0} assert first["position"] == {"x": 1030.0, "y": 4700.0, "z": 1500.0}
assert data["connections"][1] == {"from": "TRO01", "to": "TRO02", "kind": "weiche"} assert data["connections"][1] == {"from": "TRO01", "to": "TRO02", "kind": "weiche"}
assert "openPoints" in data assert data["candidates"] == []
def test_candidate_round_trip():
candidate = TroCandidate(name="Qualitaetskontrolle", type_name="1Sep1Swi",
position=(-1026.0, 5250.05, 1478.5), belongs_to="TRO09")
data = json.loads(candidate.model_dump_json(by_alias=True))
assert data["type"] == "1Sep1Swi"
assert data["position"] == {"x": -1026.0, "y": 5250.05, "z": 1478.5}
assert data["belongsTo"] == "TRO09"
assert TroCandidate.model_validate(data) == candidate
assert candidate.describe_position() == "-1026, 5250"
def test_items_and_fb_block_follow_the_type(): def test_items_and_fb_block_follow_the_type():