tro_flow: --use-overrides, --from-connect und --geometry entfernt

TROs, Typen und Verbindungen kommen nur noch aus dem Layout. Fehler der
Ableitung werden im Code behoben, nicht in einer Korrekturdatei.

- tro_flow: Optionen --use-overrides/--no-overrides, --from-connect,
  --geometry, --geo-tol-mm entfernt; analyse_tros/derive_tros ohne
  overrides/resolve_host/count_exits-Parameter, Analysis.overrides entfernt,
  Override-Schritte 4b/4c/5b, Doku-Abschnitt "6. Manuelle Korrekturen" und
  die Override-Zeilen im Bericht entfernt; connect.ini ist reine Ausgabe
- lib/tro_geometry.py geloescht (nur von --geometry genutzt), shapely aus
  requirements.txt
- tro_overrides: apply_splits/_merges/_connections/_overrides und
  load_connect_ini entfernt - nur noch load_overrides fuer
  material_flow.py --connect
- doc/Python_Scripts.md nachgezogen

Gegenprobe: DOT (beide Layouts) und connect.ini fuer HundM05 und Mubea
unveraendert (28 bzw. 26 Verbindungen), Doku nur ohne den leeren
Abschnitt 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-10-05 16:02:16 +02:00
co-authored by Claude Opus 5.5
parent a5fab7e2dc
commit e0defad011
5 changed files with 49 additions and 935 deletions
+22 -52
View File
@@ -55,12 +55,8 @@ Supporting libraries (no CLI of their own, imported by the tools above):
(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_overrides.py`** — reads the hand-maintained - **`lib/tro_overrides.py`** — reads the hand-maintained
`%SKEL_CFG%\tro_overrides.ini` (type corrections, open points, TRO merges) `%SKEL_CFG%\tro_overrides.ini`; only `material_flow.py --connect` still uses
that survive re-runs of `tro_flow.py`. See §6a. it. See §6a.
- **`lib/tro_geometry.py`** — resolves a separator's host and a switch's exit
count for `tro_flow.py` **directly from the part world-coordinates** (shapely
`STRtree`) instead of from the CSV `Zuordnung`/`Nachbarn` columns, for exports
where those columns have gaps. Only used with `tro_flow.py --geometry`. See §6b.
Separate, documentation-only tool (not part of the generator pipeline): Separate, documentation-only tool (not part of the generator pipeline):
@@ -99,8 +95,7 @@ Conventions shared by the CLI tools:
output file paths); non-fatal findings are printed as `?`/`!` lines and also output file paths); non-fatal findings are printed as `?`/`!` lines and also
do not stop the run. do not stop the run.
- Requires the packages pinned in `requirements.txt` (`pydantic>=2.0.0`; - Requires the packages pinned in `requirements.txt` (`pydantic>=2.0.0`;
`ezdxf>=1.4.0` for `tro_annotate.py` only; `shapely>=2.1` for `ezdxf>=1.4.0` for `tro_annotate.py` only) — run `bin\install_py.bat` once.
`tro_flow.py --geometry` only) — run `bin\install_py.bat` once.
- `--tosvg` needs Graphviz's `dot` (or `neato`, for `tro_flow.py - `--tosvg` needs Graphviz's `dot` (or `neato`, for `tro_flow.py
--use-cords`) on `PATH`, or the `GRAPHVIZ_DOT` environment variable pointing --use-cords`) on `PATH`, or the `GRAPHVIZ_DOT` environment variable pointing
at the `dot` executable. `bin/material_flow.bat`/`.sh` and `bin/tro_flow.bat`/`.sh` at the `dot` executable. `bin/material_flow.bat`/`.sh` and `bin/tro_flow.bat`/`.sh`
@@ -284,8 +279,12 @@ colors all come from `lib/tro_catalog.py` — nothing is hard-coded here.
| `--tosvg` | — | off | Also render the TRO flow diagram to `.svg`. | | `--tosvg` | — | off | Also render the TRO flow diagram to `.svg`. |
| `--use-cords` (alias `--use-coords`) | — | off | Place every TRO at the centroid of the components it was built from and render with `neato -n` (fixed positions) instead of `dot`'s computed layout, so the diagram matches the plant layout. Only has an effect together with `--tosvg`; fails if any TRO has no coordinate. | | `--use-cords` (alias `--use-coords`) | — | off | Place every TRO at the centroid of the components it was built from and render with `neato -n` (fixed positions) instead of `dot`'s computed layout, so the diagram matches the plant layout. Only has an effect together with `--tosvg`; fails if any TRO has no coordinate. |
| `--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. |
| `--geometry` | — | off | Determine each separator's host and each switch's exit count (fanout) **from the part world-coordinates** via a shapely `STRtree` (`lib/tro_geometry.py`, see §6b) instead of from the CSV `Zuordnung`/`Nachbarn` columns. Use this for exports where those columns are incomplete (the columns are then bypassed entirely for these two inputs). Requires `shapely>=2.1`. | | `--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. |
| `--geo-tol-mm` | `MM` (float) | catalog default | Only with `--geometry`: the tolerance in mm within which two connection points count as the same transfer point. | | `--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). |
TROs, types and connections always come from the layout alone;
`cfg/tro_overrides.ini` is not read. Errors in the derivation are fixed in
`lib/tro_flow.py`, not in a correction file.
**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
@@ -342,9 +341,8 @@ overridden.
| `--check` | — | off | Dry run: only analyze and report, write no output file. | | `--check` | — | off | Dry run: only analyze and report, write no output file. |
| `--emit-lisp` | — | off | Regenerate `cad/tro_types.lsp` (the type list for the `TRO_INSERT`/`TRO_EDIT` dialogs) from `tro_catalog.py` and exit — no `--dxf` needed. Written under `%DXFM_LISP%` if set (next to the `dxfmakros` SSG_LIB modules that provide the dialogs), otherwise under `<SPS_SKEL>/cad`. | | `--emit-lisp` | — | off | Regenerate `cad/tro_types.lsp` (the type list for the `TRO_INSERT`/`TRO_EDIT` dialogs) from `tro_catalog.py` and exit — no `--dxf` needed. Written under `%DXFM_LISP%` if set (next to the `dxfmakros` SSG_LIB modules that provide the dialogs), otherwise under `<SPS_SKEL>/cad`. |
Layers always carry the prefix `TRO_`. `cfg/tro_overrides.ini` is never read, Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it never reads
so the markers always match `tro_flow.py` (which reads it only with `cfg/tro_overrides.ini`, so the markers always match the flow diagram.
`--use-overrides`).
**Output** (in `%SKEL_RESULTS%`, unless `--check`): `<name>_annotated.dxf`, **Output** (in `%SKEL_RESULTS%`, unless `--check`): `<name>_annotated.dxf`,
`<name>_registration.json` (the coordinate transform used, with its proof). `<name>_registration.json` (the coordinate transform used, with its proof).
@@ -467,6 +465,12 @@ free. Consumed by `tro_flow.py` and `tro_annotate.py`; not run directly.
## 6a. `tro_overrides.py` — manual corrections to the derived TROs (library, no CLI) ## 6a. `tro_overrides.py` — manual corrections to the derived TROs (library, no CLI)
> **Since 2026-10-05 only `material_flow.py --connect` reads this file.**
> `tro_flow.py` and `tro_annotate.py` no longer apply corrections: the sections
> were keyed on running TRO numbers and went stale with every new export.
> Errors in the TRO derivation are fixed in `lib/tro_flow.py` instead. The
> format below is kept for `material_flow.py`.
`tro_flow.py` derives TRO types from the mechanical layout alone. Some things `tro_flow.py` derives TRO types from the mechanical layout alone. Some things
are simply not in the CSV export: a switch that the export does not carry, a are simply not in the CSV export: a switch that the export does not carry, a
scanner that lifts the type to `1Sep_SSCC`, or a spot that a walkthrough scanner that lifts the type to `1Sep_SSCC`, or a spot that a walkthrough
@@ -536,46 +540,12 @@ 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; the point is only marked in the doc TRO. The structure stays untouched.
(section 6). This keeps "derived" and "asserted" distinguishable until the
spot has been checked in BricsCAD.
Consumers: `tro_flow.py` (only with `--use-overrides`: type corrections + doc Consumer: `material_flow.py --connect` (writes the `[<plant>.manual]` section
section 6), `material_flow.py` (writes the into `connect.ini`). Keep in mind: **TRO IDs are running numbers of one run and
`[<plant>.manual]` section into `connect.ini`). Unknown TRO IDs and types are are only valid for the CSV named in the section.** Swap the data basis and the
not applied silently — they are reported as warnings in the console output and numbers shift.
in doc section 6.3, which catches the common trap: **TRO IDs are running
numbers of one run and are only valid for the CSV named in the section.** Swap
the data basis and the numbers shift.
## 6b. `tro_geometry.py` — geometric separator-host and switch-fanout (library, no CLI)
`tro_flow.py` normally derives two of its inputs from the CSV columns
(`material_flow.py::build_graph`):
```
Zuordnung (host of the separator) -> which conveyor/Kreisel a separator sits on
Nachbarn / graph edges (fanout) -> how many exits a switch has (1Sep / 1Sep1Swi / 1Sep2Swi)
```
For some exports those columns are incomplete — e.g. `hundmM05_1.csv` reports
"nur ein Partner" for several `Strecke` rows, and the older `hundm_export.csv`
failed outright (5 TROs, all `Vario`, 0 connections, all isolated) because
`Zuordnung`/`Nachbarn` could not be resolved. This module derives the same two
values **directly from the part world-coordinates** instead, so the TRO
derivation no longer depends on the quality of those two columns:
- a shapely `STRtree` over the AS/ES chain ends (K1/K2) of every
Strecke/Gefällestrecke plus the two Kreisel lane-ends;
- `resolve_host_geometric` — `.nearest()` to find the closest host to a
separator;
- `count_geometric_exits` — `.query(..., predicate="dwithin")` to find every
other chain that actually meets at a given point (within a tolerance, mm).
It is activated by `tro_flow.py --geometry` (via `make_geometric_resolvers`);
`--geo-tol-mm` sets the meeting tolerance. This module knows nothing about
`tro_flow.py` — only `material_flow.py::Element` lists. Requires `shapely>=2.1`
(pinned in `requirements.txt`, needed only for this path).
## 7. `dxf_registration.py` — CSV↔DXF coordinate registration (library, no CLI) ## 7. `dxf_registration.py` — CSV↔DXF coordinate registration (library, no CLI)
+19 -263
View File
@@ -98,7 +98,6 @@ import csv
import json import json
import math import math
import sys import sys
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -136,15 +135,6 @@ from tro_catalog import (
get_tro, get_tro,
mermaid_classdefs, mermaid_classdefs,
) )
from tro_overrides import (
Overrides,
apply_connections,
apply_merges,
apply_overrides,
apply_splits,
load_connect_ini,
load_overrides,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Konstanten # Konstanten
@@ -250,9 +240,7 @@ class Analysis:
isolated: list[Tro] = field(default_factory=list) isolated: list[Tro] = field(default_factory=list)
unknown_types: list[str] = field(default_factory=list) unknown_types: list[str] = field(default_factory=list)
findings: list[str] = field(default_factory=list) findings: list[str] = field(default_factory=list)
# Manuelle Korrekturen aus cfg/tro_overrides.ini (Typkorrekturen, offene Punkte) # Wie Host/Fanout ermittelt wurden (Doku/Bericht)
overrides: Overrides = field(default_factory=Overrides)
# Wie Host/Fanout ermittelt wurden - siehe derive_tros(resolve_host, count_exits)
method: str = "Materialfluss-Graph (Zuordnung/Nachbarn)" method: str = "Materialfluss-Graph (Zuordnung/Nachbarn)"
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein # Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird. # TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
@@ -547,20 +535,13 @@ def derive_tros(
graph: Graph, graph: Graph,
elements: list[Element], elements: list[Element],
findings: list[str], findings: list[str],
overrides: Overrides | None = None,
*, *,
resolve_host: Callable[[Element, list[str]], str | None] | None = None,
count_exits: Callable[[list[str]], list[str]] | None = None,
sepliste_counts: dict[str, int] | None = None, sepliste_counts: dict[str, int] | None = None,
) -> list[Tro]: ) -> list[Tro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden. """TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
resolve_host(sep, findings) -> Host-Knoten-Id oder None; Default liest die Host eines Separators aus der CSV-Spalte "Zuordnung" (_default_resolve_host),
CSV-Spalte "Zuordnung" (siehe _default_resolve_host). count_exits(hosts) -> abgehende Wege eines TRO-Hosts aus graph.edges (_switch_exits).
Liste abgehender Nachbarn eines TRO-Hosts; Default liest graph.edges (siehe
_switch_exits). tro_geometry.py::make_geometric_resolvers() liefert ein
Paar, das beides stattdessen direkt aus den Bauteil-Weltkoordinaten sucht
(STRtree) - siehe tro_flow.py --geometry.
sepliste_counts (optional, siehe load_sepliste_counts): Container-Id -> sepliste_counts (optional, siehe load_sepliste_counts): Container-Id ->
Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe - stimmt die aus Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe - stimmt die aus
@@ -568,8 +549,11 @@ def derive_tros(
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).
""" """
resolve_host = resolve_host or (lambda sep, findings: _default_resolve_host(sep, findings, graph)) def resolve_host(sep: Element, findings: list[str]) -> str | None:
count_exits = count_exits or (lambda hosts: _switch_exits(hosts, graph)) return _default_resolve_host(sep, findings, graph)
def count_exits(hosts: list[str]) -> list[str]:
return _switch_exits(hosts, graph)
separators = sorted( separators = sorted(
(e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id (e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id
@@ -969,41 +953,12 @@ 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"
# 4b) Manuell ergaenzte Uebergabestellen (cfg/tro_overrides.ini, [.split.]).
# Vor der Nummernvergabe, damit die neuen TROs in derselben Reihenfolge
# eine Nummer bekommen wie die abgeleiteten.
if overrides is not None and overrides.splits:
def _neuer_tro(spec, hosts, label):
return Tro(
tro_id="",
type_name=spec.type_name,
label=label,
hosts=hosts,
separators=list(spec.separators),
scanners=list(spec.scanners),
reason=spec.reason or "manuell ergaenzte Uebergabestelle",
confidence=spec.confidence,
)
apply_splits(tros, overrides, _neuer_tro)
# 4c) Zusammenfassungen - nach den Splits, damit auch ein frisch
# herausgeloester TRO Teil einer Zusammenfassung sein kann.
if overrides is not None and overrides.merges:
apply_merges(tros, overrides)
# 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten # 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten
tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else "")) tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else ""))
width = max(2, len(str(len(tros)))) width = max(2, len(str(len(tros))))
for index, tro in enumerate(tros, start=1): for index, tro in enumerate(tros, start=1):
tro.tro_id = f"TRO{index:0{width}d}" tro.tro_id = f"TRO{index:0{width}d}"
# 5b) Manuelle Korrekturen aus cfg/tro_overrides.ini. Sie greifen erst hier,
# weil sie ueber die TRO-ID adressiert sind, und noch vor Schritt 6, damit
# ein manuell gesetzter Typ den SSCC-Hinweis nicht mehr ausloest.
if overrides is not None:
apply_overrides(tros, overrides)
# 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:
if tro.type_name == "1Sep" and tro.scanners: if tro.type_name == "1Sep" and tro.scanners:
@@ -1597,20 +1552,12 @@ def locate_tros(tros: list[Tro], elements: list[Element], graph: Graph) -> None:
def analyse_tros( def analyse_tros(
graph: Graph, graph: Graph,
elements: list[Element], elements: list[Element],
overrides: Overrides | None = None,
*, *,
resolve_host: Callable[[Element, list[str]], str | None] | None = None,
count_exits: Callable[[list[str]], list[str]] | None = None,
method: str | None = None,
sepliste_counts: dict[str, int] | None = None, sepliste_counts: dict[str, int] | None = None,
) -> Analysis: ) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.""" """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
analysis = Analysis() analysis = Analysis()
analysis.overrides = overrides if overrides is not None else Overrides() analysis.tros = derive_tros(graph, elements, analysis.findings,
if method is not None:
analysis.method = method
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.overrides,
resolve_host=resolve_host, count_exits=count_exits,
sepliste_counts=sepliste_counts) sepliste_counts=sepliste_counts)
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:
@@ -1623,25 +1570,6 @@ def analyse_tros(
locate_tros(linked, elements, graph) locate_tros(linked, elements, graph)
analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements) analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements)
# Vor Ort aufgenommene Topologie schlaegt die abgeleitete. Die Differenz
# wird protokolliert, damit sichtbar bleibt, was die Ableitung falsch hatte.
if analysis.overrides.connections:
abgeleitet = set(analysis.edges)
analysis.edges, analysis.edge_kinds = apply_connections(
linked, analysis.overrides
)
gesetzt = set(analysis.edges)
for src, dst in sorted(abgeleitet - gesetzt):
analysis.findings.append(
f"abgeleitete Verbindung {src} -> {dst} entfaellt - steht nicht "
f"in der gesetzten Topologie"
)
for src, dst in sorted(gesetzt - abgeleitet):
analysis.findings.append(
f"Verbindung {src} -> {dst} nur gesetzt, aus dem Layout nicht "
f"ableitbar"
)
for tro in analysis.tros: for tro in analysis.tros:
definition = tro.definition() definition = tro.definition()
if definition: if definition:
@@ -2020,29 +1948,26 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# connect.ini (TRO-Ebene) - --connect schreibt, --from-connect liest # connect.ini (TRO-Ebene) - --connect schreibt
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def render_connect_ini(analysis: Analysis, section: str, source: str) -> str: def render_connect_ini(analysis: Analysis, section: str, source: str) -> str:
""" """
TRO-Topologie als connect.ini, angelehnt an doc/TRO_Katalog/connect.ini TRO-Topologie als connect.ini, angelehnt an doc/TRO_Katalog/connect.ini
(hmf-connect-v1). Zurueck eingelesen von tro_overrides.load_connect_ini (hmf-connect-v1). Reine Ausgabe - wird von keinem Lauf zurueckgelesen.
(--from-connect): geaenderte Typen und Verbindungen gehen dann in DOT/SVG.
Abweichung vom Referenzformat: Systemgrenzen stehen nicht als EXTERN, Abweichung vom Referenzformat: Systemgrenzen stehen nicht als EXTERN,
sondern mit ihrer ID (SYSn), damit die Datei verlustfrei zurueckgelesen sondern mit ihrer ID (SYSn). [<Anlage>.tros] ist eine reine Uebersicht
werden kann. [<Anlage>.tros] ist eine reine Uebersicht (Vorgaenger/ (Vorgaenger/Nachfolger je TRO).
Nachfolger je TRO) und wird beim Einlesen ignoriert.
""" """
out: list[str] = [] out: list[str] = []
add = out.append add = out.append
add("; ============================================================") add("; ============================================================")
add("; connect.ini - Routing / Zusammenhalt der TROs (TRO-Ebene)") add("; connect.ini - Routing / Zusammenhalt der TROs (TRO-Ebene)")
add(f"; Automatisch erzeugt von lib/tro_flow.py aus '{source}'.") add(f"; Automatisch erzeugt von lib/tro_flow.py aus '{source}'.")
add("; Wird bei jedem Lauf mit --connect neu geschrieben. Eine von Hand") add("; Wird bei jedem Lauf mit --connect neu geschrieben - nicht von Hand")
add("; geaenderte Fassung wird mit --from-connect gezeichnet; dauerhafte") add("; aendern, Korrekturen gehoeren in die Ableitung (lib/tro_flow.py).")
add("; Korrekturen gehoeren nach cfg/tro_overrides.ini.")
add(";") add(";")
add("; [<Anlage>.nodes] TRO = <Typ> | <Bezeichnung> | <Bauteile>") add("; [<Anlage>.nodes] TRO = <Typ> | <Bezeichnung> | <Bauteile>")
add("; SYSn = Materialeingang/-ausgang (kein TRO)") add("; SYSn = Materialeingang/-ausgang (kein TRO)")
@@ -2250,11 +2175,6 @@ def render_doc(
add(f"| TRO-Verbindungen | {len(analysis.edges)} |") add(f"| TRO-Verbindungen | {len(analysis.edges)} |")
add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |") add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |")
add(f"| Hinweise | {len(analysis.findings)} |") add(f"| Hinweise | {len(analysis.findings)} |")
if analysis.overrides.applied:
add(f"| Manuelle Typkorrekturen | {len(analysis.overrides.applied)} |")
if analysis.overrides.open_points:
add(f"| Offene Punkte (noch kein TRO) | "
f"{len(analysis.overrides.open_points)} |")
add("") add("")
add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe | Symbol |") add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe | Symbol |")
add("|---|---|---:|---|---|---|") add("|---|---|---:|---|---|---|")
@@ -2360,56 +2280,7 @@ def render_doc(
add("_Keine._") add("_Keine._")
add("") add("")
add("## 6. Manuelle Korrekturen und offene Punkte") add("## 6. Ableitungsregeln und Typdefinition")
add("")
overrides = analysis.overrides
if not overrides and not overrides.warnings:
add("_Keine._ Alles unten Stehende ist rein aus dem Layout abgeleitet.")
add("")
else:
add(f"Quelle: `{overrides.source}`" if overrides.source
else "_Keine Override-Datei gefunden._")
add("")
if overrides.applied:
add("### 6.1 Angewandte Typkorrekturen")
add("")
add("Diese Typen stammen **nicht** aus dem Layout, sondern aus der "
"Begehung bzw. dem Review - im Layout sind sie nicht erkennbar.")
add("")
add("| Korrektur | Begruendung |")
add("|---|---|")
for entry in overrides.applied:
head, _, tail = entry.partition(" (")
add(f"| `{head}` | {tail[:-1] if tail.endswith(')') else tail} |")
add("")
if overrides.open_points:
add("### 6.2 Offene Punkte - noch kein TRO")
add("")
add("Im Review gemeldete Stellen. Die TRO-Struktur ist hier "
"**bewusst unveraendert**; die Punkte sind nur markiert (auch in der "
"annotierten Zeichnung, Layer `TRO_OPENPOINT`), damit sie in BricsCAD "
"geprueft werden koennen.")
add("")
add("| Punkt | X [mm] | Y [mm] | Z [mm] | erwartet | heute Teil von | Anmerkung |")
add("|---|---:|---:|---:|---|---|---|")
for point in overrides.open_points:
z_text = f"{point.z:.0f}" if point.z is not None else "-"
expect = f"`{point.expect}`" if point.expect else "-"
owner = f"`{point.belongs_to}`" if point.belongs_to else "-"
add(f"| {point.name} | {point.x:.0f} | {point.y:.0f} | {z_text} "
f"| {expect} | {owner} | {point.note or '-'} |")
add("")
if overrides.warnings:
add("### 6.3 Probleme mit der Override-Datei")
add("")
for warning in overrides.warnings:
add(f"- {warning}")
add("")
add("## 7. Ableitungsregeln und Typdefinition")
add("") add("")
add("Ein TRO ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt " add("Ein TRO ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt "
"ist der Separator und das, was an ihm haengt (Host-Objekt, Scanner).") "ist der Separator und das, was an ihm haengt (Host-Objekt, Scanner).")
@@ -2516,29 +2387,6 @@ def report(
print("FEHLER: TRO-Typen ohne Eintrag in TRO_CATALOG: " print("FEHLER: TRO-Typen ohne Eintrag in TRO_CATALOG: "
+ ", ".join(analysis.unknown_types)) + ", ".join(analysis.unknown_types))
overrides = analysis.overrides
if overrides.applied:
print("")
print(f"Manuelle Typkorrekturen ({len(overrides.applied)}) aus "
f"{overrides.source}:")
for entry in overrides.applied:
print(f" * {entry}")
if overrides.open_points:
print("")
print(f"Offene Punkte ({len(overrides.open_points)}) - nur markiert, "
f"TRO-Struktur unveraendert:")
for point in overrides.open_points:
expect = f" erwartet {point.expect}" if point.expect else ""
owner = f", heute Teil von {point.belongs_to}" if point.belongs_to else ""
print(f" o {point.name} bei {point.describe_position()}{expect}{owner}")
if overrides.warnings:
print("")
print(f"Probleme mit {overrides.source} ({len(overrides.warnings)}):")
for warning in overrides.warnings:
print(f" ! {warning}")
if analysis.findings: if analysis.findings:
print("") print("")
print(f"Hinweise ({len(analysis.findings)}):") print(f"Hinweise ({len(analysis.findings)}):")
@@ -2611,35 +2459,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"schreiben: Knoten (TRO = Typ), Verbindungen, Systemgrenzen und je " "schreiben: Knoten (TRO = Typ), Verbindungen, Systemgrenzen und je "
"TRO Vorgaenger/Nachfolger.", "TRO Vorgaenger/Nachfolger.",
) )
parser.add_argument(
"--from-connect",
dest="from_connect",
nargs="?",
const="",
default=None,
metavar="INI",
help="Typen und Verbindungen aus einer (von Hand geaenderten) connect.ini "
"uebernehmen und damit DOT/SVG/Doku erzeugen. Ohne Wert: "
"<name>_tro_connect.ini in %%SKEL_RESULTS%%. Die connect.ini wird "
"dabei nicht ueberschrieben.",
)
parser.add_argument(
"--geometry",
action="store_true",
help="Separator-Host und Weichen-Fanout per STRtree direkt aus den "
"Bauteil-Weltkoordinaten bestimmen (lib/tro_geometry.py) statt "
"aus den CSV-Spalten 'Zuordnung'/'Nachbarn' - unabhaengig davon, "
"ob diese Spalten fuer die Eingabedatei luecken haben.",
)
parser.add_argument(
"--geo-tol-mm",
type=float,
default=tro_geometry_default_tolerance_mm(),
metavar="MM",
help="Nur mit --geometry: Toleranz (mm), innerhalb derer zwei "
"Anschlusspunkte als derselbe Uebergabepunkt gelten. "
"Standard: %(default)s",
)
parser.add_argument( parser.add_argument(
"--true-scale", "--true-scale",
dest="true_scale", dest="true_scale",
@@ -2648,34 +2467,9 @@ 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(
"--use-overrides",
dest="use_overrides",
action="store_true",
help="Manuelle Korrekturen aus cfg/tro_overrides.ini anwenden. Standard "
"ist AUS: TROs, Typen und Verbindungen kommen rein aus dem Layout "
"(die INI-Abschnitte haengen an laufenden TRO-Nummern und veralten "
"mit jedem neuen Export).",
)
parser.add_argument(
"--no-overrides",
dest="use_overrides",
action="store_false",
help=argparse.SUPPRESS,
)
return parser.parse_args(argv) return parser.parse_args(argv)
def tro_geometry_default_tolerance_mm() -> float:
"""Default fuer --geo-tol-mm; importiert tro_geometry nur bei Bedarf, damit
--help/normale Laeufe ohne --geometry kein shapely brauchen."""
try:
from tro_geometry import DEFAULT_TOLERANCE_MM
except ImportError:
return 50.0
return DEFAULT_TOLERANCE_MM
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
@@ -2709,48 +2503,15 @@ def main(argv: list[str] | None = None) -> int:
print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr) print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr)
return 1 return 1
resolve_host = count_exits = None
method = None
if args.geometry:
try:
from tro_geometry import make_geometric_resolvers
except ImportError as exc:
print(f"FEHLER: --geometry braucht shapely (pip install shapely): {exc}",
file=sys.stderr)
return 1
resolve_host, count_exits = make_geometric_resolvers(elements, tol_mm=args.geo_tol_mm)
method = f"geometrisch (STRtree, Toleranz {args.geo_tol_mm:.0f}mm)"
sepliste_counts: dict[str, int] = {} sepliste_counts: dict[str, int] = {}
raw_json_file = find_sibling_raw_json(csv_file) raw_json_file = find_sibling_raw_json(csv_file)
if raw_json_file is not None: if raw_json_file is not None:
sepliste_counts = load_sepliste_counts(raw_json_file, warnings) sepliste_counts = load_sepliste_counts(raw_json_file, warnings)
if args.use_overrides: stem = csv_file.stem
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
else:
overrides = Overrides()
stem = csv_file.stem + ("_geo" if args.geometry else "")
connect_target = results / f"{stem}_tro_connect.ini" connect_target = results / f"{stem}_tro_connect.ini"
connect_source: Path | None = None
if args.from_connect is not None:
connect_source = Path(args.from_connect) if args.from_connect else connect_target
if not connect_source.is_absolute() and connect_source.parent == Path("."):
connect_source = results / connect_source
if not connect_source.is_file():
print(f"FEHLER: connect.ini nicht gefunden: {connect_source}", file=sys.stderr)
print(" Erst mit --connect erzeugen.", file=sys.stderr)
return 1
try:
overrides = load_connect_ini(connect_source, overrides)
except ValueError as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 1
analysis = analyse_tros(graph, elements, overrides, analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts)
resolve_host=resolve_host, count_exits=count_exits,
method=method, sepliste_counts=sepliste_counts)
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)
@@ -2777,7 +2538,7 @@ def main(argv: list[str] | None = None) -> int:
) )
connect_file = None connect_file = None
if args.connect and connect_source is None: if args.connect:
connect_target.write_text( connect_target.write_text(
render_connect_ini(analysis, section=stem, source=csv_file.name), render_connect_ini(analysis, section=stem, source=csv_file.name),
encoding="utf-8", encoding="utf-8",
@@ -2809,11 +2570,6 @@ def main(argv: list[str] | None = None) -> int:
warnings, engine=engine) warnings, engine=engine)
if connect_file: if connect_file:
print(f"connect.ini = {connect_file}") print(f"connect.ini = {connect_file}")
if connect_source:
print(f"Gezeichnet aus = {connect_source}")
if args.connect and connect_source is not None:
print("HINWEIS: --connect zusammen mit --from-connect - die connect.ini "
"wird nicht ueberschrieben.")
if svg_error: if svg_error:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr) print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
-338
View File
@@ -1,338 +0,0 @@
# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
tro_geometry.py - Separator-Host und Weichen-Fanout direkt aus der Geometrie.
Ergaenzt tro_flow.py::derive_tros() um einen zweiten Weg, zwei seiner Eingaben
zu bestimmen:
Zuordnung (Host des Separators) -> resolve_host_geometric
Nachbarn/graph.edges (Fanout) -> count_geometric_exits
Beide Werte kommen normalerweise aus den CSV-Spalten "Zuordnung"/"Nachbarn"
(material_flow.py::build_graph). Dieses Modul sucht sie stattdessen direkt in
den Weltkoordinaten der Bauteile: ein shapely-STRtree ueber die AS/ES-Enden
(K1/K2) jeder Strecke/Gefaellestrecke sowie die beiden Kreisel-Bahnenden
liefert per .nearest() den naechstgelegenen Host zu einem Separator und per
.query(..., predicate="dwithin") alle anderen Ketten, die sich an einem
gegebenen Punkt tatsaechlich treffen - unabhaengig davon, ob die betroffene
Zeile im CSV eine vollstaendige "Nachbarn"-Liste hat.
Hintergrund: hundmM05_1.csv zeigt fuer mehrere "ILS 2.0 Strecke"-Zeilen den
Fehler "nur ein Partner" (Spalte Fehler) - die von dxfmakros berechnete
Nachbarschaft ist dort unvollstaendig. Eine fruehere, gleichartige Datei
(hundm_export.csv) zeigte den Ausfall dieser Abhaengigkeit in voller Wucht:
tro_flow.py leitete 5 TROs ab, alle vom Typ Vario, 0 Verbindungen, alle
isoliert (siehe results/hundm_export_tro_doc.md) - Zuordnung/Nachbarn liessen
sich fuer diesen Export nicht ausreichend aufloesen. Dieses Modul macht die
TRO-Ableitung fuer genau diesen Fall unabhaengig von der Qualitaet dieser
beiden Spalten.
Nutzung: `tro_flow.py --geometry` (siehe dort, `make_geometric_resolvers`).
Dieses Modul kennt tro_flow.py nicht - nur material_flow.py::Element-Listen.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from shapely import STRtree
from shapely.geometry import LineString, Point
from material_flow import (
LANE_LEFT,
LANE_RIGHT,
UNASSIGNED,
Element,
_as_float,
_merkmal,
)
# Kreisel-Durchmesser (mm), falls kein Merkmal mit dem Durchmesser vorliegt -
# Default wie dxfmakros' cfg/export.cfg [Kreisel] durchmesser_mm.
DEFAULT_KREISEL_DURCHMESSER_MM = 800.0
# Merkmal-Namen, unter denen der Achsabstand der beiden Kreiselstationen
# (AN8/SP8) im CSV-Export auftaucht - in Metern, siehe kreisel_capsule().
ABSTAND_MERKMALE = (
"Abstand (Kreiselachse A - Kreiselachse) in Meter",
"Abstand_m",
"ABSTAND",
)
# Default-Toleranz (mm), innerhalb derer zwei Anschlusspunkte als "derselbe
# Uebergabepunkt" gelten. Bewusst zwischen dxfmakros' omniflo_ks_toleranz_mm
# (10mm, enge KOS-Deckung von Omniflo-Steckverbindern) und vf_linienzug.lsp's
# *vfl-kette-tol-weit* (100mm, "sieht verbunden aus, ist es aber vielleicht
# nicht") - zwei Kettenenden an einem echten mechanischen Uebergang treffen
# sich nicht auf den zehntel Millimeter, muessen aber auch nicht 10cm
# auseinanderliegen duerfen, um noch als verbunden zu gelten.
DEFAULT_TOLERANCE_MM = 50.0
@dataclass
class _EndpointEntry:
"""Ein indizierter Anschlusspunkt: welches Bauteil, welches Ende."""
teile_id: str
end: str # "K1"/"K2" (AS/ES) oder "a"/"b" (Kreisel-Bahnende)
def chain_endpoints(
element: Element,
) -> tuple[tuple[float, float] | None, tuple[float, float] | None]:
"""AS-(K1) und ES-(K2) Weltpunkt einer Strecke/Gefaellestrecke.
Fehlen K1/K2 (aeltere Exporte ohne AS/ES-Punkte), Ersatz durch die Mitten
der kurzen Stirnseiten der Bounding-Box entlang ihrer laengeren Ausdehnung
- gleiche Idee wie dxfmakros' export_neighbors.py::_strecke_ends, nur ohne
dessen "Anfang liegt naeher am Einfuegepunkt"-Zuordnung: fuer die reine
Fanout-Zaehlung an einem Punkt (count_geometric_exits) ist unerheblich,
welches Ende AS und welches ES ist.
"""
by_label = dict(element.kpoints)
k1, k2 = by_label.get("K1"), by_label.get("K2")
if k1 is not None and k2 is not None:
return k1, k2
if element.x is None or element.y is None or element.bbox is None:
return k1, k2
cx, cy = element.x, element.y
dx, dy = element.bbox_x or 0.0, element.bbox_y or 0.0
if dx >= dy:
bbox_ends = ((cx - dx / 2.0, cy), (cx + dx / 2.0, cy))
else:
bbox_ends = ((cx, cy - dy / 2.0), (cx, cy + dy / 2.0))
return k1 or bbox_ends[0], k2 or bbox_ends[1]
def kreisel_durchmesser_mm(element: Element) -> float:
"""Kreisel-Durchmesser (mm) aus einem Merkmal, sonst DEFAULT_*."""
for key in ("Durchmesser_mm", "Durchmesser (mm)", "Durchmesser"):
value = _as_float(_merkmal(element.merkmale, key))
if value:
return value
return DEFAULT_KREISEL_DURCHMESSER_MM
def kreisel_capsule(element: Element, durchmesser_mm: float | None = None) -> dict | None:
"""Achsen-Segment (Kapsel) eines Kreisels in Weltkoordinaten.
dxfmakros' eigenes export_neighbors.py::_kreisel_capsule setzt die
Antriebsstation (AN8) lokal bei (radius, 0) und die Spannstation (SP8) bei
(radius+abstand, 0) - relativ zum BLOCK-Einfuegepunkt (lokaler Ursprung
des KREISEL_n-Wrappers, siehe KreiselInsert.lsp::draw-module). Die CSV-
Spalte "Position" fuehrt bei einem Kreisel jedoch nachweislich den
BBOX-MITTELPUNKT, nicht diesen Einfuegepunkt: die exportierte Boundingbox
ist exakt symmetrisch um Position (halbe Breite = (abstand+durchmesser)/2,
siehe hundmM05_1.csv Zeile 6/7 - AN8-lokal-(radius,0) waere das nicht).
AN8/SP8 werden daher symmetrisch um Position gelegt: AN8 bei
(-abstand/2, 0), SP8 bei (+abstand/2, 0) - beide auf der lokalen X-Achse,
gedreht um das Merkmal "Drehung" (Grad, CCW positiv).
Rueckgabe {"a": (x,y), "b": (x,y), "r": radius} (a = AN8-, b = SP8-Mitte),
oder None ohne Position.
"""
if element.x is None or element.y is None:
return None
abstand_m = None
for key in ABSTAND_MERKMALE:
abstand_m = _as_float(_merkmal(element.merkmale, key))
if abstand_m is not None:
break
abstand_mm = (abstand_m or 0.0) * 1000.0
durchmesser = durchmesser_mm or kreisel_durchmesser_mm(element)
r = durchmesser / 2.0
drehung = _as_float(_merkmal(element.merkmale, "Drehung")) or 0.0
rad = math.radians(drehung)
cos_r, sin_r = math.cos(rad), math.sin(rad)
def world(lx: float, ly: float) -> tuple[float, float]:
return (element.x + lx * cos_r - ly * sin_r,
element.y + lx * sin_r + ly * cos_r)
half = abstand_mm / 2.0
return {"a": world(-half, 0.0), "b": world(half, 0.0), "r": r}
def kreisel_lane_for_point(capsule: dict, point: tuple[float, float]) -> str:
"""Welche Bahn (L/R) liegt ein Punkt bezogen auf die Kreiselachse a->b.
Kreuzprodukt Achse x (Punkt - a): positiv = links der Achsrichtung
(mathematisch positive/CCW-Drehung von der Achse aus), konsistent mit
dxfmakros' "links" = lokal Y in [0, +radius] (siehe kreisel_half_bboxes).
"""
ax, ay = capsule["a"]
bx, by = capsule["b"]
px, py = point
cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
return LANE_LEFT if cross >= 0 else LANE_RIGHT
class GeometryIndex:
"""STRtree-Index der Bauteil-Geometrie fuer die geometrische TRO-Ableitung.
Zwei getrennte Baeume:
_host_tree eine LineString je Gefaellestrecke/Strecke (K1->K2-Achse)
bzw. Kreisel (AN8->SP8-Achse), abgefragt mit .nearest() -
"welcher Bauteil-ACHSE liegt dieser Separator am
naechsten" (resolve_host_geometric). Bewusst die Achse,
nicht die lose Bounding-Box: ein Kreisel deckt mit
seiner Box leicht 20+ Meter ab (Antriebs- bis
Spannstation), ein Separator auf einer nahen VF-Kette
waere nach reiner Box-Distanz faelschlich "im Kreisel",
obwohl er auf der Kette sitzt (siehe Modul-Historie:
genau dieser Fehler zeigte sich beim ersten Testlauf
gegen hundmM05_1.csv).
_endpoint_tree ein Punkt je AS/ES-Ende einer Strecke/Gefaellestrecke
plus je Kreisel-Bahnende, abgefragt mit .query(...,
predicate="dwithin") - "wie viele andere Ketten treffen
sich an diesem Punkt" (count_geometric_exits).
"""
def __init__(self, elements: list[Element], durchmesser_mm: float | None = None):
self.durchmesser_mm = durchmesser_mm
self._host_ids: list[str] = []
host_axes = []
# Kreisel-Kapseln nach TeileId, um am Treffer die Bahn (-L/-R) zu
# bestimmen - graph.nodes kennt Kreisel nur unter "<id>-L"/"<id>-R",
# nie unter der blanken TeileId (siehe material_flow.py::_build_nodes).
self._kreisel_capsules: dict[str, dict] = {}
self._endpoints: list[_EndpointEntry] = []
endpoint_points = []
for element in elements:
if element.kind in ("Gefaellestrecke", "Strecke"):
k1, k2 = chain_endpoints(element)
if k1 is None or k2 is None:
continue
self._host_ids.append(element.teile_id)
host_axes.append(LineString([k1, k2]))
for label, point in (("K1", k1), ("K2", k2)):
self._endpoints.append(_EndpointEntry(element.teile_id, label))
endpoint_points.append(Point(point))
elif element.kind == "Kreisel":
if element.x is None or element.y is None:
continue
capsule = kreisel_capsule(element, durchmesser_mm)
if capsule is None:
continue
self._host_ids.append(element.teile_id)
host_axes.append(LineString([capsule["a"], capsule["b"]]))
self._kreisel_capsules[element.teile_id] = capsule
for end in ("a", "b"):
self._endpoints.append(_EndpointEntry(element.teile_id, end))
endpoint_points.append(Point(capsule[end]))
self._host_tree = STRtree(host_axes) if host_axes else None
self._endpoint_tree = STRtree(endpoint_points) if endpoint_points else None
def nearest_host(self, x: float, y: float) -> str | None:
"""Naechster Host-Knoten nach Achsabstand (Gefaellestrecke/Strecke-
TeileId, oder "<Kreisel-TeileId>-L"/"-R" - dieselbe Schreibweise wie
graph.nodes)."""
if self._host_tree is None:
return None
index = self._host_tree.nearest(Point(x, y))
if index is None:
return None
teile_id = self._host_ids[int(index)]
capsule = self._kreisel_capsules.get(teile_id)
if capsule is None:
return teile_id
lane = kreisel_lane_for_point(capsule, (x, y))
return f"{teile_id}-{lane}"
def exits_near(self, x: float, y: float, tol_mm: float, exclude_ids: set[str]) -> set[str]:
"""Andere Bauteil-Ids, deren Anschlusspunkt innerhalb tol_mm von (x,y) liegt."""
if self._endpoint_tree is None:
return set()
hits = self._endpoint_tree.query(Point(x, y), predicate="dwithin", distance=tol_mm)
return {self._endpoints[int(i)].teile_id for i in hits} - exclude_ids
def resolve_host_geometric(separator: Element, index: GeometryIndex) -> str | None:
"""Naechster Host zum Separator rein nach Weltkoordinate."""
if separator.x is None or separator.y is None:
return None
return index.nearest_host(separator.x, separator.y)
def _strip_lane(host_id: str) -> str:
""""0006-L" -> "0006" (Kreisel-Bahnsuffix entfernen; alles andere unveraendert)."""
if host_id.endswith(("-L", "-R")):
return host_id[:-2]
return host_id
def count_geometric_exits(
hosts: list[str],
elements_by_id: dict[str, Element],
index: GeometryIndex,
tol_mm: float = DEFAULT_TOLERANCE_MM,
) -> list[str]:
"""Andere Ketten, die geometrisch an einem Ende von `hosts` zusammentreffen.
Entspricht tro_flow.py::_switch_exits, aber unabhaengig von graph.edges:
beide Enden JEDES Hosts (K1 und K2 bzw. beide Kreisel-Bahnenden) werden
abgefragt, die Treffer aller Enden vereinigt - wie eine aus beiden Enden
zusammengefuehrte "Nachbarn"-Spalte, nur direkt aus der Geometrie statt aus
der (fuer diese Bauteile teils unvollstaendigen) CSV-Spalte.
`hosts` kann Kreisel-Bahnen als "<TeileId>-L"/"-R" enthalten (siehe
GeometryIndex.nearest_host) - die Endpunkt-Eintraege im Index fuehren dazu
nur die blanke TeileId, daher wird der Suffix hier wie dort entfernt.
"""
exclude = {_strip_lane(h) for h in hosts}
found: set[str] = set()
for host_id in hosts:
element = elements_by_id.get(_strip_lane(host_id))
if element is None:
continue
if element.kind == "Kreisel":
capsule = kreisel_capsule(element, index.durchmesser_mm)
points = (capsule["a"], capsule["b"]) if capsule else ()
else:
points = tuple(p for p in chain_endpoints(element) if p is not None)
for x, y in points:
found |= index.exits_near(x, y, tol_mm, exclude)
return sorted(found)
def make_geometric_resolvers(
elements: list[Element],
tol_mm: float = DEFAULT_TOLERANCE_MM,
durchmesser_mm: float | None = None,
):
"""resolve_host/count_exits-Paar fuer tro_flow.py::derive_tros(...).
Ersetzt Zuordnung (Spalte) und Nachbarn/graph.edges durch eine direkte
STRtree-Nachbarsuche ueber die Weltkoordinaten der Bauteile. Weicht der
geometrisch gefundene Host von der CSV-Spalte "Zuordnung" ab, wird das als
Hinweis protokolliert statt stillschweigend ignoriert - das deckt zugleich
auf, wo die CSV-Spalte fehlerhaft ist.
"""
index = GeometryIndex(elements, durchmesser_mm)
by_id = {e.teile_id: e for e in elements}
def resolve_host(sep: Element, findings: list[str]) -> str | None:
host = resolve_host_geometric(sep, index)
if host is None:
findings.append(
f"Separator {sep.teile_id} '{sep.name}': geometrisch kein Host "
f"gefunden (CSV-Zuordnung '{sep.assignment or '-'}') - keinem "
f"TRO zugeordnet"
)
return None
if sep.assignment and sep.assignment != UNASSIGNED and sep.assignment != host:
findings.append(
f"Separator {sep.teile_id} '{sep.name}': geometrischer Host "
f"'{host}' weicht von CSV-Zuordnung '{sep.assignment}' ab"
)
return host
def count_exits(hosts: list[str]) -> list[str]:
return count_geometric_exits(hosts, by_id, index, tol_mm)
return resolve_host, count_exits
+8 -278
View File
@@ -10,10 +10,13 @@ eigener TRO erkannt wurde. Solche Erkenntnisse kommen aus der Begehung bzw.
aus dem Review der erzeugten Diagramme. aus dem Review der erzeugten Diagramme.
Damit sie den naechsten Lauf ueberleben, stehen sie in einer INI-Datei Damit sie den naechsten Lauf ueberleben, stehen sie in einer INI-Datei
(`%SKEL_CFG%/tro_overrides.ini`) statt in den erzeugten Dateien. `tro_flow.py` (`%SKEL_CFG%/tro_overrides.ini`) statt in den erzeugten Dateien.
und `tro_annotate.py` lesen sie und wenden sie nach der Ableitung an; die Doku
weist jede angewandte Korrektur getrennt aus, damit abgeleitet und behauptet Seit 2026-10-05 nur noch von `material_flow.py --connect` gelesen (Abschnitt
unterscheidbar bleiben. `[<Anlage>.manual]` der connect.ini). `tro_flow.py` und `tro_annotate.py`
wenden keine Korrekturen mehr an: die Abschnitte hingen an laufenden
TRO-Nummern und veralteten mit jedem Export - Fehler der Ableitung werden
seitdem im Code behoben (lib/tro_flow.py), nicht in der INI.
Aufbau der INI (Sektionsnamen mit dem CSV-Dateinamen als Praefix, damit Aufbau der INI (Sektionsnamen mit dem CSV-Dateinamen als Praefix, damit
mehrere Anlagen in einer Datei stehen koennen): mehrere Anlagen in einer Datei stehen koennen):
@@ -31,9 +34,7 @@ mehrere Anlagen in einer Datei stehen koennen):
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**. Sie werden nur Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**.
in der Doku und in der annotierten Zeichnung markiert, damit sie in BricsCAD
geprueft werden koennen, bevor daraus ein TRO wird.
""" """
from __future__ import annotations from __future__ import annotations
@@ -320,274 +321,3 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
result.open_points.sort(key=lambda p: p.name) result.open_points.sort(key=lambda p: p.name)
return result return result
def apply_splits(tros, overrides: Overrides, make_tro) -> list:
"""Neue TROs aus bestehenden herausloesen (vor der Nummernvergabe).
`make_tro(spec, hosts, label)` erzeugt das projektspezifische Tro-Objekt -
so bleibt dieses Modul frei von einem Import aus tro_flow (das umgekehrt
schon von hier importiert).
"""
for spec in overrides.splits:
quellen = [t for t in tros if set(spec.separators) & set(t.separators)]
if not quellen:
overrides.warnings.append(
f"Split '{spec.name}': keiner der Separatoren "
f"{', '.join(spec.separators)} kommt in diesem Lauf vor - "
f"nicht angelegt"
)
continue
hosts: list[str] = []
for quelle in quellen:
quelle.separators = [s for s in quelle.separators
if s not in spec.separators]
quelle.scanners = [s for s in quelle.scanners if s not in spec.scanners]
hosts.extend(quelle.hosts)
if not quelle.separators and quelle.type_name not in ("Vario",):
overrides.warnings.append(
f"Split '{spec.name}': {quelle.label} hat danach keinen "
f"Separator mehr - bitte pruefen"
)
ziel_hosts = [spec.host] if spec.host else sorted(set(hosts))[:1]
label = spec.label or (
f"Separator {', '.join(spec.separators)} "
f"(manuell aus {quellen[0].label} herausgeloest)"
)
tros.append(make_tro(spec, ziel_hosts, label))
overrides.applied.append(
f"neu {spec.name}: {spec.type_name} auf {', '.join(ziel_hosts)} "
f"mit Separator {', '.join(spec.separators)}, herausgeloest aus "
f"{', '.join(sorted({q.label for q in quellen}))} "
f"({spec.reason or 'manuelle Ergaenzung'})"
)
return tros
def apply_merges(tros, overrides: Overrides) -> list:
"""Mehrere TROs zu einem zusammenfassen (nach den Splits, vor der Nummer).
Der erste betroffene TRO bleibt bestehen und uebernimmt Bauteile, Hosts und
Typ; die uebrigen werden aus der Liste entfernt.
"""
for spec in overrides.merges:
betroffen = [t for t in tros if set(spec.separators) & set(t.separators)]
if len(betroffen) < 2:
gefunden = ", ".join(t.tro_id or t.label for t in betroffen) or "keiner"
overrides.warnings.append(
f"Merge '{spec.name}': nur {len(betroffen)} der Separatoren "
f"{', '.join(spec.separators)} gefunden ({gefunden}) - "
f"nicht zusammengefasst"
)
continue
ziel = betroffen[0]
quellen = betroffen[1:]
for quelle in quellen:
ziel.separators = sorted(set(ziel.separators) | set(quelle.separators))
ziel.scanners = sorted(set(ziel.scanners) | set(quelle.scanners))
ziel.hosts = sorted(set(ziel.hosts) | set(quelle.hosts))
tros.remove(quelle)
if spec.host:
# Ein Host bestimmt, wo der zusammengefasste TRO im Fluss haengt;
# die uebrigen Bahnen bleiben als weitere Hosts erhalten, damit die
# Verknuepfung beide Seiten des Uebergangs sieht.
ziel.hosts = [spec.host] + [h for h in ziel.hosts if h != spec.host]
ziel.type_name = spec.type_name
ziel.confidence = spec.confidence
ziel.label = spec.label or ziel.label
grund = spec.reason or "manuell zusammengefasst"
ziel.reason = f"{ziel.reason}; manuell zusammengefasst ({grund})"
overrides.applied.append(
f"merge {spec.name}: {len(betroffen)} TROs -> ein {spec.type_name} "
f"mit Separatoren {', '.join(ziel.separators)} ({grund})"
)
return tros
def apply_connections(tros, overrides: Overrides):
"""Gesetzte Topologie an die Stelle der abgeleiteten Kanten setzen.
Ersetzt vollstaendig, nicht ergaenzend: wenn die Verbindungen vor Ort
aufgenommen wurden, sind die abgeleiteten damit erledigt - sonst blieben
genau die Kanten stehen, die korrigiert werden sollten. Was dabei wegfaellt
bzw. hinzukommt, steht anschliessend in overrides.applied.
Rueckgabe: (edges, edge_kinds) wie von link_tros.
"""
bekannt = {t.tro_id for t in tros}
edges: list[tuple[str, str]] = []
kinds: dict[tuple[str, str], str] = {}
for spec in overrides.connections:
for seite, wert in (("von", spec.src), ("nach", spec.dst)):
if wert not in bekannt:
overrides.warnings.append(
f"Verbindung {spec.src} -> {spec.dst}: {seite} '{wert}' ist "
f"kein TRO dieses Laufs - Verbindung ignoriert"
)
break
else:
paar = (spec.src, spec.dst)
if paar in kinds:
continue
edges.append(paar)
kinds[paar] = spec.kind or "gesetzt"
for tro in tros:
tro.successors = sorted(d for s, d in edges if s == tro.tro_id)
tro.predecessors = sorted(s for s, d in edges if d == tro.tro_id)
tro.colocated = []
ohne = [t.tro_id for t in tros if not t.successors and not t.predecessors]
if ohne:
overrides.warnings.append(
"ohne Verbindung in der gesetzten Topologie: " + ", ".join(sorted(ohne))
)
overrides.applied.append(
f"Topologie gesetzt: {len(edges)} Verbindungen aus der Override-Datei "
f"ersetzen die abgeleiteten"
)
return sorted(edges), kinds
def apply_overrides(tros, overrides: Overrides) -> None:
"""Typkorrekturen auf die abgeleitete TRO-Liste anwenden.
Wird aufgerufen, nachdem die TRO-IDs vergeben sind (die Korrekturen sind
ueber die ID adressiert) und bevor FB-Baustein und Bauteilliste aus dem
Katalog gezogen werden - der Typwechsel zieht beides mit.
"""
if not overrides.tros:
return
for section_key, override in sorted(overrides.tros.items()):
treffer = [t for t in tros if override.matches(t)]
if not treffer:
wie = "Separator" if override.by_separator else "TRO-ID"
overrides.warnings.append(
f"[{section_key}]: kein TRO mit {wie} '{override.key}' in diesem "
f"Lauf - Korrektur nicht angewandt"
+ ("" if override.by_separator else
" (Nummerierung geaendert? Besser ueber SEP<Nummer> adressieren)")
)
continue
if len(treffer) > 1:
overrides.warnings.append(
f"[{section_key}]: trifft {len(treffer)} TROs "
f"({', '.join(t.tro_id for t in treffer)}) - auf alle angewandt"
)
for tro in treffer:
if not override.type_name or override.type_name == tro.type_name:
continue
vorher = tro.type_name
tro.type_name = override.type_name
tro.confidence = override.confidence
grund = override.reason or "manuelle Korrektur"
tro.reason = (f"{tro.reason}; manuell auf {override.type_name} "
f"gesetzt ({grund})")
overrides.applied.append(
f"{tro.tro_id}: {vorher} -> {override.type_name} ({grund})"
)
# ---------------------------------------------------------------------------
# connect.ini (TRO-Ebene) zurueck einlesen - tro_flow.py --from-connect
# ---------------------------------------------------------------------------
# Vertrauensangabe fuer Typen, die aus einer (evtl. von Hand geaenderten)
# connect.ini stammen - in der Doku von Layout und Begehung unterscheidbar.
CONNECT_CONFIDENCE = "connect.ini"
def _connect_pair(raw: str) -> tuple[str, str, str] | None:
"""'<von> -> <nach> | <JamArea> | <kind>' -> (von, nach, kind); None wenn
kein Pfeil. Die JamArea wird (noch) nicht ausgewertet."""
pfeil, _, rest = str(raw).partition("|")
src, sep, dst = (t.strip() for t in pfeil.partition("->"))
if not sep or not src or not dst:
return None
felder = [f.strip() for f in rest.split("|")]
kind = felder[1] if len(felder) > 1 else ""
return src, dst, kind
def load_connect_ini(path: Path, base: Overrides | None = None) -> Overrides:
"""
Eine von tro_flow.py --connect geschriebene connect.ini als Korrekturen lesen.
Die Datei wird dabei nicht als eigenes Modell behandelt, sondern auf die
vorhandenen Korrekturarten abgebildet - so laeuft sie durch dieselbe
Pipeline wie cfg/tro_overrides.ini:
[<Anlage>.nodes] TRO-ID = <Typ> | ... -> Typkorrektur je TRO-ID
[<Anlage>.connections] cNNN = von -> nach | JamArea | kind
[<Anlage>.externals] eNNN = SYSn -> TRO | ... bzw. TRO -> SYSn | ...
-> zusammen die gesetzte Topologie, die die
abgeleiteten Kanten vollstaendig ersetzt
[<Anlage>.tros] (Vorgaenger/Nachfolger je TRO) ist nur eine Uebersicht und
wird ignoriert - massgeblich sind die Verbindungen.
`base` (die Korrekturen aus cfg/tro_overrides.ini) bleibt erhalten; Typen
aus der connect.ini gewinnen, die Topologie der connect.ini ersetzt eine
dort gesetzte.
"""
result = base if base is not None else Overrides()
parser = configparser.ConfigParser(inline_comment_prefixes=(";",),
interpolation=None)
parser.optionxform = str # TRO-IDs nicht in Kleinschreibung wandeln
# utf-8-sig: von Hand gespeicherte Dateien (Windows-Editoren, PowerShell)
# tragen oft ein BOM, an dem configparser sonst in Zeile 1 scheitert.
try:
parser.read(path, encoding="utf-8-sig")
except configparser.Error as exc:
raise ValueError(f"{path.name} nicht lesbar: {exc}") from exc
connections: list[ConnectionSpec] = []
for section in parser.sections():
art = section.rsplit(".", 1)[-1].lower()
values = parser[section]
if art == "nodes":
for tro_id, raw in values.items():
type_name = str(raw).partition("|")[0].strip()
# Systemgrenzen (SYSn) sind kein TRO, ihr "Typ" ist nur Anzeige
if tro_id.upper().startswith("SYS") or not type_name:
continue
if get_tro(type_name) is None:
result.warnings.append(
f"{path.name} [{section}] {tro_id}: Typ '{type_name}' steht "
f"nicht im TRO-Katalog - Typ bleibt wie abgeleitet"
)
continue
result.tros[tro_id.upper()] = TroOverride(
key=tro_id.upper(), type_name=type_name,
reason=f"aus {path.name}", confidence=CONNECT_CONFIDENCE,
)
elif art in ("connections", "externals"):
for key, raw in values.items():
pair = _connect_pair(raw)
if pair is None:
result.warnings.append(
f"{path.name} [{section}] {key}: '{raw}' ist keine "
f"Verbindung '<von> -> <nach>'"
)
continue
src, dst, kind = pair
if "EXTERN" in (src.upper(), dst.upper()):
result.warnings.append(
f"{path.name} [{section}] {key}: EXTERN wird nicht "
f"ausgewertet - Systemgrenze als SYSn angeben"
)
continue
connections.append(ConnectionSpec(src=src, dst=dst, kind=kind))
if connections:
result.connections = connections
else:
result.warnings.append(
f"{path.name}: keine Verbindungen gefunden - Topologie bleibt wie abgeleitet"
)
result.applied.append(f"connect.ini eingelesen: {path}")
return result
-4
View File
@@ -6,10 +6,6 @@ pydantic >= 2.0.0
# DXF lesen und schreiben (tro_annotate). DWG wird nicht unterstuetzt. # DXF lesen und schreiben (tro_annotate). DWG wird nicht unterstuetzt.
ezdxf >= 1.4.0 ezdxf >= 1.4.0
# STRtree fuer die geometrische TRO-Ableitung (tro_flow.py --geometry, siehe
# lib/tro_geometry.py) - nur zur Laufzeit von --geometry benoetigt.
shapely >= 2.1
# Nur fuer Vorschaubilder eines DXF (ezdxf.addons.drawing) - optional: # Nur fuer Vorschaubilder eines DXF (ezdxf.addons.drawing) - optional:
# matplotlib >= 3.8 # matplotlib >= 3.8