Stabile TRO-IDs: ID-Datei je Anlage, Wiederfinden ueber die Lage

- lib/tro_ids.py: cfg/<csv>_tro_ids.ini lesen/schreiben, TROs ueber die
  Lage ihrer Separatoren wiederfinden, neue IDs vergeben, Weggefallene melden
- tro_flow: Nummerierung entlang des Materialflusses, Schalter --ids,
  --renumber, --id-tolerance; Bericht der IDs
- tro_annotate: liest dieselbe ID-Datei (nur lesend)
- Tests tests/test_tro_ids.py, Doku Python_Scripts.md, Konzept (Stufe 1 erledigt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-11 20:31:18 +02:00
co-authored by Claude Opus 5.5
parent ef57a2218e
commit dd2d98f2ee
7 changed files with 712 additions and 29 deletions
+7 -2
View File
@@ -36,7 +36,9 @@ Template" below). What exists today is:
`tro_annotate.py --emit-lisp`) `tro_annotate.py --emit-lisp`)
- `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform; - `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform;
`cfg/tro_overrides.ini` — hand-maintained corrections to the derived TROs (see `tro_overrides.py`); `cfg/tro_overrides.ini` — hand-maintained corrections to the derived TROs (see `tro_overrides.py`);
`cfg/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts `cfg/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts;
`cfg/<csv>_tro_ids.ini` — per-plant TRO-ID file (versioned, IDs renamed by the SPS
programmers), created/extended by `tro_flow.py` via `lib/tro_ids.py`
- `doc/*.md` — analysis documents that reverse-engineer the SCL patterns and propose the - `doc/*.md` — analysis documents that reverse-engineer the SCL patterns and propose the
fuller JSON layout schema for the parts of the pipeline still to be built out fuller JSON layout schema for the parts of the pipeline still to be built out
@@ -179,7 +181,10 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha
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-v3`, `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. TRO-IDs come from `cfg/<csv>_tro_ids.ini` (`lib/tro_ids.py`): each
TRO is found again by the position of its separators, so IDs survive a new layout export
(TeileIds do not); `--renumber` renumbers along the material flow, `--ids` points another
export of the same plant at the same file. 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
skeleton (`<source>_FB_Main.scl`): one `REGION` per TRO with the instance call and every skeleton (`<source>_FB_Main.scl`): one `REGION` per TRO with the instance call and every
+34 -16
View File
@@ -220,26 +220,42 @@ stabil: Vergleich zweier Exporte derselben Anlage
Stabil ist nur die **Lage** des Bauteils. Stabil ist nur die **Lage** des Bauteils.
**Vorschlag:** **Umgesetzt (2026-10-11, `lib/tro_ids.py`, Schalter in `tro_flow.py`;
Einzelheiten in `doc/Python_Scripts.md` §4 "TRO-IDs"):**
- Eine versionierte ID-Datei je Anlage, z. B. `cfg/<anlage>_tro_ids.ini`: je TRO - Eine versionierte ID-Datei je Anlage, `cfg/<csv>_tro_ids.ini`, Abschnitt
die ID und die Lage seines Anker-Separators (x, y in mm), optional ein Name. `[tro]`: je TRO die ID und die Lage seines Anker-Separators (x, y in mm), der
Von `tro_flow` angelegt, von den SPS-Programmierern umbenannt. Kommentar dahinter nennt Typ, Bezeichnung und Separatoren. Von `tro_flow`
angelegt, von den SPS-Programmierern umbenannt. Weitere Abschnitte (später
Separator- und Förderernummern) bleiben bei jedem Lauf erhalten. Ein anderer
Export derselben Anlage nutzt dieselbe Datei über `--ids`.
```ini ```ini
; Beispiel, Werte erfunden [tro]
[mubea] TRO01 = -400.0, 13920.0 ; 1Sep1Swi | Separator 0003 auf Kreisel2 Bahn L | Sep 0003
TRO107 = 12450.0, 3310.0 ; Kreisel 2 vor Beladung K0302_1 = 10310.0, -440.1 ; von Hand umbenannt
TRO108 = 13250.0, 3310.0
``` ```
- **Standard (IDs halten):** `tro_flow` liest die ID-Datei und findet jeden TRO - **Standard (IDs halten):** `tro_flow` liest die ID-Datei und findet jeden TRO
über die Lage des Anker-Separators wieder (Toleranz einstellbar). Gefundene TROs über die Lage seiner Separatoren wieder (Toleranz `--id-tolerance`, Standard
behalten ihre ID. Neue TROs bekommen eine vorläufige ID (nächste freie Nummer 100 mm, nächster Treffer zuerst, je TRO ein Eintrag). Gefundene TROs behalten
entlang des Materialflusses) und stehen im Bericht; weggefallene TROs stehen im ihre ID, auch umbenannte. Neue TROs bekommen die nächste freie Nummer und
Bericht, ihre ID wird nicht automatisch wiederverwendet. werden an `[tro]` angehängt; weggefallene stehen im Bericht (mit dem TRO, der
ihren Separator jetzt enthält), bleiben in der Datei, ihre ID wird nicht
wiederverwendet. `tro_annotate` liest dieselbe Datei, schreibt sie aber nicht.
- **Programmschalter `--renumber` (frühe Phase):** alle IDs neu entlang des - **Programmschalter `--renumber` (frühe Phase):** alle IDs neu entlang des
Materialflusses vergeben und die ID-Datei neu schreiben. Materialflusses vergeben (Tiefensuche ab den Beladestationen, auf Kreisel und
Strecke in `TrackIds`-Reihenfolge) und `[tro]` neu schreiben. Ohne ID-Datei
geschieht dasselbe beim ersten Lauf.
- **Geprüft** mit `mubea.csv` → `mubea_export.csv` (andere TeileIds): 18 TROs
wiedergefunden, darunter eine umbenannte ID; `TRO14` als weggefallen gemeldet,
weil der Export die Separatoren 0005 und 0036 zu einem `2Sep2Swi` (`TRO13`)
zusammenfasst.
- **Noch offen:** die Abschnitte `[mubea.csv.tro.TRO03]` usw. in
`cfg/tro_overrides.ini` hängen an der alten Nummerierung und passen schon
heute nicht mehr (`TRO03` ist dort Sep 0010, im aktuellen Lauf Sep 0011).
Sie werden auf die IDs der ID-Datei umgestellt, sobald `tro_flow` die
Overrides wieder liest.
- Dadurch werden auch die Abschnitte `[<csv>.tro.<TRO-ID>]` in - Dadurch werden auch die Abschnitte `[<csv>.tro.<TRO-ID>]` in
`cfg/tro_overrides.ini` und die Zuordnungen von `hw_assign` stabil. `cfg/tro_overrides.ini` und die Zuordnungen von `hw_assign` stabil.
@@ -317,6 +333,7 @@ unverändert.
1. **TRO-IDs stabil machen:** ID-Datei, Wiederfinden über die Lage, 1. **TRO-IDs stabil machen:** ID-Datei, Wiederfinden über die Lage,
`--renumber`. Darauf bauen Overrides, Staubereiche und Zuordnungen auf. `--renumber`. Darauf bauen Overrides, Staubereiche und Zuordnungen auf.
**Erledigt** (2026-10-11).
2. Slots und `jamAreas` in die TRO-JSON (Umbau 1), ein Eingangs-Staubereich je 2. Slots und `jamAreas` in die TRO-JSON (Umbau 1), ein Eingangs-Staubereich je
TRO — ohne Slots hat die Zuordnung kein Ziel. TRO — ohne Slots hat die Zuordnung kein Ziel.
3. `hw_import` mit dem TIA-Import als erster Quelle. 3. `hw_import` mit dem TIA-Import als erster Quelle.
@@ -335,7 +352,8 @@ erzeugen, `doc/Python_Scripts.md` ergänzen.
- **Staubereiche:** woher die Separatornummer (`Sep1024` → `J1024`) stammt; wo auf - **Staubereiche:** woher die Separatornummer (`Sep1024` → `J1024`) stammt; wo auf
einem Kreisel die Zählung `<k>` beginnt (Referenz K0102 prüfen). Das SCL-Schema einem Kreisel die Zählung `<k>` beginnt (Referenz K0102 prüfen). Das SCL-Schema
ist festgelegt (UH01–UH05). ist festgelegt (UH01–UH05).
- **Toleranz** beim Wiederfinden der TROs über die Lage; Verhalten, wenn ein - **Toleranz** beim Wiederfinden der TROs über die Lage: vorläufig 100 mm
Separator weiter verschoben wurde (Vorschlag: als "neu" + "weggefallen" melden, (`--id-tolerance`). Ein weiter verschobener Separator wird als "neu" und
der Programmierer übernimmt die ID von Hand). "weggefallen" gemeldet, der Programmierer übernimmt die ID von Hand in der
ID-Datei. Mit echten Layoutänderungen prüfen, ob der Wert passt.
- **Korrekturen in BricsCAD oder in der JSON:** zurückgestellt. - **Korrekturen in BricsCAD oder in der JSON:** zurückgestellt.
+34 -1
View File
@@ -73,6 +73,10 @@ Supporting libraries (no CLI of their own, imported by the tools above):
- **`lib/dxf_registration.py`** — derives, verifies and persists the rigid - **`lib/dxf_registration.py`** — derives, verifies and persists the rigid
(rotation + offset) transform between the CSV export's coordinate system and (rotation + offset) transform between the CSV export's coordinate system and
the DXF drawing's. the DXF drawing's.
- **`lib/tro_ids.py`** — the per-plant ID file `%SKEL_CFG%\<name>_tro_ids.ini`:
reads it, finds each TRO again by the position of its separators, assigns
new IDs, writes/extends the file. Used by `tro_flow.py` (read and write) and
`tro_annotate.py` (read only). See §4, "TRO-IDs".
- **`lib/tro_overrides.py`** — reads the hand-maintained - **`lib/tro_overrides.py`** — reads the hand-maintained
`%SKEL_CFG%\tro_overrides.ini`; only `material_flow.py --connect` still uses `%SKEL_CFG%\tro_overrides.ini`; only `material_flow.py --connect` still uses
it. See §6a. it. See §6a.
@@ -305,16 +309,44 @@ colors all come from `lib/tro_catalog.py` — nothing is hard-coded here.
| `--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-v3`, 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`. |
| `--ids` | `DATEI` | `<name>_tro_ids.ini` | ID file of the plant (name in `%SKEL_CFG%`, or a path). Use it to run another export of the same plant against the same IDs, e.g. `--file mubea_export.csv --ids mubea_tro_ids.ini`. |
| `--renumber` | — | off | Number all TROs afresh along the material flow and rewrite the `[tro]` section of the ID file (early project phase). Renamed IDs are lost; other sections of the file are kept. |
| `--id-tolerance` | `MM` (float) | `100.0` | Maximum distance between an ID-file entry and a separator of a TRO for the TRO to count as found again. |
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
`lib/tro_flow.py`, not in a correction file. `lib/tro_flow.py`, not in a correction file.
**TRO-IDs** (`lib/tro_ids.py`). The IDs belong to the SPS programmers and must
survive a new layout export; the layout `TeileId` changes between exports, the
position does not. So every run reads `%SKEL_CFG%\<name>_tro_ids.ini`:
```ini
[tro]
TRO01 = -400.0, 13920.0 ; 1Sep1Swi | Separator 0003 auf Kreisel2 Bahn L | Sep 0003
K0302_1 = 10310.0, -440.1 ; renamed by hand
```
- Each entry is `ID = x, y` (mm, CSV coordinates) of the TRO's anchor
separator. An entry finds the TRO with a separator within `--id-tolerance`
(closest first, one entry per TRO), and that TRO keeps the ID — renamed IDs
included. An ID must be unique and a valid SCL identifier; `SYSn` is
reserved for system boundaries.
- No file yet: the TROs are numbered along the material flow (depth-first from
the loading stations over the material-flow graph, on a carousel or Strecke in
`TrackIds` order) and the file is created.
- A TRO not found gets the next free `TRO<n>` and is appended to `[tro]`;
an entry without a TRO is reported ("weggefallen", with the TRO that now
holds its separator, e.g. after two TROs merged into a `2Sep2Swi`) and stays
in the file; its ID is not reused.
- The console report lists found / new / dropped IDs.
**Output** (in `%SKEL_RESULTS%`): `<name>_tro_flow.dot` (always, with `pos` **Output** (in `%SKEL_RESULTS%`): `<name>_tro_flow.dot` (always, with `pos`
attributes when `--use-cords` was used), `<name>_tro_flow.svg` (with attributes when `--use-cords` was used), `<name>_tro_flow.svg` (with
`--tosvg`), `<name>_tro_doc.md` (with `--doc`), `<name>_tro.json` (with `--tosvg`), `<name>_tro_doc.md` (with `--doc`), `<name>_tro.json` (with
`--json`), `<name>_tro_errors.md` (only if TROs are unconnected or of an `--json`), `<name>_tro_errors.md` (only if TROs are unconnected or of an
unknown type). unknown type). In `%SKEL_CFG%`: `<name>_tro_ids.ini` (created, extended or,
with `--renumber`, rewritten — see above).
**Exit codes:** `0` ok · `1` input/CLI error · `2` SVG rendering failed **Exit codes:** `0` ok · `1` input/CLI error · `2` SVG rendering failed
(Graphviz) · `3` TRO validation errors found (error file written). (Graphviz) · `3` TRO validation errors found (error file written).
@@ -365,6 +397,7 @@ overridden.
| `--legend` | — | off | Draw a legend of the color groups. | | `--legend` | — | off | Draw a legend of the color groups. |
| `--marker-size` | `MM` (float) | `300.0` | Marker radius in mm. | | `--marker-size` | `MM` (float) | `300.0` | Marker radius in mm. |
| `--text-height` | `MM` (float) | `220.0` | Text height in mm. | | `--text-height` | `MM` (float) | `220.0` | Text height in mm. |
| `--ids` | `DATEI` | `<csv-stem>_tro_ids.ini` | ID file as for `tro_flow.py`, **read only**: the markers carry the same IDs as the TRO-JSON. Without the file the TROs are numbered along the material flow, as `tro_flow.py` would. |
| `--offset` | `DX,DY` | — | Set the CSV↔DXF offset by hand instead of deriving/loading it. For a drawing built with dxfmakros (CSV exported from the same drawing) this is `0,0`. | | `--offset` | `DX,DY` | — | Set the CSV↔DXF offset by hand instead of deriving/loading it. For a drawing built with dxfmakros (CSV exported from the same drawing) this is `0,0`. |
| `--re-register` | — | off | Re-derive the coordinate offset even if a verified one is already cached in `%SKEL_CFG%`. | | `--re-register` | — | off | Re-derive the coordinate offset even if a verified one is already cached in `%SKEL_CFG%`. |
| `--check` | — | off | Dry run: only analyze and report, write no output file. | | `--check` | — | off | Dry run: only analyze and report, write no output file. |
+16 -2
View File
@@ -116,7 +116,9 @@ from tro_flow import (
analyse_tros, analyse_tros,
find_sibling_raw_json, find_sibling_raw_json,
load_sepliste_counts, load_sepliste_counts,
resolve_ids_file,
) )
from tro_ids import TroIds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Konstanten # Konstanten
@@ -844,6 +846,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
metavar="MM", help="Markerradius in mm. Standard: %(default)s") metavar="MM", help="Markerradius in mm. Standard: %(default)s")
parser.add_argument("--text-height", type=float, default=DEFAULT_TEXT, parser.add_argument("--text-height", type=float, default=DEFAULT_TEXT,
metavar="MM", help="Texthoehe in mm. Standard: %(default)s") metavar="MM", help="Texthoehe in mm. Standard: %(default)s")
parser.add_argument("--ids", metavar="DATEI",
help="ID-Datei der TROs wie bei tro_flow (nur gelesen). "
"Standard: <csv>_tro_ids.ini in %%SKEL_CFG%%.")
parser.add_argument("--offset", metavar="DX,DY", parser.add_argument("--offset", metavar="DX,DY",
help="Lagebezug von Hand setzen statt ermitteln.") help="Lagebezug von Hand setzen statt ermitteln.")
parser.add_argument("--re-register", action="store_true", parser.add_argument("--re-register", action="store_true",
@@ -907,12 +912,21 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
graph = build_graph(elements, csv_file.name, read_warnings) graph = build_graph(elements, csv_file.name, read_warnings)
# Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit # Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit
# der Separator-Gegenprobe aus <name>_raw.json), damit die Zeichnung genau # der Separator-Gegenprobe aus <name>_raw.json und der ID-Datei), damit die Zeichnung genau
# die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle. # die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle.
raw_json_file = find_sibling_raw_json(csv_file) raw_json_file = find_sibling_raw_json(csv_file)
sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings) sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings)
if raw_json_file is not None else {}) if raw_json_file is not None else {})
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts) # Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen
# dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde.
ids_file = resolve_ids_file(args.ids, csv_file)
try:
tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
if not analysis.tros: if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr) print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return 1 return 1
+186 -8
View File
@@ -90,6 +90,14 @@ Ausgabe (alles in SKEL_RESULTS)
fuer scl_skeleton.py fuer scl_skeleton.py
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden <name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
TRO-IDs (ID-Datei in SKEL_CFG)
------------------------------
Die IDs kommen aus %SKEL_CFG%/<name>_tro_ids.ini (lib/tro_ids.py): jeder TRO
wird ueber die Lage seiner Separatoren wiedergefunden und behaelt seine ID,
auch wenn die SPS-Programmierer sie umbenannt haben. Fehlt die Datei, werden
die TROs entlang des Materialflusses durchnummeriert und die Datei angelegt;
neue TROs werden angehaengt. --renumber nummeriert alles neu.
Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh. Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh.
""" """
@@ -126,6 +134,16 @@ from material_flow import (
resolve_input, resolve_input,
) )
from model import Analysis, DerivedTro, TroDocument from model import Analysis, DerivedTro, TroDocument
from tro_ids import (
DEFAULT_TOLERANCE,
TroIds,
assign_ids,
ids_path,
insert_entries,
other_sections,
render_ids,
render_new_entries,
)
from tro_catalog import ( from tro_catalog import (
CATALOG_AS_OF, CATALOG_AS_OF,
ITEM_FB, ITEM_FB,
@@ -655,6 +673,7 @@ def derive_tros(
findings: list[str], findings: list[str],
*, *,
sepliste_counts: dict[str, int] | None = None, sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None,
) -> list[DerivedTro]: ) -> list[DerivedTro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden. """TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
@@ -666,6 +685,10 @@ def derive_tros(
der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts
nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten
selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle). selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle).
tro_ids (optional): ID-Datei der Anlage. Ohne sie werden die TROs entlang
des Materialflusses neu durchnummeriert; mit ihr behalten wiedergefundene
TROs ihre ID (Ergebnis in tro_ids.result).
""" """
def resolve_host(sep: Element, findings: list[str]) -> str | None: def resolve_host(sep: Element, findings: list[str]) -> str | None:
return _default_resolve_host(sep, findings, graph) return _default_resolve_host(sep, findings, graph)
@@ -1156,11 +1179,16 @@ def derive_tros(
tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche" tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche"
tro.confidence = "niedrig" tro.confidence = "niedrig"
# 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten # 5) IDs: Reihenfolge entlang des Materialflusses, wiedergefundene TROs
tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else "")) # behalten ihre ID aus der ID-Datei (siehe lib/tro_ids.py)
width = max(2, len(str(len(tros)))) tros.sort(key=_flow_key(graph, elements_by_id))
for index, tro in enumerate(tros, start=1): anchors = separator_anchors(elements)
tro.tro_id = f"TRO{index:0{width}d}" if tro_ids is None:
assign_ids(tros, anchors, [], renumber=True)
else:
tro_ids.result = assign_ids(
tros, anchors, tro_ids.entries,
tolerance=tro_ids.tolerance, renumber=tro_ids.renumber)
# 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen # 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen
for tro in tros: for tro in tros:
@@ -1174,6 +1202,56 @@ def derive_tros(
return tros return tros
def separator_anchors(elements: list[Element]) -> dict[str, tuple[float, float]]:
"""Separator-TeileId -> (x, y) in mm, Bezugslage fuer die ID-Datei."""
return {e.teile_id: (e.position[0], e.position[1])
for e in elements if e.kind == "Separator" and e.position}
def _flow_rank(graph: Graph) -> dict[str, int]:
"""Knoten -> Rang entlang des Materialflusses (Tiefensuche).
Start an den Materialeingaengen (Beladung/Eingang), danach an jedem noch
nicht erreichten Knoten - eine geschlossene Anlage ohne Station wird so
trotzdem vollstaendig erfasst. Gleichrangige Nachfolger nach Knotennamen.
"""
succ: dict[str, list[str]] = {}
for edge in graph.edges:
succ.setdefault(edge.src, []).append(edge.dst)
starts = sorted(n for n, node in graph.nodes.items() if node.element.is_entry)
rank: dict[str, int] = {}
for start in starts + sorted(graph.nodes):
stack = [start]
while stack:
node = stack.pop()
if node in rank:
continue
rank[node] = len(rank)
stack.extend(n for n in sorted(succ.get(node, []), reverse=True)
if n not in rank)
return rank
def _flow_key(graph: Graph, elements_by_id: dict[str, Element]):
"""Sortierschluessel fuer TROs entlang des Materialflusses.
Erst der Rang des Host-Knotens, dann die Lage des Separators in der
TrackIds-Kette des Hosts (Kreisel: Umlaufrichtung, Strecke: Baureihenfolge).
"""
rank = _flow_rank(graph)
def key(tro: DerivedTro) -> tuple:
host_rank = min((rank.get(h, len(rank)) for h in tro.hosts),
default=len(rank))
sep = tro.separators[0] if tro.separators else ""
host = elements_by_id.get(tro.hosts[0]) if tro.hosts else None
track = host.track_ids if host else []
in_track = track.index(sep) if sep in track else len(track)
return (host_rank, in_track, sep)
return key
# Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT # Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT
# in TRO_CATALOG: eine Be-/Entladestation (BTMT oder generisches Eingang/ # in TRO_CATALOG: eine Be-/Entladestation (BTMT oder generisches Eingang/
# Ausgang-Symbol) ist kein Steuerobjekt mit eigenem FB-Baustein, nur der # Ausgang-Symbol) ist kein Steuerobjekt mit eigenem FB-Baustein, nur der
@@ -1781,11 +1859,15 @@ def analyse_tros(
elements: list[Element], elements: list[Element],
*, *,
sepliste_counts: dict[str, int] | None = None, sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None,
) -> Analysis: ) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.""" """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren.
"""
analysis = Analysis() analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.tros = derive_tros(graph, elements, analysis.findings,
sepliste_counts=sepliste_counts) sepliste_counts=sepliste_counts, tro_ids=tro_ids)
analysis.boundaries = derive_boundaries(elements, graph) analysis.boundaries = derive_boundaries(elements, graph)
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen: # Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines # link_tros() registriert eine Kante nur zu einem Knoten, der Host eines
@@ -2638,6 +2720,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" tro_flow.bat --file mubea.csv --tosvg --doc\n" " tro_flow.bat --file mubea.csv --tosvg --doc\n"
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n" " tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
" tro_flow.bat --file mubea.csv --json\n" " tro_flow.bat --file mubea.csv --json\n"
" tro_flow.bat --file mubea_export.csv --ids mubea_tro_ids.ini --json\n"
" tro_flow.bat --file mubea.csv --renumber\n"
"\n" "\n"
"Exit-Codes:\n" "Exit-Codes:\n"
" 0 ok\n" " 0 ok\n"
@@ -2694,9 +2778,93 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
f"Leerstrecken ueber {COORD_MAX_LUECKE} mm zwischen den Knoten " f"Leerstrecken ueber {COORD_MAX_LUECKE} mm zwischen den Knoten "
"(Reihenfolge und Nachbarschaft bleiben erhalten).", "(Reihenfolge und Nachbarschaft bleiben erhalten).",
) )
parser.add_argument(
"--ids",
metavar="DATEI",
help="ID-Datei der Anlage (Name in %%SKEL_CFG%% oder vollstaendiger "
"Pfad). Standard: <name>_tro_ids.ini in %%SKEL_CFG%%. Fehlt sie, "
"wird sie angelegt.",
)
parser.add_argument(
"--renumber",
action="store_true",
help="Alle TRO-IDs neu entlang des Materialflusses vergeben und die "
"Abschnitt [tro] der ID-Datei neu schreiben (fruehe Projektphase - "
"umbenannte IDs gehen verloren, andere Abschnitte bleiben).",
)
parser.add_argument(
"--id-tolerance",
dest="id_tolerance",
type=float,
default=DEFAULT_TOLERANCE,
metavar="MM",
help="Hoechster Abstand (mm) zwischen Eintrag der ID-Datei und "
"Separator, bis zu dem ein TRO als wiedergefunden gilt. "
"Standard: %(default)s",
)
return parser.parse_args(argv) return parser.parse_args(argv)
def resolve_ids_file(arg: str | None, csv_file: Path) -> Path:
"""Pfad der ID-Datei: --ids (Name in SKEL_CFG oder Pfad), sonst Standard."""
cfg_dir = env_dir("SKEL_CFG", "cfg")
if not arg:
return ids_path(csv_file.stem, cfg_dir)
path = Path(arg)
if path.is_absolute() or path.parent != Path("."):
return path
return cfg_dir / path
def write_ids_file(tro_ids: TroIds, analysis: Analysis, elements: list[Element],
source: str) -> str:
"""ID-Datei anlegen, neu schreiben oder neue TROs anhaengen.
Rueckgabe: kurze Beschreibung fuer den Bericht.
"""
result = tro_ids.result
if result is None:
return "unveraendert"
anchors = separator_anchors(elements)
if result.renumbered:
tro_ids.path.parent.mkdir(parents=True, exist_ok=True)
rest = other_sections(tro_ids.path)
text = render_ids(analysis.tros, anchors, source)
tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""),
encoding="utf-8")
return "neu geschrieben" if tro_ids.exists else "angelegt"
if result.new:
insert_entries(tro_ids.path,
render_new_entries(analysis.tros, anchors, result.new, source))
return f"{len(result.new)} neue(r) TRO(s) angehaengt"
return "unveraendert"
def report_ids(tro_ids: TroIds, status: str) -> None:
"""Abschnitt TRO-IDs im Konsolenbericht."""
result = tro_ids.result
if result is None:
return
print(f"TRO-IDs = {tro_ids.path} ({status})")
if result.renumbered:
grund = "--renumber" if tro_ids.renumber else "keine ID-Datei"
print(f" neu entlang des Materialflusses nummeriert "
f"({grund})")
return
print(f" wiedergefunden: {len(result.kept)}, "
f"neu: {len(result.new)}, weggefallen: {len(result.missing)} "
f"(Toleranz {tro_ids.tolerance:g} mm)")
if result.new:
print(f" + neu (vorlaeufige ID, in der ID-Datei umbenennbar): "
f"{', '.join(result.new)}")
for entry in result.missing:
merged = result.merged_into.get(entry.tro_id)
grund = (f"Separator gehoert jetzt zu {merged}" if merged else
f"kein Separator innerhalb {tro_ids.tolerance:g} mm")
print(f" - weggefallen: {entry.tro_id} bei {entry.x:.1f}, {entry.y:.1f} "
f"({grund}; Eintrag bleibt stehen, ID wird nicht wiederverwendet)")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
@@ -2739,11 +2907,20 @@ def main(argv: list[str] | None = None) -> int:
connect_target = results / f"{stem}_tro_connect.ini" connect_target = results / f"{stem}_tro_connect.ini"
json_target = results / f"{stem}_tro.json" json_target = results / f"{stem}_tro.json"
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts) try:
tro_ids = TroIds.load(resolve_ids_file(args.ids, csv_file),
tolerance=args.id_tolerance, renumber=args.renumber)
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
if not analysis.tros: if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs " print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr) f"ableitbar.", file=sys.stderr)
return 1 return 1
ids_status = write_ids_file(tro_ids, analysis, elements, csv_file.name)
dot_file = results / f"{stem}_tro_flow.dot" dot_file = results / f"{stem}_tro_flow.dot"
svg_file = results / f"{stem}_tro_flow.svg" svg_file = results / f"{stem}_tro_flow.svg"
@@ -2806,6 +2983,7 @@ def main(argv: list[str] | None = None) -> int:
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, report(analysis, csv_file, dot_file, created_svg, error_file, doc_file,
warnings, engine=engine) warnings, engine=engine)
report_ids(tro_ids, ids_status)
if connect_file: if connect_file:
print(f"connect.ini = {connect_file}") print(f"connect.ini = {connect_file}")
if json_file: if json_file:
+309
View File
@@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
"""
tro_ids.py - stabile TRO-IDs ueber eine ID-Datei je Anlage.
Die TRO-IDs vergeben die SPS-Programmierer; eine ID muss nur eindeutig sein.
Sobald SCL-Code steht, darf sich eine ID nicht mehr aendern, nur weil das
Layout neu exportiert wurde. Die Layout-TeileId taugt dafuer nicht (sie
wechselt zwischen zwei Exporten derselben Anlage), stabil ist nur die Lage.
Siehe doc/Konzept_Layout_Hardware_SCL.md, "Festlegungen" -> "TRO-IDs".
Die ID-Datei (`%SKEL_CFG%/<csv>_tro_ids.ini`, versioniert, von Hand
umbenennbar) haelt je TRO die ID und die Lage seines Anker-Separators:
[tro]
TRO01 = 1234.5, -567.8 ; 1Sep1Swi | Separator 0003 auf Kreisel2 Bahn L
K0102_1 = 2210.0, 410.0 ; von Hand umbenannt
Ablauf (assign_ids):
- ohne ID-Datei bzw. mit renumber: alle TROs in der uebergebenen Reihenfolge
(entlang des Materialflusses) neu durchnummerieren, TRO01, TRO02, ...
- mit ID-Datei: jeder Eintrag findet den TRO, dessen Separator seiner Lage am
naechsten liegt (innerhalb der Toleranz, je TRO hoechstens ein Eintrag).
Nicht gefundene TROs bekommen die naechste freie Nummer, nicht gefundene
Eintraege bleiben in der Datei stehen und ihre ID wird nicht wiederverwendet.
Das Modul liest und schreibt nur die Datei; Lage und Reihenfolge der TROs
liefert tro_flow.py.
"""
from __future__ import annotations
import configparser
import math
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
__all__ = [
"SECTION",
"DEFAULT_TOLERANCE",
"IdEntry",
"IdAssignment",
"TroIds",
"ids_path",
"load_ids",
"assign_ids",
"render_ids",
"render_new_entries",
"insert_entries",
"other_sections",
]
SECTION = "tro"
ID_FILE_SUFFIX = "_tro_ids.ini"
PREFIX = "TRO"
# Abstand (mm) zwischen Eintrag und Separator, bis zu dem ein TRO als
# wiedergefunden gilt. Separatoren derselben Bahn liegen deutlich weiter
# auseinander; ein verschobenes Bauteil faellt so als "neu" auf.
DEFAULT_TOLERANCE = 100.0
# Eine ID wird zum SCL-Bezeichner (Instanz #<ID>, "DB_TRO_TEST".<ID>).
_ID_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# SYSn sind die Systemgrenzen (tro_flow.derive_boundaries), keine TROs.
_RESERVED = re.compile(r"^SYS\d+$")
_NUMBERED = re.compile(rf"^{PREFIX}(\d+)$")
@dataclass
class IdEntry:
"""Ein Eintrag der ID-Datei: ID und Lage des Anker-Separators (mm)."""
tro_id: str
x: float
y: float
@dataclass
class IdAssignment:
"""Ergebnis von assign_ids, fuer den Bericht."""
renumbered: bool = False
# (ID, Abstand in mm) der wiedergefundenen TROs
kept: list[tuple[str, float]] = field(default_factory=list)
# IDs, die in diesem Lauf neu vergeben wurden (ohne renumbered)
new: list[str] = field(default_factory=list)
# Eintraege der Datei ohne passenden TRO
missing: list[IdEntry] = field(default_factory=list)
# weggefallene ID -> TRO, dessen Separator dort liegt, der aber schon eine
# naehere ID traegt (z. B. zwei TROs zu einem 2Sep2Swi zusammengelegt)
merged_into: dict[str, str] = field(default_factory=dict)
@dataclass
class TroIds:
"""ID-Datei einer Anlage plus Einstellungen fuer einen Lauf.
entries ist leer, wenn die Datei (noch) nicht existiert. result fuellt
tro_flow.derive_tros ueber assign_ids.
"""
path: Path
entries: list[IdEntry] = field(default_factory=list)
exists: bool = False
tolerance: float = DEFAULT_TOLERANCE
renumber: bool = False
result: IdAssignment | None = None
@classmethod
def load(cls, path: Path, *, tolerance: float = DEFAULT_TOLERANCE,
renumber: bool = False) -> "TroIds":
exists = path.is_file()
entries = load_ids(path) if exists else []
return cls(path=path, entries=entries, exists=exists,
tolerance=tolerance, renumber=renumber)
def ids_path(csv_stem: str, cfg_dir: Path) -> Path:
"""Standardpfad der ID-Datei zu einer CSV: <cfg>/<stem>_tro_ids.ini."""
return cfg_dir / f"{csv_stem}{ID_FILE_SUFFIX}"
def _parser() -> configparser.ConfigParser:
parser = configparser.ConfigParser(
inline_comment_prefixes=(";", "#"),
interpolation=None,
strict=True,
)
parser.optionxform = str # IDs nicht in Kleinbuchstaben wandeln
return parser
def load_ids(path: Path) -> list[IdEntry]:
"""ID-Datei lesen. ValueError bei doppelter ID, falscher Lage oder ID."""
parser = _parser()
try:
parser.read(path, encoding="utf-8")
except configparser.Error as exc:
raise ValueError(f"{path}: {exc}") from exc
if not parser.has_section(SECTION):
raise ValueError(f"{path}: Abschnitt [{SECTION}] fehlt")
entries: list[IdEntry] = []
for tro_id, value in parser.items(SECTION):
if not _ID_PATTERN.match(tro_id):
raise ValueError(f"{path}: '{tro_id}' ist kein gueltiger SCL-Bezeichner")
if _RESERVED.match(tro_id):
raise ValueError(f"{path}: '{tro_id}' ist fuer Systemgrenzen reserviert")
parts = [p.strip() for p in value.split(",")]
try:
x, y = (float(p) for p in parts)
except ValueError:
raise ValueError(f"{path}: {tro_id} = '{value}' - erwartet 'x, y' in mm") \
from None
entries.append(IdEntry(tro_id, x, y))
return entries
def _distance(tro, entry: IdEntry, anchors: dict[str, tuple[float, float]]) -> float:
"""Kleinster Abstand eines Separators des TRO zum Eintrag (inf ohne Lage)."""
best = math.inf
for sep in tro.separators:
pos = anchors.get(sep)
if pos is not None:
best = min(best, math.hypot(pos[0] - entry.x, pos[1] - entry.y))
return best
def _width(count: int) -> int:
return max(2, len(str(count)))
def assign_ids(
tros: list,
anchors: dict[str, tuple[float, float]],
entries: list[IdEntry],
*,
tolerance: float = DEFAULT_TOLERANCE,
renumber: bool = False,
) -> IdAssignment:
"""tro.tro_id fuer alle TROs setzen (Reihenfolge = Reihenfolge der Liste).
anchors: Separator-TeileId -> (x, y) in mm.
"""
result = IdAssignment(renumbered=renumber or not entries)
if result.renumbered:
width = _width(len(tros))
for index, tro in enumerate(tros, start=1):
tro.tro_id = f"{PREFIX}{index:0{width}d}"
return result
# Paare nach Abstand; jeder TRO und jeder Eintrag hoechstens einmal
pairs = sorted(
(d, i, j)
for i, tro in enumerate(tros)
for j, entry in enumerate(entries)
if (d := _distance(tro, entry, anchors)) <= tolerance
)
tro_taken: dict[int, tuple[int, float]] = {}
entry_taken: set[int] = set()
for d, i, j in pairs:
if i in tro_taken or j in entry_taken:
continue
tro_taken[i] = (j, d)
entry_taken.add(j)
used = {e.tro_id for e in entries}
numbers = [int(m.group(1)) for e in entries if (m := _NUMBERED.match(e.tro_id))]
widths = [len(m.group(1)) for e in entries if (m := _NUMBERED.match(e.tro_id))]
width = max(widths + [_width(len(tros))])
next_number = max(numbers, default=0) + 1
for i, tro in enumerate(tros):
if i in tro_taken:
j, d = tro_taken[i]
tro.tro_id = entries[j].tro_id
result.kept.append((tro.tro_id, d))
continue
while f"{PREFIX}{next_number:0{width}d}" in used:
next_number += 1
tro.tro_id = f"{PREFIX}{next_number:0{width}d}"
used.add(tro.tro_id)
next_number += 1
result.new.append(tro.tro_id)
result.missing = [e for j, e in enumerate(entries) if j not in entry_taken]
for d, i, j in pairs:
if j not in entry_taken:
result.merged_into.setdefault(entries[j].tro_id, tros[i].tro_id)
return result
def _anchor(tro, anchors: dict[str, tuple[float, float]]) -> tuple[float, float] | None:
for sep in tro.separators:
if sep in anchors:
return anchors[sep]
return None
def _entry_lines(tros: list, anchors: dict[str, tuple[float, float]]) -> list[str]:
lines = []
for tro in tros:
pos = _anchor(tro, anchors)
if pos is None:
continue
seps = ", ".join(tro.separators)
lines.append(f"{tro.tro_id} = {pos[0]:.1f}, {pos[1]:.1f}"
f" ; {tro.type_name} | {tro.label} | Sep {seps}")
return lines
def render_ids(tros: list, anchors: dict[str, tuple[float, float]], source: str) -> str:
"""Komplette ID-Datei (erster Lauf oder renumber)."""
head = [
"; ============================================================",
f"; TRO-IDs zu {source} - erzeugt von lib/tro_flow.py "
f"am {datetime.now():%Y-%m-%d}",
";",
"; Je TRO: ID = x, y (mm) seines Anker-Separators im CSV-Koordinatensystem.",
"; Die IDs duerfen umbenannt werden (eindeutig, gueltiger SCL-Bezeichner);",
"; tro_flow findet jeden TRO ueber die Lage wieder. Neue TROs werden",
"; unten angehaengt, weggefallene bleiben stehen und werden gemeldet.",
"; Alles neu nummerieren: tro_flow --renumber (ueberschreibt diese Datei).",
"; Siehe doc/Konzept_Layout_Hardware_SCL.md, Abschnitt TRO-IDs.",
"; ============================================================",
"",
f"[{SECTION}]",
]
return "\n".join(head + _entry_lines(tros, anchors)) + "\n"
def render_new_entries(tros: list, anchors: dict[str, tuple[float, float]],
new_ids: list[str], source: str) -> str:
"""Anhang fuer neu gefundene TROs an eine bestehende ID-Datei."""
neu = [t for t in tros if t.tro_id in set(new_ids)]
head = [f"; neu aus {source} am {datetime.now():%Y-%m-%d}"]
return "\n".join(head + _entry_lines(neu, anchors)) + "\n"
def other_sections(path: Path) -> str:
"""Alle Abschnitte ausser [tro] als Text (bleiben bei renumber erhalten)."""
if not path.is_file():
return ""
header = re.compile(r"^\s*\[(.+)\]\s*$")
keep: list[str] = []
current = None
for line in path.read_text(encoding="utf-8").splitlines():
if m := header.match(line):
current = m.group(1).strip()
if current is not None and current != SECTION:
keep.append(line)
return "\n".join(keep).strip("\n")
def insert_entries(path: Path, block: str) -> None:
"""block am Ende des Abschnitts [tro] einfuegen, Rest der Datei unveraendert."""
lines = path.read_text(encoding="utf-8").splitlines()
header = re.compile(r"^\s*\[(.+)\]\s*$")
start = next(i for i, line in enumerate(lines)
if (m := header.match(line)) and m.group(1).strip() == SECTION)
end = next((i for i in range(start + 1, len(lines)) if header.match(lines[i])),
len(lines))
# vor Leerzeilen am Abschnittsende einfuegen
while end > start + 1 and not lines[end - 1].strip():
end -= 1
lines[end:end] = block.rstrip("\n").splitlines()
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+126
View File
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""Tests fuer lib/tro_ids.py: ID-Datei lesen/schreiben, TROs ueber die Lage
wiederfinden (doc/Konzept_Layout_Hardware_SCL.md, Abschnitt TRO-IDs)."""
from __future__ import annotations
from dataclasses import dataclass, field
import pytest
from tro_ids import (
IdEntry,
TroIds,
assign_ids,
insert_entries,
load_ids,
other_sections,
render_ids,
render_new_entries,
)
@dataclass
class FakeTro:
separators: list[str]
type_name: str = "1Sep"
label: str = ""
tro_id: str = ""
hosts: list[str] = field(default_factory=list)
ANCHORS = {
"0001": (0.0, 0.0),
"0002": (1000.0, 0.0),
"0003": (2000.0, 0.0),
"0004": (2000.0, 500.0),
}
def _tros(*seps: list[str]) -> list[FakeTro]:
return [FakeTro(separators=list(s)) for s in seps]
def test_ohne_eintraege_wird_durchnummeriert():
tros = _tros(["0001"], ["0002"], ["0003"])
result = assign_ids(tros, ANCHORS, [])
assert result.renumbered
assert [t.tro_id for t in tros] == ["TRO01", "TRO02", "TRO03"]
def test_wiederfinden_ueber_lage_auch_umbenannt():
# Neuer Export: andere TeileIds, gleiche Lage, andere Reihenfolge
anchors = {"0101": (2000.0, 0.0), "0102": (0.0, 0.0), "0103": (1000.0, 30.0)}
tros = _tros(["0101"], ["0102"], ["0103"])
entries = [IdEntry("TRO01", 0.0, 0.0), IdEntry("K0102_1", 1000.0, 0.0),
IdEntry("TRO03", 2000.0, 0.0)]
result = assign_ids(tros, anchors, entries)
assert [t.tro_id for t in tros] == ["TRO03", "TRO01", "K0102_1"]
assert not result.new and not result.missing
def test_neue_tros_naechste_freie_nummer_weggefallene_nicht_wiederverwendet():
tros = _tros(["0001"], ["0003"])
entries = [IdEntry("TRO01", 0.0, 0.0), IdEntry("TRO02", 1000.0, 0.0)]
result = assign_ids(tros, ANCHORS, entries)
assert [t.tro_id for t in tros] == ["TRO01", "TRO03"]
assert result.new == ["TRO03"]
assert [e.tro_id for e in result.missing] == ["TRO02"]
def test_ausserhalb_toleranz_gilt_als_neu():
tros = _tros(["0001"])
entries = [IdEntry("TRO01", 150.0, 0.0)]
result = assign_ids(tros, ANCHORS, entries, tolerance=100.0)
assert tros[0].tro_id == "TRO02"
assert result.missing[0].tro_id == "TRO01"
assert assign_ids(tros, ANCHORS, entries, tolerance=200.0).kept[0][0] == "TRO01"
def test_zusammengelegte_tros_naechster_eintrag_gewinnt():
# zwei Separatoren, frueher zwei TROs, jetzt ein 2Sep-TRO
tros = _tros(["0003", "0004"])
entries = [IdEntry("TRO05", 2000.0, 0.0), IdEntry("TRO06", 2000.0, 510.0)]
result = assign_ids(tros, ANCHORS, entries)
assert tros[0].tro_id == "TRO05"
assert result.merged_into == {"TRO06": "TRO05"}
def test_renumber_ignoriert_eintraege():
tros = _tros(["0001"], ["0002"])
result = assign_ids(tros, ANCHORS, [IdEntry("X", 1000.0, 0.0)], renumber=True)
assert result.renumbered
assert [t.tro_id for t in tros] == ["TRO01", "TRO02"]
def test_datei_rundlauf_und_anhaengen(tmp_path):
path = tmp_path / "anlage_tro_ids.ini"
tros = _tros(["0001"], ["0002"])
assign_ids(tros, ANCHORS, [])
path.write_text(render_ids(tros, ANCHORS, "anlage.csv")
+ "\n[separator]\n; spaeter\n", encoding="utf-8")
entries = load_ids(path)
assert [(e.tro_id, e.x, e.y) for e in entries] == [
("TRO01", 0.0, 0.0), ("TRO02", 1000.0, 0.0)]
tros = _tros(["0001"], ["0002"], ["0003"])
result = assign_ids(tros, ANCHORS, entries)
insert_entries(path, render_new_entries(tros, ANCHORS, result.new, "anlage.csv"))
assert [e.tro_id for e in load_ids(path)] == ["TRO01", "TRO02", "TRO03"]
# neuer Eintrag steht in [tro], nicht im folgenden Abschnitt
assert other_sections(path) == "[separator]\n; spaeter"
assert TroIds.load(path).exists
@pytest.mark.parametrize("inhalt, meldung", [
("[tro]\nTRO01 = 1, 2\nTRO01 = 3, 4\n", "already exists"),
("[tro]\nSYS1 = 1, 2\n", "reserviert"),
("[tro]\nTRO-1 = 1, 2\n", "SCL-Bezeichner"),
("[tro]\nTRO01 = 1\n", "erwartet"),
("[andere]\n", "fehlt"),
])
def test_fehlerhafte_datei(tmp_path, inhalt, meldung):
path = tmp_path / "x.ini"
path.write_text(inhalt, encoding="utf-8")
with pytest.raises(ValueError, match=meldung):
load_ids(path)