Weichenknoten zwischen Kreiseln, Track-Mouseover, Parameter in cfg/parameter.ini

TRO-Typen an Kreisel-Beruehrpunkten (lib/kreisel_junctions.py, derive_tros
Schritt 3c): jeder Kreisel als Stadion-Form mit Laufrichtung; jeder
gleichlaufende Beruehrpunkt ist eine Weiche (exportierte ILS Weiche oder
abgeleitet W<a>_<b>), Beruehrpunkte derselben Umlenkung bilden einen
Knoten. Ein Separator steuert ihn, wenn er in Laufrichtung unmittelbar
davor liegt (keine andere Weiche dazwischen). Typ = Separatoren x Weichen.
export.csv: 1Sep1Swi (9/1, 9/8, 8/7), 2Sep1Swi (7/2), 2Sep2Swi (2/3/4),
3Sep2Swi (4/5/6). Neuer Katalogtyp 3Sep2Swi (nicht aus SCL belegt).
Gegenlaeufige Beruehrpunkte (Mubea, Dematic) bleiben bei der alten Regel;
Mubea/HundM05/Dematic unveraendert, Showroom 0018+0019 jetzt 2Sep1Swi.

material_flow --track: Mouseover je Knoten mit Zugehoerigkeit zu
Kreisel/VF/GF (Zuordnung, TrackIds-Kette, Lage laut Geometrie), Weiche mit
beiden Kreiseln, Be-/Entladung mit ihrem Kreisel; Widersprueche mit "!".

Zahlenwerte (Toleranzen, Schwellen, Massstaebe, Groessen) aus allen
lib-Modulen nach cfg/parameter.ini (lib/parameter.py, Abschnitt je Modul,
Ueberschreiben je Anlage ueber %SKEL_CFG%/parameter.ini). Ausgaben aller
Anlagen bytegleich.

tro_flow.py: Docstrings mit Parametern/Rueckgabe, fehlende Typangaben.

Geprueft: 90 Tests gruen; tro_flow/material_flow/scl_skeleton auf 5 CSVs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-10-14 17:20:30 +02:00
co-authored by Claude Opus 5.5
parent 0c69faf3db
commit 550bdeb464
17 changed files with 1634 additions and 125 deletions
+5 -2
View File
@@ -37,6 +37,8 @@ Template" below). What exists today is:
- `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/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts;
`cfg/parameter.ini` — tolerances, thresholds, scales and drawing sizes of the `lib/` modules
(read by `lib/parameter.py`; no numeric settings in the code, see `doc/Python_Scripts.md` §7a);
`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
@@ -95,8 +97,9 @@ this order for onboarding:
0. **`doc/Python_Scripts.md`** — the existing CLI tooling (`lib/material_flow.py`,
`lib/tro_flow.py`, `lib/tro_annotate.py`, `lib/tro_extract.py`, `lib/scl_skeleton.py`,
`lib/e_planning.py`, plus the `lib/tro_catalog.py`, `lib/model.py`, `lib/dxf_registration.py` and
`lib/tro_overrides.py` libraries they share): what each script does, its switches, inputs/outputs. This is
`lib/e_planning.py`, plus the `lib/tro_catalog.py`, `lib/model.py`, `lib/dxf_registration.py`,
`lib/kreisel_junctions.py` (switches between touching Kreisel, from geometry and direction
of travel) and `lib/tro_overrides.py` libraries they share): what each script does, its switches, inputs/outputs. This is
working code, not a proposal — read it before touching `lib/`.
1. **`doc/HundM/Json_Layout-Konzept.md`** — the core proposal: a JSON file as single source
of truth (`plc`, `controlUnits`, `sensors[]`, `conveyors[]`, `tros[]`, `loadingBooms[]`,
+1 -1
View File
@@ -1,4 +1,4 @@
;; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp
;; Quelle: TRO_CATALOG in lib/tro_catalog.py - nicht manuell aendern.
(setq *TRO-TYPES* (list "1Sep" "1Sep1Swi" "1Sep2Swi" "1Sep_SSCC" "Vario" "PinStore_Auto" "Vario_workStation" "EmptyCarrBuffer" "LoadingBoom" "2Sep1Swi" "2Sep2Swi"))
(setq *TRO-TYPES* (list "1Sep" "1Sep1Swi" "1Sep2Swi" "1Sep_SSCC" "Vario" "PinStore_Auto" "Vario_workStation" "EmptyCarrBuffer" "LoadingBoom" "2Sep1Swi" "2Sep2Swi" "3Sep2Swi"))
(princ)
+2 -1
View File
@@ -21,7 +21,8 @@
; soll_mm raeumlich: Sollabstand - das Symbol liegt immer gleich weit
; neben dem Bauteil; gewertet wird die Abweichung vom Soll
; (nicht der kleinste Abstand), zulaessig bis toleranz_mm
; toleranz_mm zulaessige Abweichung vom Sollabstand (Standard 150)
; toleranz_mm zulaessige Abweichung vom Sollabstand (Standard: cfg/parameter.ini
; [e_planning] toleranz_mm)
; nummer_von Nummernregel: BMK-Nummer = Nummer der genannten Rolle
; nummer_versatz ... plus dieser Versatz (Mubea: Ventil = Sensor + 1)
; Mit nummer_von wird zuerst die Nummer versucht, danach (wenn max_mm oder
+178
View File
@@ -0,0 +1,178 @@
; ================================================================
; parameter.ini - Zahlenwerte der Bibliotheken in lib/
; ================================================================
; Gelesen von lib/parameter.py. Ein Abschnitt je Modul, der Schluessel ist
; der Name der Konstanten im Modul in Kleinbuchstaben:
; [kreisel_junctions] contact_tolerance -> CONTACT_TOLERANCE
;
; Diese Datei ist die versionierte Grunddatei und muss vollstaendig sein -
; fehlt ein Schluessel, bricht das Programm ab (kein Ersatzwert im Code).
; Eine weitere parameter.ini in %SKEL_CFG% (falls SKEL_CFG woanders hin
; zeigt) ueberschreibt einzelne Werte fuer eine Anlage.
;
; Laengen in mm, Winkel in Grad, sofern nicht anders angegeben.
; Werte, die das Ergebnis der Ableitung aendern (Toleranzen, Schwellen),
; nur mit Begruendung und Vergleich der Ausgaben aller Anlagen aendern.
; ================================================================
[kreisel_junctions]
; Hoechster Abstand zweier Kreisel-Umlaeufe, der noch als Beruehrung gilt.
contact_tolerance = 50.0
; Hoechster Abstand eines Separators vom Umlauf, damit er auf ihm liegt.
; Auch die Grenze fuer "liegt auf Kreisel" im Mouseover von
; material_flow.py --track.
separator_tolerance = 100.0
; Hoechster Abstand einer exportierten ILS Weiche von ihrem Beruehrpunkt.
weiche_tolerance = 300.0
; Abtastschritt beim Suchen des Beruehrpunkts.
sample_step = 10.0
; Mindestanzahl Abtastpunkte je Umlauf, auch bei sehr kleinen Kreiseln.
min_samples = 8
; Bei tangentialer Beruehrung sind viele Abtastpunkte fast gleich nah: alle
; bis zu diesem Mehrabstand ueber dem naechsten gehen in den Mittelpunkt ein.
contact_tie = 1.0
; Ein Beruehrbereich, der laenger ist, liegt nicht an einem Punkt an (zwei
; parallele Bahnen nebeneinander) - Hinweis statt Weiche.
max_contact_length = 500.0
; Ein Punkt bis zu diesem Abstand vor dem Ende der Geraden zaehlt schon zur
; Umlenkung. Zwei Kreisel, deren Umlenkungen sich beruehren, tun das oft genau
; am Uebergang Gerade/Halbkreis (export.csv: Kreisel 5 an Kreisel 6 und 4).
end_tolerance = 100.0
; Radius, wenn die Boundingbox ihn nicht liefert (schraeg liegender Kreisel).
default_radius = 400.0
; Halbe Schrittweite fuer die Laufrichtung an einer Stelle des Umlaufs
; (Differenz zweier Punkte davor und dahinter).
direction_step = 1.0
; Gleichlauf am Beruehrpunkt: Mindest-Kosinus des Winkels zwischen beiden
; Laufrichtungen (0.5 = hoechstens 60 Grad). Darunter "gegenlaeufig".
gleichlauf_min_cos = 0.5
[tro_flow]
; Ab dieser Anzahl zusammenhaengender Schwerkraftlinien (Gefaellestrecke mit
; je einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
min_store_lanes = 2
; Mindest-Hoehenverlust auf dem vorangehenden TrackIds-Segment, damit ein
; Separator auf einer "Strecke" trotzdem als 1Sep statt Vario gilt (siehe
; _incoming_drop). Muss nur "horizontal" gefuehrte, aber tatsaechlich
; Gefaelle-Segmente erwischen (HundM05 Separator 0035: -104mm auf 3,8m), nicht
; das Rauschen eines echten flachen VF-Laufs (HundM05 Separator 0036: +26mm
; auf 0,5m). 30mm liegt bewusst weit unter dem Befund und ueber dem Rauschen.
gefaelle_drop_mm = 30.0
; Zeichnungsmassstab fuer --use-cords in pt/mm: Graphviz rechnet in Punkt, die
; Anlage in Millimeter. Seit die Symbole eine feste Groesse haben, reichen
; 0.1 pt/mm: ~11 x 20 m Anlage ergeben etwa 1100 x 2000 pt. Vorher 0.25
; (riesige Zeichnung, Symbole und Schrift verschwanden; Review 2026-10-01).
coord_scale = 0.1
; Zu-/Ablaufpfeil einer Station in der lagerichtigen Zeichnung: feste Laenge
; ab dem angehaengten TRO (siehe _station_positionen).
coord_pfeil_laenge = 1300
; Groesste Luecke zwischen zwei benachbarten Knotenkoordinaten je Achse in der
; lagerichtigen Zeichnung; groessere Leerstrecken werden auf diesen Wert
; gekuerzt (Review 2026-10-01). --true-scale schaltet das ab.
coord_max_luecke = 1500
; Abstand der Legende rechts neben der Anlage.
legend_margin = 3000
; Oberkante der Legende so weit unter der Anlagenoberkante.
legend_drop = 1000
[tro_ids]
; Abstand zwischen Eintrag der ID-Datei und Separator, bis zu dem ein TRO als
; wiedergefunden gilt (Standard fuer --id-tolerance). Separatoren derselben
; Bahn liegen deutlich weiter auseinander; ein verschobenes Bauteil faellt so
; als "neu" auf.
default_tolerance = 100.0
[material_flow]
; Max. Abstand, ab dem ein generisches Eingang/Ausgang-Symbol als
; "deckungsgleich" mit einer BTMT-Station gilt und verschluckt wird. Die
; beobachteten Paare liegen bei 256-431 mm, echte getrennte Stationen >1000 mm.
station_merge_mm = 600.0
; Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei
; Kreisel-Bahnen werden sie im Materialfluss-Diagramm zu einem Block
; zusammengefasst - sonst wird eine grosse Linienbank unlesbar breit.
min_gefaelle_group = 2
; Zeichnungsmassstab fuer --use-cords in pt/mm. Achtung: tro_flow nutzt seit
; Review 2026-10-01 0.1 - beide Graphen haben damit verschiedene Massstaebe.
coord_scale = 0.25
; Die beiden Bahnen (L/R) eines Kreisels teilen sich eine Position; im
; lagerichtigen Layout so weit versetzen, damit sie nicht uebereinander liegen.
lane_nudge_mm = 350.0
; Legende im lagerichtigen Layout so weit unterhalb der Anlage.
legend_offset_mm = 3000.0
; Legende (--show-bbs): Kaestchen- und Schriftgroesse.
bbs_legend_box_mm = 250.0
bbs_legend_font_mm = 180.0
; --show-bbs: Rand um die Anlage herum und minimale Schriftgroesse.
bbs_margin_mm = 500.0
bbs_min_font_mm = 40.0
; Anschlusspunkte K1-K4 (--show-kx): Groesse des Kreuzes und der Beschriftung.
kx_cross_mm = 120.0
kx_font_mm = 90.0
; --chain-graph: Hoechstgroesse der Zeichenflaeche, Rand und Titelhoehe (px).
chain_max_w = 1400.0
chain_max_h = 850.0
chain_margin = 70.0
chain_title_h = 60.0
[dxf_registration]
; Rastergroesse der Abstimmung.
vote_grid = 100.0
; So viele Kandidaten der Abstimmung werden ausgeglichen und bewertet.
vote_candidates = 6
; Nur Paare bis zu diesem Abstand gehen in den Ausgleich ein (exakte Treffer).
fit_tol = 50.0
; Punkte gelten als zugeordnet, wenn sie nach der Verschiebung so nah liegen.
default_match_tol = 600.0
; So viele exakte Treffer muessen es mindestens sein.
min_inliers = 5
; Ab dieser Drehung (Grad) wird gewarnt.
rotation_warn_deg = 0.05
[tro_annotate]
; Zeichnungsgroessen, wenn nicht per --marker-size/--text-height angegeben.
default_marker = 300.0
default_text = 220.0
; Restfehler des Lagebezugs, ab dem er abgelehnt wird.
default_max_residual = 600.0
[e_planning]
; Zulaessige Abweichung vom Sollabstand (soll_mm), wenn eine Regel in
; e_planning.ini kein toleranz_mm angibt.
toleranz_mm = 150.0
+50 -3
View File
@@ -265,7 +265,7 @@ See `doc/track.md` for the fuller derivation notes.
| `--connect` | — | off | Also write the topology as `<name>_connect.ini` (item/flow level): `[…nodes]` (Kreisel lanes, Strecke, Gefällestrecke), `[…connections]` with a `kind` per edge (`normal`/`umlauf`/`weiche`/`einschleusung`/`ausschleusung`), and `[…externals]` — the BTMT entry/exit if present, otherwise the open lane-ends listed as entry candidates. A hand-editable bridge into the TRO step, styled after `doc/TRO_Katalog/connect.ini`. |
| `--show-bbs [flow\|all]` | `flow` | off | Also write `<name>_bbs.svg`: a plain 2-D top-down view of every CSV row (not just flow nodes — Separator/Scanner/Ein-/Ausschleuselement too), one axis-aligned rectangle per row from the `Position` column (its center, X/Y) and the `Boundingbox` column (its width/depth, X/Y — the Z extent is ignored), with the row's `Bezeichnung` centered in it. A Kreisel is the one exception: instead of one rectangle for the whole ring, it's split into its two Bahn L/R halves (`_kreisel_lane_rects()` — short axis divided in half, `Zuordnung`-majority-vote decides which half is L vs R), each drawn and labelled separately; its K1–K4 points (with `--show-kx`) still belong to the Kreisel as a whole and are drawn once, not per half. No rotation, no Graphviz — hand-built SVG; the `viewBox` stays in plant millimetres (Y flipped so plant "up" renders as screen "up") but the printed page size is scaled to DIN A3 (landscape or portrait, whichever matches the layout's aspect ratio) instead of the plant's real-world extent. Larger objects are drawn first so small ones (Separator, Scanner, …) stay visible on top. A legend column right of the plant lists every drawn kind with its row count, and each rectangle carries a `<title>` tooltip (row, TeileId, TeileArt, name). Mode `flow` (default, also bare `--show-bbs`) skips the VF/GF chain detail rows exported by dxfmakros since Sept. 2026 (`Bogen`, `Streckenelement`, `Motorstation`, `Umlenkstation` — `BBS_DETAIL_KINDS`), because they fully overlap their Strecke/Gefällestrecke box; `all` draws every row, detail kinds in their own stronger colours. dxfmakros calls this via `bin/show_exportcsv.bat` (mode `all`). |
| `--show-kx` | — | off | Only valid together with `--show-bbs` (else exit 1). Additionally draw each object's connection coordinate systems K1–K4 into the `_bbs.svg`: the K1–K4 columns are 12-char base64 strings (three 24-bit fixed-point values `x,y,z`, factor 10, `csv:trans-encode` in `dxfmakros/Lisp/export.lsp`) decoded to a plant-mm X/Y. Each present K-point is drawn as a small red cross (two lines) with a small `K1`…`K4` label, and consecutive points are joined `K1→K2→K3→K4` by a blue line. Empty K columns are skipped, so an object with only K1/K2 gets two crosses and one link. |
| `--track` | — | off | Additionally write the finer Separator/Ausschleus/Einschleus/Weiche graph described above (`<name>_track.dot`, and `<name>_track.svg` with `--tosvg`). Does not change the normal `_material_flow.*` output. See `doc/track.md`. |
| `--track` | — | off | Additionally write the finer Separator/Ausschleus/Einschleus/Weiche graph described above (`<name>_track.dot`, and `<name>_track.svg` with `--tosvg`). Does not change the normal `_material_flow.*` output. See `doc/track.md`. Every node has a **mouseover text** (`tooltip`, shown on hover in the SVG): for a separator its `Zuordnung`, the container whose `TrackIds` list it (Kreisel/VF/GF) and the Kreisel lane it lies on by geometry (`lib/kreisel_junctions.py`); for an ILS Weiche the two Kreisel that created it (`Kreisel A`/`B`) and the touch point by geometry; for Beladung/Entladung the Kreisel they sit on. Contradictions between these sources get a line starting with `!` (e.g. `export.csv` 0015: `Zuordnung` Kreisel 0003, lies on Kreisel 0002). |
| `--chain-graph` | — | off | Write a "chain-/Lageplan" of the **grobe Objekte** (big items): every Kreisel split into its Bahn L/R halves (`_kreisel_lane_rects()`, same split as `--show-bbs`), every Strecke/Gefällestrecke ("VF"/"GF"), and every Ein-/Ausgabestation (BTMT `Beladung`/`Entladung`, or the generic `Eingang`/`Ausgang` symbol where no BTMT station absorbed it — see `_merge_station_symbols()`) — the plant runs as a closed loop, so these stations are its only material sources/sinks and are drawn like any other node, not skipped. Connected by the already-resolved top-level `graph.edges` (material flow, Kreisel-Umlauf, unresolved). Rendering method and output file are chosen by `--graphtype`. |
| `--graphtype` | `coarsebox`\|`compound`\|`subgraph` | `coarsebox` | Only valid together with `--chain-graph` (else exit 1). `coarsebox`: containers only (as above), to real scale, hand-built `<name>_chain.svg`, no Graphviz — the original behaviour. `compound`: the same to-scale picture, **plus** each container's Anbauteile (Separator, Scanner, Ausschleus-/Einschleuselement, Weiche) drawn nested inside it at their real position (`_container_children()` reads them off `Element.track_ids`, the same source `build_track_graph()`/`--track` uses — not a re-derivation from `Nachbarn` like the old `data/gen_chain_svg.py` prototype), linked by the already-resolved `TrackGraph.edges`; still `<name>_chain.svg`, no Graphviz. `subgraph`: the same containment expressed as **nested Graphviz clusters** (`subgraph cluster_*` — one per Kreisel, nested one per Bahn L/R, plus one per Strecke/Gefällestrecke, each containing its Anbauteile as child nodes) — schematic (`dot` auto-layout), **not** to scale; writes `<name>_chain.dot` always, and `<name>_chain.svg` additionally with `--tosvg` (needs Graphviz `dot`, unlike the other two graph types). |
@@ -293,8 +293,27 @@ draws *their* flow. Derivation rules (see
| Gefällestrecke, single line | `1Sep` |
| Strecke (driven) | `Vario` (one per segment) |
| Kreisel lane | `1Sep` |
| Separator before an Ausschleuselement (one / two in a row) | `1Sep1Swi` / `1Sep2Swi` |
| Junction between Kreisel: *n* separators directly upstream of *m* touch points on one pulley | `1Sep1Swi`, `2Sep1Swi`, `2Sep2Swi`, `3Sep2Swi` |
| — additionally: 2 or 3 outgoing paths | upgraded to `1Sep1Swi` / `1Sep2Swi` |
**Junctions between Kreisel** (`lib/kreisel_junctions.py`, step 3c in
`derive_tros`). Every Kreisel is modelled as a stadium (two pulleys plus two
straight lanes, from position, `Drehung` and bounding box) with its direction of
travel (`Drehrichtung`). Wherever two Kreisel touch and run the same way, there
is a switch. An exported `ILS Weiche` is matched to it by position; a touch point
without one gets the ID `W<KreiselA>_<KreiselB>` and a finding (in
`export.csv` three are missing: 2/3, 3/4, 8/9). Touch points on the same pulley
form one junction (Kreisel 5 touches 4 and 6 with its west pulley → 2 switches).
A separator controls a junction if, walking **against** the direction of travel
from the junction on one of its Kreisel, it is the first thing reached — before
any other switch. The type follows from the count: separators × switches. The
host of each separator is the lane it physically lies on, not its `Zuordnung`.
If the two Kreisel run against each other at the touch point (both `UZS` at a
pulley-to-pulley touch, as in Mubea and Dematic — probably an export error), no
junction is formed, a finding is written, and the ILS Weiche is handled by the
older neighbour rule (`_find_weiche_separators`, step 3c').
A scanner mounted on a separator does **not** change the type (stays `1Sep`);
the `1Sep_SSCC` special case can't be recognized from the mechanical layout
alone and is only raised as a finding. TRO types, FB blocks, sub-components and
@@ -641,12 +660,12 @@ into `scl_context` and `scl_skeleton --eplan`).
The single source of truth for everything needed to generate or draw a TRO
type: short name (as used in `FB_Main`/`connect.ini`), Siemens FB block,
sub-components and their counts, color group (for Graphviz/Mermaid/CAD) and
CAD marker shape. **Eleven** types are defined in `TRO_CATALOG`. Ten of them are
CAD marker shape. **Twelve** types are defined in `TRO_CATALOG`. Ten of them are
verified against `data/*.scl` where the source is vendored locally, and against
`TRO_Typen.md`'s mapping table otherwise (see the module docstring,
`CATALOG_AS_OF`, for provenance per type).
> **`2Sep2Swi` is the exception — it is not backed by any SCL.** It was added on
> **`2Sep2Swi` and `3Sep2Swi` are the exceptions — they are not backed by any SCL.** It was added on
> 2026-08-12 for project 500573 (Mubea), to model the transfer point at the
> Kreisel2/Kreisel1 touch as a single control object with 2 separators,
> 2 scanners and 2 switches. **`FB_ILS_MTRO_2Sep2Swi` does not exist** in this
@@ -654,6 +673,9 @@ verified against `data/*.scl` where the source is vendored locally, and against
> the ILSLib — otherwise a future SCL generator will emit a call to a missing
> block. The closest verified type is `2Sep1Swi` (2 separators, 2 scanners,
> **1** switch), which matches the hardware actually present in that export.
> `3Sep2Swi` (3 separators, 3 scanners, 2 switches) was added on 2026-10-14
> for the test plant `data/export.csv`, where one Kreisel pulley touches two
> others; **`FB_ILS_MTRO_3Sep2Swi` does not exist either.**
Everything is a Pydantic v2 model
(`TroDefinition`/`TroStyle`), so it validates on every assignment and comes
@@ -825,6 +847,31 @@ shows a larger `residual_max` than either role alone. That is expected; the
mean residual is the number to watch, and only pairs within `FIT_TOL` (50 mm)
enter the Procrustes fit.
## 7a. `parameter.py` — numeric settings from `cfg/parameter.ini` (library, no CLI)
Tolerances, thresholds, scales and drawing sizes of the libraries are not
written in the code but read from **`cfg/parameter.ini`**: one section per
module, the key is the constant's name in lower case
(`[kreisel_junctions] contact_tolerance` → `kreisel_junctions.CONTACT_TOLERANCE`).
Meaning and unit of every value are documented as comments in the INI file.
- The base file in the repository is read first and must be complete — a
missing key or a non-number stops the import with `ParameterError`; there are
deliberately no fallback values in the code.
- A second `parameter.ini` in `%SKEL_CFG%` (when it points somewhere else)
overrides single values, e.g. a different contact tolerance for one plant.
- Read by `kreisel_junctions`, `tro_flow`, `tro_ids`, `material_flow`,
`dxf_registration`, `tro_annotate` and `e_planning` (fallback for
`toleranz_mm` in `e_planning.ini`). `tests/test_parameter.py` checks that
every key is read by its module.
- Not in the INI, on purpose: format constants (`KX_TRANS_FAKTOR`, the coding
of the CSV columns K1–K4), mathematical epsilons, and style tables
(colours, fonts, `COORD_SYMBOL_SIZE`, symbol proportions in the drawing
code).
Changing a value that drives the derivation (a tolerance or threshold) can
change TRO types and IDs — compare the outputs of all plants afterwards.
## 8. `gen_tro_graphs.py` — documentation image generator (doc tooling, not part of the generator)
Lives under `doc/TRO_Katalog/tro_graphs/`, not `lib/` — it produces the
+11 -12
View File
@@ -58,6 +58,8 @@ from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from parameter import get_float, get_int
__all__ = [
"Transform",
"RegistrationError",
@@ -84,18 +86,15 @@ ANCHOR_BLOCKS: dict[str, tuple[str, ...]] = {
"Scanner": ("SCAN", "Scanner_*"),
}
# Rastergroesse der Abstimmung in mm
VOTE_GRID = 100.0
# So viele Kandidaten der Abstimmung werden ausgeglichen und bewertet
VOTE_CANDIDATES = 6
# Nur Paare bis zu diesem Abstand gehen in den Ausgleich ein (exakte Treffer)
FIT_TOL = 50.0
# Punkte gelten als zugeordnet, wenn sie nach der Verschiebung so nah liegen
DEFAULT_MATCH_TOL = 600.0
# So viele exakte Treffer muessen es mindestens sein
MIN_INLIERS = 5
# Ab dieser Drehung wird gewarnt (Grad)
ROTATION_WARN_DEG = 0.05
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [dxf_registration] - Bedeutung
# und Einheit stehen dort (lib/parameter.py).
_P = "dxf_registration"
VOTE_GRID = get_float(_P, "vote_grid")
VOTE_CANDIDATES = get_int(_P, "vote_candidates")
FIT_TOL = get_float(_P, "fit_tol")
DEFAULT_MATCH_TOL = get_float(_P, "default_match_tol")
MIN_INLIERS = get_int(_P, "min_inliers")
ROTATION_WARN_DEG = get_float(_P, "rotation_warn_deg")
class RegistrationError(RuntimeError):
+7 -2
View File
@@ -60,6 +60,7 @@ from dxf_registration import (
save_transform,
verify_transform,
)
from parameter import get_float
__all__ = [
"EPLAN_SCHEMA",
@@ -78,6 +79,10 @@ __all__ = [
EPLAN_SCHEMA = "sps_skel-eplan-v1"
RULES_FILE = "e_planning.ini"
# Zulaessige Abweichung vom Sollabstand, wenn eine Regel kein toleranz_mm
# angibt - aus cfg/parameter.ini [e_planning] (lib/parameter.py).
DEFAULT_TOLERANCE_MM = get_float("e_planning", "toleranz_mm")
_CONFIG = ConfigDict(extra="forbid")
# "=A1+UH01-KF107" -> Ort/Steuerung "UH01", Geraet "KF107"
@@ -153,7 +158,7 @@ class Rule:
# Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil.
# Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand.
target_mm: float | None = None
tolerance_mm: float = 150.0
tolerance_mm: float = DEFAULT_TOLERANCE_MM
def score(self, dist: float) -> float | None:
"""Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig."""
@@ -304,7 +309,7 @@ def load_rules(path: Path) -> tuple[list[Rule], list[str]]:
number_offset=int(s.get("nummer_versatz", "0")),
prefix=s.get("praefix", "").strip(),
target_mm=float(soll) if soll else None,
tolerance_mm=float(s.get("toleranz_mm", "150")),
tolerance_mm=float(s.get("toleranz_mm", str(DEFAULT_TOLERANCE_MM))),
))
known = {r.role for r in rules}
for r in rules:
+391
View File
@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
"""
kreisel_junctions.py - Weichenknoten zwischen Kreiseln aus der Geometrie.
Zwei Kreisel, die sich beruehren, bilden eine Weichenstelle: am Beruehrpunkt
laufen beide Umlaeufe in dieselbe Richtung, ein Traeger kann dort von einem
Kreisel auf den anderen wechseln. dxfmakros exportiert solche Stellen als
"ILS Weiche" (Merkmal "Erzeugt aus: Kreisel-Beruehrpunkt") - aber nicht
immer: in data/export.csv fehlen die Weichen zwischen Kreisel 9/8, 2/3 und
3/4, obwohl sich die Kreisel dort beruehren. Darum wird hier jede Weiche aus
der Kreisel-Geometrie bestimmt und eine exportierte ILS Weiche nur noch ihrem
Beruehrpunkt zugeordnet (Id und Name).
Geometrie
---------
Ein Kreisel ist eine Stadion-Form: zwei Umlenkungen (Halbkreise vom Radius
r) im Abstand L, verbunden durch die beiden geraden Bahnen. Mittelpunkt ist
die CSV-Position, die Laengsachse zeigt in Richtung "Drehung". r und L kommen
aus der Boundingbox (kurze Seite = 2r, lange Seite = L + 2r), bei schraeger
Lage aus dem Merkmal "Abstand (Kreiselachse A - Kreiselachse) in Meter".
Jeder Punkt auf dem Umlauf hat eine Laufposition s (mm) in Drehrichtung
("Drehrichtung" UZS/GUZ) - damit ist "stromauf"/"stromab" definiert.
Weichenknoten und steuernde Separatoren
---------------------------------------
Beruehrpunkte, die auf derselben Umlenkung eines Kreisels liegen, bilden
EINEN Weichenknoten (Kreisel 5 beruehrt mit seiner Umlenkung Kreisel 6 und
Kreisel 4 - zwei Weichen, eine Uebergabestelle). Ein Separator steuert den
Knoten, wenn er auf einem der beteiligten Kreisel unmittelbar stromauf vor
dem Knoten liegt: vom Knoten gegen die Drehrichtung zurueckgegangen, kommt
zuerst dieser Separator und keine andere Weiche. Liegt dazwischen eine
fremde Weiche, steuert der Separator diese, nicht den Knoten.
Aus der Anzahl steuernder Separatoren und Weichen ergibt sich der TRO-Typ
(tro_flow.derive_tros, Schritt 3c): 1Sep1Swi, 2Sep1Swi, 2Sep2Swi, 3Sep2Swi.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from material_flow import (
LANE_LEFT,
LANE_RIGHT,
ROTATION_CCW,
ROTATION_CW,
Element,
_as_float,
_merkmal,
)
from parameter import get_float, get_int
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [kreisel_junctions] - Bedeutung
# und Einheit stehen dort (lib/parameter.py).
_P = "kreisel_junctions"
CONTACT_TOLERANCE = get_float(_P, "contact_tolerance")
SEPARATOR_TOLERANCE = get_float(_P, "separator_tolerance")
WEICHE_TOLERANCE = get_float(_P, "weiche_tolerance")
SAMPLE_STEP = get_float(_P, "sample_step")
MIN_SAMPLES = get_int(_P, "min_samples")
CONTACT_TIE = get_float(_P, "contact_tie")
MAX_CONTACT_LENGTH = get_float(_P, "max_contact_length")
END_TOLERANCE = get_float(_P, "end_tolerance")
DEFAULT_RADIUS = get_float(_P, "default_radius")
DIRECTION_STEP = get_float(_P, "direction_step")
GLEICHLAUF_MIN_COS = get_float(_P, "gleichlauf_min_cos")
# Abschnitte eines Umlaufs: die beiden Umlenkungen und die beiden Geraden.
END_PLUS = "+" # Umlenkung in Achsrichtung
END_MINUS = "-" # Umlenkung gegen die Achsrichtung
STRAIGHT = "gerade"
@dataclass
class Loop:
"""Umlauf eines Kreisels als Stadion-Form mit Laufrichtung.
Lokale Koordinaten: a entlang der Achse ("Drehung"), b quer dazu (links
positiv). Die Umlenkungen liegen bei a = +half und a = -half.
"""
kreisel: Element
cx: float
cy: float
ux: float # Achsrichtung (Einheitsvektor)
uy: float
half: float # halber Umlenkungsabstand L/2 (mm)
radius: float # Bahnabstand / 2 (mm)
clockwise: bool # True = UZS
@property
def length(self) -> float:
"""Umfang des Umlaufs (mm)."""
return 4 * self.half + 2 * math.pi * self.radius
def _local(self, x: float, y: float) -> tuple[float, float]:
"""Weltpunkt -> lokale Koordinaten (a, b)."""
dx, dy = x - self.cx, y - self.cy
return dx * self.ux + dy * self.uy, -dx * self.uy + dy * self.ux
def lane(self, x: float, y: float) -> str:
"""Bahn eines Punkts: LANE_LEFT links der Achse, sonst LANE_RIGHT
(dieselbe Konvention wie tro_flow._kreisel_bahn)."""
_a, b = self._local(x, y)
return LANE_LEFT if b > 0 else LANE_RIGHT
def project(self, x: float, y: float) -> tuple[float, float, str]:
"""Naechster Punkt auf dem Umlauf.
Parameter:
x, y: Weltpunkt (mm).
Rueckgabe:
(s, Abstand, Abschnitt): s = Laufposition in Drehrichtung
(0 <= s < length), Abstand des Punkts vom Umlauf (mm), Abschnitt
END_PLUS, END_MINUS oder STRAIGHT.
"""
a, b = self._local(x, y)
h, r = self.half, self.radius
cands: list[tuple[float, float, str]] = []
# Gegen den Uhrzeigersinn (lokal): untere Gerade (b=-r) in +a, rechte
# Umlenkung, obere Gerade (b=+r) in -a, linke Umlenkung.
if -h <= a <= h:
cands.append((abs(b + r), a + h, STRAIGHT))
cands.append((abs(b - r), 2 * h + math.pi * r + (h - a), STRAIGHT))
if a >= h:
theta = math.atan2(b, a - h) # -pi/2 .. pi/2
cands.append((abs(math.hypot(a - h, b) - r),
2 * h + r * (theta + math.pi / 2), END_PLUS))
if a <= -h:
phi = math.atan2(b, a + h) % (2 * math.pi) # pi/2 .. 3pi/2
cands.append((abs(math.hypot(a + h, b) - r),
4 * h + math.pi * r + r * (phi - math.pi / 2), END_MINUS))
if not cands: # Innenpunkt nahe Achse
cands.append((abs(b + r), a + h, STRAIGHT))
dist, s_ccw, section = min(cands)
if section == STRAIGHT and a >= h - END_TOLERANCE:
section = END_PLUS
elif section == STRAIGHT and a <= -h + END_TOLERANCE:
section = END_MINUS
s = (self.length - s_ccw) % self.length if self.clockwise else s_ccw % self.length
return s, dist, section
def point(self, s: float) -> tuple[float, float]:
"""Weltpunkt zur Laufposition s (Umkehrung von project)."""
s_ccw = (self.length - s) % self.length if self.clockwise else s % self.length
h, r = self.half, self.radius
if s_ccw < 2 * h:
a, b = s_ccw - h, -r
elif s_ccw < 2 * h + math.pi * r:
theta = (s_ccw - 2 * h) / r - math.pi / 2
a, b = h + r * math.cos(theta), r * math.sin(theta)
elif s_ccw < 4 * h + math.pi * r:
a, b = h - (s_ccw - 2 * h - math.pi * r), r
else:
phi = (s_ccw - 4 * h - math.pi * r) / r + math.pi / 2
a, b = -h + r * math.cos(phi), r * math.sin(phi)
return (self.cx + a * self.ux - b * self.uy, self.cy + a * self.uy + b * self.ux)
def direction(self, s: float) -> tuple[float, float]:
"""Laufrichtung (Einheitsvektor) an der Laufposition s."""
x0, y0 = self.point(s - DIRECTION_STEP)
x1, y1 = self.point(s + DIRECTION_STEP)
n = math.hypot(x1 - x0, y1 - y0) or 1.0
return (x1 - x0) / n, (y1 - y0) / n
def kreisel_loop(kreisel: Element) -> Loop | None:
"""Umlauf eines Kreisels aus Position, Drehung, Boundingbox, Drehrichtung.
Parameter:
kreisel: Element mit kind "Kreisel".
Rueckgabe:
Loop oder None, wenn Position, Drehrichtung oder Abmessungen fehlen.
"""
if kreisel.x is None or kreisel.y is None:
return None
if kreisel.rotation not in (ROTATION_CW, ROTATION_CCW):
return None
drehung = _as_float(_merkmal(kreisel.merkmale, "Drehung")) or 0.0
rad = math.radians(drehung)
bw, bh = kreisel.bbox_x, kreisel.bbox_y
if bw and bh and abs(drehung) % 90 < 1e-6:
short, long_ = min(bw, bh), max(bw, bh)
radius, half = short / 2, (long_ - short) / 2
else:
abstand = _as_float(_merkmal(
kreisel.merkmale, "Abstand (Kreiselachse A - Kreiselachse) in Meter"))
if abstand is None:
return None
radius, half = DEFAULT_RADIUS, abstand * 1000 / 2
return Loop(kreisel, kreisel.x, kreisel.y, math.cos(rad), math.sin(rad),
half, radius, kreisel.rotation == ROTATION_CW)
@dataclass
class Contact:
"""Beruehrpunkt zweier Kreisel = eine Weiche."""
weiche_id: str # TeileId der ILS Weiche oder "W<a>_<b>"
kreisel: tuple[str, str] # beide Kreisel-Ids, sortiert
x: float
y: float
exported: bool # True = als ILS Weiche im CSV
s: dict[str, float] = field(default_factory=dict) # Kreisel -> s
section: dict[str, str] = field(default_factory=dict) # Kreisel -> Abschnitt
@dataclass
class Junction:
"""Weichenknoten: Beruehrpunkte auf derselben Umlenkung, plus Steuerung."""
contacts: list[Contact]
kreisel: list[str] # beteiligte Kreisel
separators: list[str] = field(default_factory=list) # steuernde Separatoren
sep_kreisel: dict[str, str] = field(default_factory=dict) # Separator -> Kreisel
sep_lane: dict[str, str] = field(default_factory=dict) # Separator -> "<Id>-L/R"
@property
def type_name(self) -> str:
"""TRO-Typ aus Separator- und Weichenzahl, z. B. "2Sep1Swi"."""
return f"{len(self.separators)}Sep{len(self.contacts)}Swi"
def _find_contact(a: Loop, b: Loop) -> tuple[float, float, float] | None:
"""Beruehrpunkt (x, y, Laenge des Beruehrbereichs) oder None."""
steps = max(int(a.length / SAMPLE_STEP), MIN_SAMPLES)
near: list[tuple[float, float, float]] = []
for i in range(steps):
x, y = a.point(i * a.length / steps)
_s, dist, _sec = b.project(x, y)
if dist <= CONTACT_TOLERANCE:
near.append((dist, x, y))
if not near:
return None
# Bei tangentialer Beruehrung sind viele Abtastpunkte fast gleich nah -
# der Mittelpunkt der naechsten liegt am eigentlichen Beruehrpunkt.
best = min(d for d, _x, _y in near)
mitte = [(x, y) for d, x, y in near if d <= best + CONTACT_TIE]
x = sum(p[0] for p in mitte) / len(mitte)
y = sum(p[1] for p in mitte) / len(mitte)
return x, y, len(near) * a.length / steps
def find_contacts(elements: list[Element], findings: list[str]) -> tuple[dict[str, Loop], list[Contact]]:
"""Alle Beruehrpunkte zwischen Kreiseln, mit Zuordnung der ILS Weichen.
Parameter:
elements: alle Elemente des CSV-Exports (Kreisel, ILS Weichen).
findings: Fundliste; Hinweise zu fehlenden/gegenlaeufigen Weichen.
Rueckgabe:
(Kreisel-Id -> Loop, Beruehrpunkte). Ein Beruehrpunkt ohne
exportierte ILS Weiche bekommt die Id "W<KreiselA>_<KreiselB>".
"""
loops = {e.teile_id: lp for e in elements if e.kind == "Kreisel"
and (lp := kreisel_loop(e)) is not None}
weichen = [e for e in elements if e.kind == "Weiche"]
ids = sorted(loops)
contacts: list[Contact] = []
for i, ka in enumerate(ids):
for kb in ids[i + 1:]:
hit = _find_contact(loops[ka], loops[kb])
if hit is None:
continue
x, y, laenge = hit
if laenge > MAX_CONTACT_LENGTH:
findings.append(
f"Kreisel {ka}/{kb}: beruehren sich auf {laenge:.0f}mm Laenge "
f"(parallele Bahnen?) - keine Weiche angenommen")
continue
c = Contact("", (ka, kb), x, y, False)
for k in (ka, kb):
c.s[k], _d, c.section[k] = loops[k].project(x, y)
da = loops[ka].direction(c.s[ka])
db = loops[kb].direction(c.s[kb])
if da[0] * db[0] + da[1] * db[1] < GLEICHLAUF_MIN_COS:
findings.append(
f"Kreisel {ka}/{kb}: Beruehrpunkt bei {x:.0f}, {y:.0f} laeuft "
f"gegenlaeufig (Drehrichtung pruefen) - kein Weichenknoten")
continue
contacts.append(c)
for c in contacts:
passend = [w for w in weichen if w.x is not None and w.y is not None
and math.hypot(w.x - c.x, w.y - c.y) <= WEICHE_TOLERANCE]
if passend:
w = min(passend, key=lambda w: math.hypot(w.x - c.x, w.y - c.y))
c.weiche_id, c.exported = w.teile_id, True
else:
c.weiche_id = f"W{c.kreisel[0]}_{c.kreisel[1]}"
findings.append(
f"Kreisel {c.kreisel[0]}/{c.kreisel[1]}: Beruehrpunkt bei "
f"{c.x:.0f}, {c.y:.0f} ohne ILS Weiche im Export - als Weiche "
f"{c.weiche_id} angenommen")
# ILS Weichen ohne passenden Beruehrpunkt (z. B. an gegenlaeufigen
# Kreiseln) bleiben unberuehrt - sie wertet tro_flow wie bisher ueber ihre
# CSV-Nachbarn aus (_find_weiche_separators).
return loops, contacts
def _group(contacts: list[Contact]) -> list[list[Contact]]:
"""Beruehrpunkte auf derselben Umlenkung eines Kreisels zusammenfassen."""
parent = list(range(len(contacts)))
def root(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
seen: dict[tuple[str, str], int] = {}
for i, c in enumerate(contacts):
for k, section in c.section.items():
if section == STRAIGHT:
continue
j = seen.setdefault((k, section), i)
parent[root(i)] = root(j)
groups: dict[int, list[Contact]] = {}
for i, c in enumerate(contacts):
groups.setdefault(root(i), []).append(c)
return list(groups.values())
def find_junctions(
elements: list[Element],
findings: list[str],
separators: list[Element] | None = None,
) -> list[Junction]:
"""Weichenknoten zwischen Kreiseln mit ihren steuernden Separatoren.
Parameter:
elements: alle Elemente des CSV-Exports.
findings: Fundliste (siehe find_contacts).
separators: Separatoren, die steuern duerfen; None = alle.
Rueckgabe:
Weichenknoten, sortiert nach erster Weichen-Id. Knoten ohne
steuernden Separator sind enthalten (separators leer) - der Aufrufer
entscheidet, was damit geschieht.
"""
loops, contacts = find_contacts(elements, findings)
if separators is None:
separators = [e for e in elements if e.kind == "Separator"]
# Separator -> (Kreisel, s): auf dem naechstgelegenen Umlauf
sep_pos: dict[str, tuple[str, float]] = {}
sep_lane: dict[str, str] = {}
for sep in separators:
if sep.x is None or sep.y is None:
continue
best: tuple[float, str, float] | None = None
for k, lp in loops.items():
s, dist, _sec = lp.project(sep.x, sep.y)
if dist <= SEPARATOR_TOLERANCE and (best is None or dist < best[0]):
best = (dist, k, s)
if best is not None:
sep_pos[sep.teile_id] = (best[1], best[2])
sep_lane[sep.teile_id] = f"{best[1]}-{loops[best[1]].lane(sep.x, sep.y)}"
junctions: list[Junction] = []
for group in _group(contacts):
kreisel = sorted({k for c in group for k in c.kreisel})
j = Junction(sorted(group, key=lambda c: c.weiche_id), kreisel)
in_group = {id(c) for c in group}
for k in kreisel:
# Ereignisse auf Kreisel k: (s, Art, Id); Art "sep" oder "swi"
events = [(s, "sep", sid) for sid, (kk, s) in sep_pos.items() if kk == k]
events += [(c.s[k], "own" if id(c) in in_group else "swi", c.weiche_id)
for c in contacts if k in c.s]
events.sort()
n = len(events)
for idx, (_s, art, _id) in enumerate(events):
if art != "own":
continue
# gegen die Laufrichtung zurueck bis zum ersten fremden Ereignis
for step in range(1, n):
_ps, part, pid = events[(idx - step) % n]
if part == "own":
continue
if part == "sep" and pid not in j.separators:
j.separators.append(pid)
j.sep_kreisel[pid] = k
j.sep_lane[pid] = sep_lane[pid]
break
j.separators.sort()
junctions.append(j)
junctions.sort(key=lambda j: j.contacts[0].weiche_id)
return junctions
+178 -37
View File
@@ -79,6 +79,8 @@ from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from parameter import get_float, get_int
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
@@ -129,10 +131,10 @@ STATION_PATTERNS = (
("Ausgang", ("ausgang",)),
)
# Max. Abstand (mm), ab dem ein generisches Eingang/Ausgang-Symbol als
# "deckungsgleich" mit einer BTMT-Station gilt und verschluckt wird. Die
# beobachteten Paare liegen bei 256-431 mm, echte getrennte Stationen >1000 mm.
STATION_MERGE_MM = 600.0
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [material_flow] - Bedeutung
# und Einheit stehen dort (lib/parameter.py).
_P = "material_flow"
STATION_MERGE_MM = get_float(_P, "station_merge_mm")
# Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
@@ -199,11 +201,8 @@ NODE_STYLES = {
}
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
# Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei
# Kreisel-Bahnen (gleicher Zulauf, gleicher Ablauf, gleiche Steigung) werden
# sie im Materialfluss-Diagramm zu einem Block zusammengefasst - sonst wird
# eine grosse Linienbank (Speicher) unlesbar breit.
MIN_GEFAELLE_GROUP = 2
# Parallele Gefaellestrecken je Block im Materialfluss-Diagramm (parameter.ini)
MIN_GEFAELLE_GROUP = get_int(_P, "min_gefaelle_group")
# ---------------------------------------------------------------------------
@@ -1893,13 +1892,11 @@ def _node_label(node: Node) -> str:
return _dot_label(lines)
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. 0.25 pt/mm (1:4) - wie in tro_flow.py, damit Material- und TRO-Graph
# im selben Massstab lagerichtig sind.
COORD_SCALE = 0.25
# Die beiden Bahnen (L/R) eines Kreisels teilen sich eine Position; im
# lagerichtigen Layout leicht versetzen, damit sie nicht uebereinander liegen.
LANE_NUDGE_MM = 350.0
# Lagerichtiges Layout (--use-cords): Massstab, Bahnversatz, Legendenabstand
# (parameter.ini [material_flow])
COORD_SCALE = get_float(_P, "coord_scale")
LANE_NUDGE_MM = get_float(_P, "lane_nudge_mm")
LEGEND_OFFSET_MM = get_float(_P, "legend_offset_mm")
def _node_xy(node: "Node") -> tuple[float, float] | None:
@@ -2110,7 +2107,7 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
xs = [xy[0] for n in graph.nodes.values() if (xy := _node_xy(n))]
ys = [xy[1] for n in graph.nodes.values() if (xy := _node_xy(n))]
if xs and ys:
lx, ly = min(xs), min(ys) - 3000.0 # etwas unterhalb der Anlage
lx, ly = min(xs), min(ys) - LEGEND_OFFSET_MM # unterhalb der Anlage
legend_pos = (f' pos="{lx * COORD_SCALE:.2f},'
f'{ly * COORD_SCALE:.2f}",')
@@ -2198,10 +2195,157 @@ def _track_node_html_label(node: TrackNode) -> str:
)
def render_track_dot(track: TrackGraph) -> str:
# Container-Arten fuer die Zugehoerigkeit im Mouseover (--track)
CONTAINER_TEXT = {"Kreisel": "Kreisel", "Strecke": "VF", "Gefaellestrecke": "GF"}
@dataclass
class _TooltipContext:
"""Nachschlagedaten fuer die Mouseover-Texte des --track-Graphen."""
by_id: dict[str, Element]
containers: dict[str, list[Element]] # Element-Id -> Container mit ihm in TrackIds
loops: dict[str, object] # Kreisel-Id -> kreisel_junctions.Loop
contacts: dict[str, tuple[str, str]] # Weichen-Id -> beruehrende Kreisel (Geometrie)
def _tooltip_context(elements: list[Element]) -> _TooltipContext:
"""Container-Mitgliedschaft und Kreisel-Geometrie fuer die Mouseover-Texte.
kreisel_junctions baut auf diesem Modul auf; der Import steht deshalb hier
in der Funktion (beim Aufruf ist material_flow laengst geladen).
"""
from kreisel_junctions import find_contacts
by_id = {e.teile_id: e for e in elements}
containers: dict[str, list[Element]] = {}
for c in elements:
if c.kind in CONTAINER_TEXT:
for tid in c.track_ids:
containers.setdefault(tid, []).append(c)
loops, contacts = find_contacts(elements, [])
return _TooltipContext(by_id, containers, loops,
{c.weiche_id: c.kreisel for c in contacts})
def _container_text(ctx: _TooltipContext, ref: str) -> str:
"""'0009-L' -> 'Kreisel Kreisel9 (0009) Bahn L', '0014' -> 'VF VF_2 (0014)'."""
base, _sep, lane = ref.partition("-")
el = ctx.by_id.get(base)
if el is None:
return f"{ref} (unbekannt)"
art = CONTAINER_TEXT.get(el.kind, el.kind)
name = el.name if el.name.lower().startswith(art.lower()) else f"{art} {el.name}"
text = f"{name} ({base})"
return f"{text} Bahn {lane}" if lane else text
def _geometry_text(ctx: _TooltipContext, el: Element, station: bool = False
) -> tuple[str, str | None]:
"""Lage auf einem Kreisel aus der Geometrie.
Parameter:
ctx: Nachschlagedaten (_tooltip_context).
el: das Bauteil.
station: True fuer Be-/Entladung - sie steht neben dem Umlauf, darum
zaehlt dort der naechste Kreisel unabhaengig vom Abstand.
Rueckgabe:
(Text, Kreisel-Id oder None, wenn das Bauteil auf keinem Kreisel liegt).
"""
if el.x is None or el.y is None or not ctx.loops:
return "Geometrie: keine Position", None
dist, k = min((lp.project(el.x, el.y)[1], k) for k, lp in ctx.loops.items())
kreisel = _container_text(ctx, k)
if station:
return f"Geometrie: naechster Kreisel {kreisel}, {dist:.0f}mm entfernt", k
from kreisel_junctions import SEPARATOR_TOLERANCE
if dist <= SEPARATOR_TOLERANCE:
return f"Geometrie: liegt auf {kreisel} Bahn {ctx.loops[k].lane(el.x, el.y)}", k
return f"Geometrie: auf keinem Kreisel, naechster {kreisel} {dist:.0f}mm", None
def _track_node_tooltip(node: TrackNode, ctx: _TooltipContext | None) -> str:
"""Mouseover-Text eines Knotens: Zugehoerigkeit zu Kreisel/VF/GF.
Separator: Zuordnung (CSV), TrackIds-Kette(n), Lage laut Geometrie
Weiche: die beiden erzeugenden Kreisel (Merkmale Kreisel A/B),
Beruehrart, TrackIds-Kette(n), Beruehrpunkt laut Geometrie
Be-/Entladung: Kreisel, auf dessen Umlauf sie liegt (TrackIds), Geometrie
Widersprechen sich die Quellen, kommt eine Zeile mit "!" dazu.
"""
el = node.element
if el is None:
return _track_node_label(node)
lines = [f"{el.teile_art} {el.teile_id} '{el.name}' (Zeile {el.row})"]
if ctx is None:
return _dot_label(lines)
ketten = ctx.containers.get(el.teile_id, [])
ketten_ids = {c.teile_id for c in ketten}
ketten_text = ", ".join(_container_text(ctx, c.teile_id) for c in ketten) or "-"
warn: list[str] = []
if node.kind == "Weiche":
a = str(_merkmal(el.merkmale, "Kreisel A") or "")
b = str(_merkmal(el.merkmale, "Kreisel B") or "")
lines.append(f"Kreisel A: {_container_text(ctx, a) if a else '-'}")
lines.append(f"Kreisel B: {_container_text(ctx, b) if b else '-'}")
for key in ("Beruehrung", "Achse von Kreisel", "Erzeugt aus"):
if (v := _merkmal(el.merkmale, key)) is not None:
lines.append(f"{key}: {v}")
lines.append(f"TrackIds-Kette: {ketten_text}")
geo = ctx.contacts.get(el.teile_id)
if geo is None:
lines.append("Geometrie: an keinem gleichlaufenden Kreisel-Beruehrpunkt")
warn.append("Weiche ohne stimmigen Beruehrpunkt (Drehrichtung pruefen)")
else:
lines.append(f"Geometrie: Beruehrpunkt Kreisel {geo[0]} / {geo[1]}")
if a and b and set(geo) != {a, b}:
warn.append(f"Kreisel A/B ({a}/{b}) passen nicht zur Geometrie")
else:
zuordnung = el.assignment
if zuordnung and zuordnung != UNASSIGNED:
lines.append(f"Zuordnung: {_container_text(ctx, zuordnung)}")
elif node.kind == "Separator":
lines.append("Zuordnung: -")
lines.append(f"TrackIds-Kette: {ketten_text}")
station = node.kind in ("Beladung", "Entladung")
geo_text, geo_k = _geometry_text(ctx, el, station=station)
lines.append(geo_text)
zu_base = zuordnung.partition("-")[0] if zuordnung else ""
zu_el = ctx.by_id.get(zu_base)
if zu_el is not None and ketten_ids and zu_base not in ketten_ids:
warn.append("Zuordnung und TrackIds-Kette widersprechen sich")
if zu_el is not None and zu_el.kind == "Kreisel" and geo_k and geo_k != zu_base:
warn.append(f"Zuordnung Kreisel {zu_base}, liegt aber auf Kreisel {geo_k}")
kreisel_ketten = {c.teile_id for c in ketten if c.kind == "Kreisel"}
if station and geo_k and kreisel_ketten and geo_k not in kreisel_ketten:
warn.append(f"TrackIds-Kette nennt {', '.join(sorted(kreisel_ketten))}, "
f"naechster Kreisel ist aber {geo_k}")
if not ketten and el.kind not in CONTAINER_TEXT:
warn.append("in keiner TrackIds-Kette")
if (geo_k is None and not station
and not (zu_el is not None and zu_el.kind == "Kreisel")):
# Bauteil einer VF/GF: der naechste Kreisel ist dort ohne Belang
lines[-1] = "Geometrie: liegt auf keinem Kreisel"
lines.extend(f"! {w}" for w in warn)
return _dot_label(lines)
def render_track_dot(track: TrackGraph, elements: list[Element] | None = None) -> str:
"""DOT des Bahnzusammenhang-Graphen (--track); siehe build_track_graph()
und doc/track.md. Gerendert ueber dieselben graph_to_svg/dot_to_svg wie
render_dot()."""
render_dot().
Parameter:
track: der Bahnzusammenhang-Graph.
elements: alle Elemente des CSV-Exports; damit bekommt jeder Knoten
einen Mouseover-Text (tooltip, im SVG als Hover-Titel) mit seiner
Zugehoerigkeit zu Kreisel/VF/GF (siehe _track_node_tooltip).
None = nur die Bezeichnung.
"""
ctx = _tooltip_context(elements) if elements else None
out: list[str] = []
add = out.append
@@ -2228,7 +2372,8 @@ def render_track_dot(track: TrackGraph) -> str:
label_attr = f"label={_track_node_html_label(node)}"
else:
label_attr = f'label="{_track_node_label(node)}"'
add(f' "{node_id}" [{label_attr}, {attrs}{extra}];')
tooltip = f', tooltip="{_track_node_tooltip(node, ctx)}"'
add(f' "{node_id}" [{label_attr}, {attrs}{extra}{tooltip}];')
add("")
for edge in track.edges:
@@ -2411,18 +2556,14 @@ BBS_DEFAULT_STYLE = dict(fill="#f2f2f2", stroke="#808080", text="#404040")
BBS_DETAIL_KINDS = ("Bogen", "Streckenelement", "Motorstation", "Umlenkstation")
BBS_MODES = ("flow", "all")
# Legende (--show-bbs): Kaestchen- und Schriftgroesse in mm.
BBS_LEGEND_BOX_MM = 250.0
BBS_LEGEND_FONT_MM = 180.0
# Rand um die Anlage herum und minimale Schriftgroesse, jeweils in mm.
BBS_MARGIN_MM = 500.0
BBS_MIN_FONT_MM = 40.0
# Darstellung der Anschlusspunkte K1-K4 (--show-kx): Groesse des Kreuzes und
# der Beschriftung in mm, Verbindungslinie K1->K2->K3->K4 in Blau.
KX_CROSS_MM = 120.0
KX_FONT_MM = 90.0
# Groessen fuer --show-bbs/--show-kx (parameter.ini [material_flow]); die
# Verbindungslinie K1->K2->K3->K4 ist blau.
BBS_LEGEND_BOX_MM = get_float(_P, "bbs_legend_box_mm")
BBS_LEGEND_FONT_MM = get_float(_P, "bbs_legend_font_mm")
BBS_MARGIN_MM = get_float(_P, "bbs_margin_mm")
BBS_MIN_FONT_MM = get_float(_P, "bbs_min_font_mm")
KX_CROSS_MM = get_float(_P, "kx_cross_mm")
KX_FONT_MM = get_float(_P, "kx_font_mm")
KX_CROSS_COLOR = "#c00000"
KX_LINK_COLOR = "#1f5fd0"
@@ -2708,10 +2849,10 @@ def _container_children(element: Element, by_id: dict[str, Element]) -> list[Ele
# Keine Anbauteile (Separator, Scanner, Weiche, ...) und keine Ein-/
# Ausgabestationen - die kommen in einem spaeteren Schritt dazu.
CHAIN_MAX_W = 1400.0
CHAIN_MAX_H = 850.0
CHAIN_MARGIN = 70.0
CHAIN_TITLE_H = 60.0
CHAIN_MAX_W = get_float(_P, "chain_max_w") # Groessen: parameter.ini
CHAIN_MAX_H = get_float(_P, "chain_max_h")
CHAIN_MARGIN = get_float(_P, "chain_margin")
CHAIN_TITLE_H = get_float(_P, "chain_title_h")
CHAIN_EDGE_COLOR = {
EDGE_FLOW: "#2f5597",
@@ -3793,7 +3934,7 @@ def main(argv: list[str] | None = None) -> int:
track = build_track_graph(elements, by_id, graph, warnings)
if args.track:
track_dot_target = results / f"{stem}_track.dot"
track_dot_target.write_text(render_track_dot(track), encoding="utf-8")
track_dot_target.write_text(render_track_dot(track, elements), encoding="utf-8")
track_dot_file = track_dot_target
if args.tosvg:
track_svg_target = results / f"{stem}_track.svg"
+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
parameter.py - Zahlenwerte der Bibliotheken aus cfg/parameter.ini.
Toleranzen, Schwellen, Massstaebe und Zeichnungsgroessen stehen nicht im Code,
sondern in einer INI-Datei: ein Abschnitt je Modul, der Schluessel ist der
Name der Konstanten in Kleinbuchstaben ([kreisel_junctions]
contact_tolerance -> kreisel_junctions.CONTACT_TOLERANCE).
Gelesen wird zuerst die Grunddatei des Repositorys (<Projekt>/cfg/
parameter.ini, versioniert, muss vollstaendig sein). Liegt in %SKEL_CFG% eine
weitere parameter.ini, ueberschreibt sie einzelne Werte - so kann eine Anlage
abweichende Toleranzen bekommen, ohne die Grunddatei zu aendern.
Fehlt die Grunddatei oder ein Schluessel, bricht der Import mit
ParameterError ab: einen stillen Ersatzwert im Code gibt es bewusst nicht,
sonst stuende die Zahl doch wieder im Code.
Unterste Schicht: importiert nichts aus lib/, damit jedes Modul es nutzen kann.
"""
from __future__ import annotations
import configparser
import os
from functools import lru_cache
from pathlib import Path
PARAMETER_FILE = "parameter.ini"
__all__ = ["PARAMETER_FILE", "ParameterError", "files", "get_float", "get_int"]
class ParameterError(RuntimeError):
"""Grunddatei fehlt oder ein Wert fehlt bzw. ist keine Zahl."""
def _base_file() -> Path:
"""Grunddatei im Repository: <Projekt>/cfg/parameter.ini."""
return Path(__file__).resolve().parent.parent / "cfg" / PARAMETER_FILE
def files() -> list[Path]:
"""Gelesene Dateien in Lesereihenfolge (Grunddatei, ggf. %SKEL_CFG%)."""
result = [_base_file()]
cfg = os.environ.get("SKEL_CFG")
if cfg:
override = Path(cfg) / PARAMETER_FILE
if override.is_file() and override.resolve() != result[0].resolve():
result.append(override)
return result
@lru_cache(maxsize=1)
def _parser() -> configparser.ConfigParser:
"""Alle Parameterdateien einlesen (einmal je Prozess)."""
base = _base_file()
if not base.is_file():
raise ParameterError(f"Parameterdatei fehlt: {base}")
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
for path in files():
with path.open(encoding="utf-8") as handle:
parser.read_file(handle, source=str(path))
return parser
def _raw(section: str, key: str) -> str:
"""Rohwert; ParameterError, wenn Abschnitt oder Schluessel fehlt."""
parser = _parser()
if not parser.has_option(section, key):
raise ParameterError(
f"[{section}] {key} fehlt in {', '.join(str(p) for p in files())}")
return parser.get(section, key)
def get_float(section: str, key: str) -> float:
"""Zahl aus [section] key.
Parameter:
section: Abschnitt, in der Regel der Modulname (z. B. "tro_flow").
key: Schluessel, der Konstantenname in Kleinbuchstaben.
Rueckgabe:
Der Wert als float.
"""
raw = _raw(section, key)
try:
return float(raw)
except ValueError:
raise ParameterError(f"[{section}] {key} = {raw!r} ist keine Zahl") from None
def get_int(section: str, key: str) -> int:
"""Ganze Zahl aus [section] key (Anzahlen, Schrittweiten)."""
raw = _raw(section, key)
try:
return int(raw)
except ValueError:
raise ParameterError(f"[{section}] {key} = {raw!r} ist keine ganze Zahl") from None
+6 -5
View File
@@ -108,6 +108,7 @@ from dxf_registration import (
from material_flow import build_graph, env_dir, read_elements, resolve_input
from tro_catalog import STYLES, TRO_CATALOG, TroSymbol, get_tro
from model import Analysis
from parameter import get_float
from tro_flow import (
BOUNDARY_ENTRY,
BOUNDARY_EXIT,
@@ -131,11 +132,11 @@ BLOCK_PREFIX = "TRO_SYM_"
# SSG_BLOCKEDIT in dxfmakros ihn ueber "CONNECTION_*" erkennt.
CONNECTION_BLOCK = "CONNECTION_ARROW"
# Zeichnungsgroessen in mm, wenn nicht anders angegeben
DEFAULT_MARKER = 300.0
DEFAULT_TEXT = 220.0
# Restfehler, ab dem der Lagebezug abgelehnt wird
DEFAULT_MAX_RESIDUAL = 600.0
# Zeichnungsgroessen und Restfehler-Grenze des Lagebezugs aus cfg/parameter.ini,
# Abschnitt [tro_annotate] (lib/parameter.py)
DEFAULT_MARKER = get_float("tro_annotate", "default_marker")
DEFAULT_TEXT = get_float("tro_annotate", "default_text")
DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual")
def hex_to_rgb(value: str) -> tuple[int, int, int]:
+11
View File
@@ -619,6 +619,17 @@ TRO_CATALOG: tuple[TroDefinition, ...] = (
TroItem.SWITCH: 2,
TroItem.SCANNER: 2,
}, STYLE_SWI, TroSymbol.DOUBLE_TRIANGLE_DOWN, "#f2a65a"),
# ACHTUNG - NICHT aus SCL belegt, wie 2Sep2Swi. Aufgenommen am 2026-10-14
# fuer die Testanlage data/export.csv: Weichenknoten, an dem die Umlenkung
# eines Kreisels zwei andere beruehrt (Kreisel 5 an 4 und 6) - drei
# Separatoren laufen in Laufrichtung auf die zwei Weichen zu (siehe
# lib/kreisel_junctions.py). Scanner je Separator analog zu 2Sep2Swi.
# FB_ILS_MTRO_3Sep2Swi existiert noch nicht in der ILSLib.
TroDefinition("3Sep2Swi", "FB_ILS_MTRO_3Sep2Swi", {
TroItem.SEPARATOR: 3,
TroItem.SWITCH: 2,
TroItem.SCANNER: 3,
}, STYLE_SWI, TroSymbol.DOUBLE_TRIANGLE_DOWN, "#d9822b"),
)
_BY_NAME: dict[str, TroDefinition] = {t.get_name(): t for t in TRO_CATALOG}
+415 -59
View File
@@ -78,6 +78,49 @@ Hinweis ausgegeben.
Zusaetzlich: hat ein Host 2 bzw. 3 abgehende Flusskanten, wird 1Sep zu
1Sep1Swi bzw. 1Sep2Swi aufgewertet (Weiche).
Wo im Code der Typ entsteht
---------------------------
Einstieg ist analyse_tros(); den TRO-Typ (DerivedTro.type_name) setzt aber
ausschliesslich derive_tros(), Schritt fuer Schritt:
Schritt Bauteil-Muster im CSV -> TRO-Typ
-----------------------------------------------------------------------
1 >= 2 Gefaellestrecken mit Separator, PinStore_Auto
gemeinsamer Zu-/Ablauf (_group_lanes,
_speicher_bloecke)
1 einzelne Gefaellestrecke mit Separator 1Sep
2 Separator in der TrackIds-Kette einer Vario / 1Sep
Strecke (alternierendes Muster, (abwechselnd)
_incoming_drop)
2 Separator mit Zuordnung auf eine Strecke, 1Sep
aber nicht in deren TrackIds-Kette
3 Separator auf einer Kreisel-Bahn 1Sep
3b Separator vor einem Ausschleuselement 1Sep1Swi
(_find_switch_separators, _weichen_*)
3b' derselbe Separator vor einem zweiten 1Sep2Swi
Ausschleuselement ohne Separator dazwischen
3c Weichenknoten zwischen Kreiseln: n Separa- 1Sep1Swi, 2Sep1Swi,
toren in Laufrichtung unmittelbar vor m 2Sep2Swi, 3Sep2Swi
Beruehrpunkten derselben Umlenkung
(_kreisel_knoten, lib/kreisel_junctions.py)
3c' Separator an einer ILS Weiche, die kein 1Sep1Swi
Weichenknoten aus 3c ist
(_find_weiche_separators)
3d 3b-TRO und 3c'-TRO direkt hintereinander 2Sep2Swi
in der TrackIds-Kette eines Kreisels
4 1Sep mit 2 bzw. 3 abgehenden Flusskanten 1Sep1Swi / 1Sep2Swi
Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...)
aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit
2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch
erkannter Typ wird in derive_tros() korrigiert, nicht per INI.
Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6),
EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige
Drehrichtung beider Kreisel: laufen sie am Beruehrpunkt gegeneinander
(Mubea, Dematic - vermutlich Exportfehler), bleibt es fuer diese ILS Weiche
bei 3c' und 3d.
Typen, FB-Bausteine, Bauteile und Farben kommen ausschliesslich aus
lib/tro_catalog.py.
@@ -108,6 +151,7 @@ import csv
import json
import math
import sys
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
@@ -123,6 +167,7 @@ from material_flow import (
UNASSIGNED,
Element,
Graph,
Node,
_as_float,
_dot_escape,
_dot_label,
@@ -133,7 +178,9 @@ from material_flow import (
read_elements,
resolve_input,
)
from kreisel_junctions import Junction, find_junctions
from model import Analysis, DerivedTro, TroDocument
from parameter import get_float, get_int
from tro_ids import (
DEFAULT_TOLERANCE,
TroIds,
@@ -159,42 +206,19 @@ from tro_catalog import (
# Konstanten
# ---------------------------------------------------------------------------
# Ab dieser Anzahl zusammenhaengender Schwerkraftlinien (Gefaellestrecke mit je
# einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
MIN_STORE_LANES = 2
# Mindest-Hoehenverlust (mm) auf dem der Reihe nach vorangehenden TrackIds-
# Segment, damit ein Separator auf einer "Strecke" trotzdem als 1Sep statt
# Vario gilt (siehe _incoming_drop). Muss nur "horizontal" gefuehrte, aber
# tatsaechlich Gefaelle-Segmente erwischen (HundM05 Separator 0035: -104mm auf
# 3,8m), nicht das Rauschen eines echten flachen VF-Laufs (HundM05 Separator
# 0036: +26mm auf 0,5m). 30mm liegt bewusst weit unter dem echten Befund und
# weit ueber dem Rauschen.
GEFAELLE_DROP_MM = 30.0
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [tro_flow] - Bedeutung und
# Einheit stehen dort (lib/parameter.py).
_P = "tro_flow"
MIN_STORE_LANES = get_int(_P, "min_store_lanes")
GEFAELLE_DROP_MM = get_float(_P, "gefaelle_drop_mm")
COORD_SCALE = get_float(_P, "coord_scale")
COORD_PFEIL_LAENGE = get_int(_P, "coord_pfeil_laenge")
COORD_MAX_LUECKE = get_int(_P, "coord_max_luecke")
LEGEND_MARGIN = get_int(_P, "legend_margin")
LEGEND_DROP = get_int(_P, "legend_drop")
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. Seit die Symbole eine feste Groesse haben (COORD_SYMBOL_SIZE)
# statt mit ihrem Label zu wachsen, reichen 0.1 pt/mm: ~11 x 20 m Anlage ergeben
# etwa 1100 x 2000 pt - bildschirmfuellend lesbar. Vorher 0.25 (riesige
# Zeichnung, in der Symbole und Schrift verschwanden; Review 2026-10-01).
COORD_SCALE = 0.1
# Zu-/Ablaufpfeil einer Station in der lagerichtigen Zeichnung: feste Laenge
# (mm) ab dem angehaengten TRO (siehe _station_positionen).
COORD_PFEIL_LAENGE = 1300
# Groesste Luecke (mm) zwischen zwei benachbarten Knotenkoordinaten je Achse in
# der lagerichtigen Zeichnung. Groessere Leerstrecken (lange Foerderer ohne
# TRO) werden auf diesen Wert gekuerzt - Reihenfolge und Nachbarschaft der
# Knoten bleiben erhalten, nur der Leerraum verschwindet (Review 2026-10-01:
# "zu verstreut, zu viel Weissraum"). --true-scale schaltet das ab.
COORD_MAX_LUECKE = 1500
# Abstand der Legende zum Anlagenrand in mm
LEGEND_MARGIN = 3000
# ---------------------------------------------------------------------------
# TRO-Ermittlung
@@ -207,6 +231,17 @@ def _group_lanes(lanes: list[str], graph: Graph) -> list[list[str]]:
Linien, die sich einen Zu- oder Ablaufknoten teilen, gehoeren zum selben
Speicher. So entstehen bei zwei getrennten Speichern auch zwei TROs.
Parameter:
lanes: Knoten-Ids der Gefaellestrecken, die einen Separator tragen
(derive_tros Schritt 1).
graph: Materialflussgraph aus material_flow.build_graph; nur
graph.edges wird gelesen (Nachbarschaft der Linien).
Rueckgabe:
Liste von Gruppen, jede eine sortierte Liste von Linien-Ids; die
Gruppen sind nach ihrer ersten Linie sortiert. Eine Gruppe mit
>= MIN_STORE_LANES Linien wird in derive_tros zu PinStore_Auto.
"""
adjacency: dict[str, set[str]] = {}
for lane in lanes:
@@ -246,6 +281,18 @@ def _kreisel_bahn(sep: Element, elements: list[Element], graph: Graph) -> str |
der Achsrichtung L, rechts R - dieselbe Konvention wie die Zuordnungen,
die der Export richtig setzt (0038 unterhalb von Kreisel2 mit Drehung 180
-> L, 0047 oberhalb von Kreisel3 -> R).
Parameter:
sep: der Separator (Element.kind "Separator").
elements: alle Elemente des CSV-Exports; durchsucht werden die
Kreisel und ihre track_ids.
graph: Materialflussgraph; die gefundene Bahn muss dort ein Knoten
sein (z. B. "0002-L").
Rueckgabe:
Knoten-Id der Bahn ("<Kreisel-Id>-L" bzw. "-R") oder None, wenn der
Separator in keiner Kreiselkette steht, Position bzw. "Drehung"
fehlen oder die Bahn kein Graphknoten ist.
"""
for k in elements:
if k.kind != "Kreisel" or sep.teile_id not in k.track_ids:
@@ -278,6 +325,16 @@ def _weichen_nach_umlauf(
Schluss mit dem Ausschleuselement 0057 von VF_1 "benachbart" (TRO03 als
Weiche in VF_1), 0046 liegt hinter dem letzten Ausschleuselement von
Speicherblock C. Beide sind gewoehnliche Kreisel-Separatoren (Schritt 3).
Parameter:
switch_hits: Ergebnis von _find_switch_separators
(Separator-Id -> (Ausschleuselement, Abstand mm)).
elements: alle Elemente des CSV-Exports (Kreisel mit track_ids).
findings: Fundliste; je verworfenem Paar wird ein Eintrag angehaengt.
Rueckgabe:
Gefiltertes switch_hits. Paare ausserhalb einer Kreiselkette bleiben
ungeprueft erhalten.
"""
by_id = {e.teile_id: e for e in elements}
kreisel = [e for e in elements if e.kind == "Kreisel" and e.track_ids]
@@ -324,6 +381,18 @@ def _weichen_aus_umlauf(
Ausschleuselemente einer Gefaellestrecke bleiben aussen vor: das sind
Speicherlinien, ihr Steuer-Separator entsteht in _speicher_bloecke nur
aus der CSV-Nachbarschaft (Mubea 0044 bleibt eigener 1Sep vor dem Block).
Parameter:
switch_hits: bisherige Paare (Separator-Id -> (Ausschleuselement,
Abstand mm)), meist aus _weichen_nach_umlauf.
kandidaten: Separatoren, die ueberhaupt Weiche sein duerfen (in
derive_tros: ohne die eigenen VF-Kettenseparatoren).
elements: alle Elemente des CSV-Exports.
findings: Fundliste; je ergaenztem Paar wird ein Eintrag angehaengt.
Rueckgabe:
Neues dict: switch_hits plus die ergaenzten Paare. Jedes
Ausschleuselement gehoert hoechstens einem Separator.
"""
by_id = {e.teile_id: e for e in elements}
erlaubt = {s.teile_id for s in kandidaten}
@@ -381,8 +450,16 @@ def _speicher_bloecke(
Mubea 0044 (TRO05) ist durch ein Einschleuselement vom Block getrennt,
kein Nachbar eines Ausschleuselements und bleibt deshalb eigener 1Sep.
Rueckgabe: (Linie -> Block, Steuer-Separator -> Block). Linien ohne
Ausschleuselement in einer Kreisel-Kette fehlen in der ersten Zuordnung.
Parameter:
lanes: Knoten-Ids der Gefaellestrecken mit Separator.
elements: alle Elemente des CSV-Exports (Ausschleuselemente mit
Merkmal "Strecke"/"Foerderer", Kreisel mit track_ids).
switch_hits: Weichen-Separatoren (Separator-Id -> (Ausschleuselement,
Abstand mm)), siehe _find_switch_separators.
Rueckgabe: (Linie -> Block, Steuer-Separator -> Block). Ein Block ist
(Kreisel-Id, laufende Nummer ab 1). Linien ohne Ausschleuselement in
einer Kreisel-Kette fehlen in der ersten Zuordnung.
"""
lane_set = set(lanes)
by_id = {e.teile_id: e for e in elements}
@@ -427,7 +504,17 @@ def _speicher_bloecke(
def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf)."""
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf).
Parameter:
hosts: Knoten-Ids, die zusammen den TRO tragen (DerivedTro.hosts).
graph: Materialflussgraph; gezaehlt werden nur Kanten der Art
EDGE_FLOW, die aus hosts hinaus fuehren.
Rueckgabe:
Sortierte, eindeutige Ziel-Knoten-Ids. 2 bzw. 3 Ziele werten einen
1Sep in derive_tros Schritt 4 zu 1Sep1Swi bzw. 1Sep2Swi auf.
"""
return sorted(
{
edge.dst
@@ -438,7 +525,20 @@ def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
def _default_resolve_host(sep: Element, findings: list[str], graph: Graph) -> str | None:
"""Host eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver)."""
"""Host eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver).
Parameter:
sep: der Separator; gelesen wird sep.assignment (Merkmal
"Zuordnung", z. B. "0002-L" oder die Id einer Strecke).
findings: Fundliste; ist die Zuordnung leer oder kein Graphknoten,
wird ein Eintrag angehaengt.
graph: Materialflussgraph, gegen dessen Knoten geprueft wird.
Rueckgabe:
Knoten-Id des Hosts oder None (Separator bekommt dann keinen TRO).
Die Art dieses Knotens (Kreisel/Strecke/Gefaellestrecke) entscheidet
in derive_tros, in welchem Schritt der Separator landet.
"""
host = sep.assignment
if host and host != UNASSIGNED and host in graph.nodes:
return host
@@ -503,6 +603,18 @@ def _find_switch_separators(
Die Distanz im Rueckgabewert ist nur noch Beiwerk fuer die Doku ("XXXmm
von Ausschleuselement YYYY entfernt"), kein Kriterium mehr.
Parameter:
separators: zu pruefende Separatoren (in derive_tros ohne die
eigenen VF-Kettenseparatoren).
elements: alle Elemente des CSV-Exports (Ausschleuselemente und
Kreisel mit track_ids).
Rueckgabe:
Separator-Id -> (Ausschleuselement, Abstand mm); je Separator das
erste passende Element aus seinen Nachbarn. Wird in derive_tros
Schritt 3b zu 1Sep1Swi (oder zum Steuer-Separator eines
PinStore_Auto, siehe _speicher_bloecke).
"""
transfers = {e.teile_id: e for e in elements if e.kind == "Ausschleus"}
kreisel_by_id = {e.teile_id: e for e in elements if e.kind == "Kreisel"}
@@ -555,6 +667,22 @@ def _find_weiche_separators(
bestaetigt (Review 2026-08-12 und 2026-10-01). Ist nur ein Separator
benachbart, gilt dieser. Bleibt es mehrdeutig, wird nichts geraten,
sondern ein Finding geschrieben.
Grenze: einer Weiche wird hoechstens EIN Separator zugeordnet. Weichen
mit mehreren Separatoren (2Sep1Swi, 3Sep2Swi, ...) ermittelt vorher
_kreisel_knoten aus Geometrie und Laufrichtung; hier kommen nur die
ILS Weichen an, die dort keinen Knoten bilden.
Parameter:
separators: zu pruefende Separatoren (in derive_tros ohne die
Ausschleus-Weichen-Separatoren aus switch_hits).
elements: alle Elemente des CSV-Exports (ILS Weichen und Scanner).
findings: Fundliste; je mehrdeutiger Weiche wird ein Eintrag
angehaengt.
Rueckgabe:
Separator-Id -> ILS Weiche (Element.kind "Weiche"). Wird in
derive_tros Schritt 3c' zu 1Sep1Swi.
"""
by_id = {s.teile_id: s for s in separators}
mit_scanner = {
@@ -578,6 +706,48 @@ def _find_weiche_separators(
return result
def _kreisel_knoten(
elements: list[Element], findings: list[str], frei: set[str]
) -> list[Junction]:
"""Weichenknoten zwischen Kreiseln, die einen eigenen TRO bilden.
Geometrie und Steuerregel siehe lib/kreisel_junctions.py: ein Separator
steuert einen Knoten, wenn er auf einem beteiligten Kreisel in
Laufrichtung unmittelbar davor liegt (keine andere Weiche dazwischen).
Parameter:
elements: alle Elemente des CSV-Exports.
findings: Fundliste; je verworfenem Knoten ein Eintrag.
frei: Separator-Ids, die einen Knoten steuern duerfen. Steuert ein
anderer Separator (Ausschleus-Weiche aus 3b, eigener VF-
Kettenseparator) mit, bleibt der Knoten den bisherigen Regeln
(3b, 3c', 3d) ueberlassen.
Rueckgabe:
Die Knoten, deren Typ (j.type_name) im Katalog steht und die nur von
freien Separatoren gesteuert werden.
"""
result: list[Junction] = []
for j in find_junctions(elements, findings):
weichen = ", ".join(c.weiche_id for c in j.contacts)
if not j.separators:
if any(not c.exported for c in j.contacts):
findings.append(
f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): kein "
f"Separator in Laufrichtung davor - kein TRO")
continue
if not set(j.separators) <= frei:
continue
if get_tro(j.type_name) is None:
findings.append(
f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): "
f"{len(j.separators)} Separatoren und {len(j.contacts)} Weichen - "
f"Typ {j.type_name} fehlt im Katalog, kein TRO")
continue
result.append(j)
return result
def _incoming_drop(
host: Element, sep_id: str, elements_by_id: dict[str, Element]
) -> tuple[str | None, float | None]:
@@ -591,6 +761,18 @@ def _incoming_drop(
oder fehlt einem der beiden Enden das Merkmal "Hoehe" (z. B. Aus-/
Einschleuselemente tragen keins), liefert die Funktion (None, None) - der
Aufrufer behandelt das als "kein Gefaelle-Nachweis", bleibt also bei Vario.
Parameter:
host: die Streckengruppe (Element.kind "Strecke") mit track_ids und
drive_dir.
sep_id: TeileId des Separators in host.track_ids.
elements_by_id: TeileId -> Element, fuer die Hoehen der beiden
Segmentenden.
Rueckgabe:
(Antriebfahrtrichtung des Segments, Hoehendifferenz in mm; negativ =
Gefaelle). "horizontal" mit <= -GEFAELLE_DROP_MM stuft in
derive_tros Schritt 2 einen Vario auf 1Sep herab.
"""
chain = host.track_ids
if sep_id not in chain:
@@ -677,23 +859,67 @@ def derive_tros(
) -> list[DerivedTro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
Host eines Separators aus der CSV-Spalte "Zuordnung" (_default_resolve_host),
abgehende Wege eines TRO-Hosts aus graph.edges (_switch_exits).
**Hier - und nur hier - wird der TRO-Typ bestimmt** (DerivedTro.type_name,
Uebersicht im Modul-Docstring "Wo im Code der Typ entsteht").
sepliste_counts (optional, siehe load_sepliste_counts): Container-Id ->
Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe - stimmt die aus
der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts
nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten
selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle).
Vorbereitung (vor den Schritten):
a) Weichen-Separatoren: _find_switch_separators ->
_weichen_nach_umlauf -> _weichen_aus_umlauf (Ausschleuselemente),
_kreisel_knoten (Weichenknoten zwischen Kreiseln) und
_find_weiche_separators (uebrige ILS Weichen). Diese Separatoren
bekommen keinen Host aus der Zuordnung.
b) Uebrige Separatoren -> Host-Knoten (_default_resolve_host, bei
Gefaellestrecken ggf. korrigiert durch _kreisel_bahn).
c) Scanner -> Separator ueber das Merkmal "Naechster Separator".
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).
Schritte (setzen type_name):
1 Gefaellestrecken: PinStore_Auto (>= MIN_STORE_LANES Linien) bzw.
1Sep (einzelne Linie).
2 Strecken: je Separator Vario oder 1Sep (alternierendes Muster,
Gefaelle-Pruefung ueber _incoming_drop).
3 Kreisel-Bahnen: je Separator 1Sep.
3b Separator vor Ausschleuselement: 1Sep1Swi.
3b' zweites Ausschleuselement desselben Separators: 1Sep2Swi.
3c Weichenknoten zwischen Kreiseln (_kreisel_knoten): Typ aus der
Zahl der Separatoren unmittelbar davor und der Weichen, z. B.
2Sep1Swi, 3Sep2Swi.
3c' Separator an einer uebrigen ILS Weiche: 1Sep1Swi.
3d 3b- und 3c'-TRO direkt hintereinander am Kreisel: 2Sep2Swi.
4 1Sep mit 2/3 abgehenden Flusskanten: 1Sep1Swi/1Sep2Swi.
5 Sortieren entlang des Materialflusses, IDs vergeben.
6 Hinweis bei 1Sep mit Scanner (evtl. 1Sep_SSCC, Typ bleibt).
Parameter:
graph: Materialflussgraph aus material_flow.build_graph (Knoten =
Kreisel-Bahnen "<Id>-L/-R", Strecken, Gefaellestrecken,
Stationen; Kanten = Materialfluss).
elements: alle Elemente des CSV-Exports (material_flow.read_elements);
Separatoren, Scanner, Aus-/Einschleuselemente, ILS Weichen und
Kreisel werden daraus gelesen.
findings: Fundliste, an die jeder Schritt Hinweise anhaengt (wird
veraendert, nicht ersetzt).
sepliste_counts: optional, siehe load_sepliste_counts: Container-Id
-> Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe -
stimmt die aus der CSV abgeleitete Separatorenzahl eines
Strecke/Gefaellestrecke-Hosts nicht mit dieser Zahl ueberein,
wird ein Finding angehaengt; die TRO-Daten 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).
Rueckgabe:
Die TROs in Materialfluss-Reihenfolge, mit tro_id, type_name, label,
hosts, separators, scanners, reason und confidence. Position,
Vorgaenger/Nachfolger fuellt erst analyse_tros (locate_tros,
link_tros).
"""
def resolve_host(sep: Element, findings: list[str]) -> str | None:
"""Host-Knoten eines Separators (siehe _default_resolve_host)."""
return _default_resolve_host(sep, findings, graph)
def count_exits(hosts: list[str]) -> list[str]:
"""Abgehende Flussziele der Hosts (siehe _switch_exits)."""
return _switch_exits(hosts, graph)
separators = sorted(
@@ -732,17 +958,26 @@ def derive_tros(
_weichen_nach_umlauf(
_find_switch_separators(switch_candidates, elements), elements, findings),
switch_candidates, elements, findings)
# Weichenknoten zwischen Kreiseln aus Geometrie und Laufrichtung (siehe
# _kreisel_knoten, Schritt 3c) - ihre Separatoren und Weichen sind damit
# vergeben und laufen nicht mehr durch _find_weiche_separators.
knoten = _kreisel_knoten(
elements, findings, frei={s.teile_id for s in switch_candidates} - set(switch_hits))
knoten_seps = {s for j in knoten for s in j.separators}
knoten_weichen = {c.weiche_id for j in knoten for c in j.contacts}
# Separatoren an einer echten ILS Weiche (siehe _find_weiche_separators) -
# wie switch_hits vor der Host-Aufloesung herausgenommen, Schritt 3c.
# wie switch_hits vor der Host-Aufloesung herausgenommen, Schritt 3c'.
weiche_hits = _find_weiche_separators(
[s for s in switch_candidates if s.teile_id not in switch_hits],
elements, findings,
[s for s in switch_candidates
if s.teile_id not in switch_hits and s.teile_id not in knoten_seps],
[e for e in elements if e.teile_id not in knoten_weichen], findings,
)
# Separator -> Host-Knoten
sep_host: dict[str, str] = {}
for sep in separators:
if sep.teile_id in switch_hits or sep.teile_id in weiche_hits:
if (sep.teile_id in switch_hits or sep.teile_id in weiche_hits
or sep.teile_id in knoten_seps):
continue
host = resolve_host(sep, findings)
# Steht er in der Umlaufkette eines Kreisels, zeigt die Zuordnung aber
@@ -764,7 +999,8 @@ def derive_tros(
sep_scanners: dict[str, list[str]] = {}
for scanner in scanners:
target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip()
if target in sep_host or target in switch_hits or target in weiche_hits:
if (target in sep_host or target in switch_hits or target in weiche_hits
or target in knoten_seps):
sep_scanners.setdefault(target, []).append(scanner.teile_id)
mounted = scanner.assignment
target_host = sep_host.get(target)
@@ -784,9 +1020,11 @@ def derive_tros(
)
def seps_on(hosts: list[str]) -> list[str]:
"""Sortierte Separator-Ids, deren Host in hosts liegt."""
return sorted(s for s, h in sep_host.items() if h in hosts)
def scanners_for(seps: list[str]) -> list[str]:
"""Sortierte Scanner-Ids, die an einem der Separatoren lesen."""
found: list[str] = []
for sep in seps:
found.extend(sep_scanners.get(sep, []))
@@ -1106,7 +1344,35 @@ def derive_tros(
f"Barcode-Scanner am Eingang, im Layout fehlt einer vor Separator "
f"{tro.separators[0]}")
# 3c) Separatoren an einer echten ILS Weiche -> 1Sep1Swi (siehe
# 3c) Weichenknoten zwischen Kreiseln -> nSepmSwi (siehe _kreisel_knoten):
# n = Separatoren, die in Laufrichtung unmittelbar vor dem Knoten liegen,
# m = Weichen (Beruehrpunkte) des Knotens. Host je Separator ist die Bahn,
# auf der er physisch liegt - nicht die Zuordnung (export.csv 0015:
# Zuordnung 0003-L, liegt aber auf Kreisel 0002 Bahn R).
for j in knoten:
weichen = [c.weiche_id for c in j.contacts]
vor = ", ".join(f"{s} ({j.sep_lane[s]})" for s in j.separators)
hosts = list(dict.fromkeys(j.sep_lane[s] for s in j.separators
if j.sep_lane[s] in graph.nodes))
tros.append(DerivedTro(
tro_id="",
type_name=j.type_name,
label=(f"Weichenknoten Kreisel {'/'.join(j.kreisel)}: "
f"Weiche{'n' if len(weichen) > 1 else ''} {', '.join(weichen)}"),
hosts=hosts or [j.sep_kreisel[s] for s in j.separators],
separators=list(j.separators),
scanners=scanners_for(j.separators),
reason=(f"Separator{'en' if len(j.separators) > 1 else ''} {vor} "
f"in Laufrichtung unmittelbar vor "
f"{'den Weichen' if len(weichen) > 1 else 'der Weiche'} "
f"{', '.join(weichen)} (Beruehrpunkt"
f"{'e' if len(weichen) > 1 else ''} der Kreisel "
f"{'/'.join(j.kreisel)})"),
confidence="hoch" if all(c.exported for c in j.contacts) else "mittel",
))
# 3c') Separatoren an einer ILS Weiche, die kein Weichenknoten aus 3c ist
# (z. B. gegenlaeufige Drehrichtung im Export) -> 1Sep1Swi (siehe
# _find_weiche_separators). Host wie bei 3b: der aufgeloeste Zuordnung-Host
# des Separators, sonst die Weiche selbst.
weiche_tro: dict[str, DerivedTro] = {} # Weichen-Id -> 1Sep1Swi
@@ -1232,15 +1498,26 @@ def _flow_rank(graph: Graph) -> dict[str, int]:
return rank
def _flow_key(graph: Graph, elements_by_id: dict[str, Element]):
def _flow_key(
graph: Graph, elements_by_id: dict[str, Element]
) -> Callable[[DerivedTro], tuple[int, int, str]]:
"""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).
Parameter:
graph: Materialflussgraph (fuer _flow_rank).
elements_by_id: TeileId -> Element, fuer die track_ids des Hosts.
Rueckgabe:
Funktion fuer sorted()/list.sort(key=...), die je TRO
(Host-Rang, Index in der TrackIds-Kette, erster Separator) liefert.
"""
rank = _flow_rank(graph)
def key(tro: DerivedTro) -> tuple:
def key(tro: DerivedTro) -> tuple[int, int, str]:
"""(Host-Rang, Index in der TrackIds-Kette, erster Separator)."""
host_rank = min((rank.get(h, len(rank)) for h in tro.hosts),
default=len(rank))
sep = tro.separators[0] if tro.separators else ""
@@ -1328,7 +1605,7 @@ def _tro_edge_kind(edge: Edge) -> str:
return "normal"
def _bahn_richtung(node) -> tuple[float, float] | None:
def _bahn_richtung(node: Node) -> tuple[float, float] | None:
"""Foerderrichtung einer Kreisel-Bahn als Einheitsvektor.
Der Kreisel ist eine geschlossene Schleife: auf der einen Bahn laeuft das
@@ -1414,7 +1691,8 @@ def _chain_mit_ungelisteten_separatoren(
if dx == 0 and dy == 0:
return chain
def _t(pos) -> float:
def _t(pos: tuple[float, float, float]) -> float:
"""Projektion von pos auf die Richtung anfang -> ende (unskaliert)."""
return (pos[0] - anfang.position[0]) * dx + (pos[1] - anfang.position[1]) * dy
# Kettenelemente ohne Position behalten ihren Platz: sie erben den Wert des
@@ -1507,6 +1785,7 @@ def link_tros(
elem_owner.setdefault(sep_id, tro.tro_id)
def _add_chain_pair(a: str, b: str) -> None:
"""Gerichtete Kante TRO a -> TRO b aus der TrackIds-Kette eintragen."""
if a == b:
return
# Keine Sperre fuer Systemgrenzen noetig: sie sind kein Kettenglied
@@ -1863,7 +2142,23 @@ def analyse_tros(
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren.
Gemeinsamer Einstieg fuer tro_flow.py und tro_annotate.py. Der TRO-Typ
wird NICHT hier bestimmt, sondern in derive_tros (erster Aufruf unten);
danach kommen nur noch Systemgrenzen (derive_boundaries), Koordinaten
(locate_tros) und Verbindungen (link_tros) dazu.
Parameter:
graph: Materialflussgraph aus material_flow.build_graph.
elements: alle Elemente des CSV-Exports (material_flow.read_elements).
sepliste_counts: optional, Separatorenzahl je Container aus dem
Roh-JSON (load_sepliste_counts) - nur Gegenprobe.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu
nummerieren.
Rueckgabe:
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
(Typnamen ohne Katalogeintrag), isolated (TROs ohne Verbindung) und
den sortierten findings.
"""
analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings,
@@ -1892,6 +2187,12 @@ def analyse_tros(
def _tro_label(tro: DerivedTro) -> str:
"""Mehrzeiliges DOT-Label eines TRO-Knotens.
Zeilen: ID und Typ, Bezeichnung, FB-Baustein, Bauteile laut Katalog,
Separatoren (ab 7 gekuerzt), Scanner, Koordinate - jeweils nur, wenn
vorhanden.
"""
lines = [f"{tro.tro_id} ({tro.type_name})", tro.label]
if tro.fb_block:
lines.append(tro.fb_block)
@@ -1981,6 +2282,7 @@ def _station_positionen(
je_tro.setdefault(anker, []).append(station)
def _abstand(a: float, b: float) -> float:
"""Kleinster Winkelabstand (rad) zweier Richtungen."""
d = abs(a - b) % (2 * math.pi)
return min(d, 2 * math.pi - d)
@@ -2060,7 +2362,7 @@ def _legend_pos_rechts(zeichen: dict[str, tuple[float, float]]) -> tuple[float,
return (0.0, 0.0)
xs = [p[0] for p in zeichen.values()]
ys = [p[1] for p in zeichen.values()]
return (max(xs) + LEGEND_MARGIN, max(ys) - 1000)
return (max(xs) + LEGEND_MARGIN, max(ys) - LEGEND_DROP)
def _zeichen_positionen(
@@ -2099,6 +2401,7 @@ STATION_FARBE = {
def _tro_edge_attrs(kind: str | None) -> str:
"""DOT-Kantenattribute zur Kantenart (TRO_EDGE_STYLE); None = "normal"."""
return TRO_EDGE_STYLE.get(kind or "normal", "")
@@ -2118,6 +2421,7 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False,
zeichen = _zeichen_positionen(analysis, compact) if use_coords else {}
def _pos(node: DerivedTro) -> str:
"""DOT-Attribut ', pos="x,y"' des Knotens oder ''."""
if use_coords:
p = zeichen.get(node.tro_id)
return (f', pos="{p[0] * COORD_SCALE:.2f},{p[1] * COORD_SCALE:.2f}"'
@@ -2336,6 +2640,16 @@ def render_connect_ini(analysis: Analysis, section: str, source: str) -> str:
def render_errors(analysis: Analysis, csv_file: Path) -> str:
"""Fehlerbericht <name>_tro_errors.md als Markdown-Text.
Parameter:
analysis: Ergebnis von analyse_tros (isolated, unknown_types).
csv_file: Eingabe-CSV, nur fuer Titel und Quellenangabe.
Rueckgabe:
Der Markdown-Text. Ob er geschrieben wird (nur bei isolierten TROs
oder unbekannten Typen), entscheidet write_error_file.
"""
out = [GENERATED_MARKER, f"# TRO-Fehler - {csv_file.name}", ""]
out.append(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} ")
out.append(f"**Quelle:** `{csv_file}` ")
@@ -2406,10 +2720,16 @@ def write_error_file(analysis: Analysis, csv_file: Path, path: Path) -> bool:
def _mermaid_text(text: str) -> str:
"""Text fuer ein Mermaid-Knotenlabel entschaerfen (\" und [] ersetzen)."""
return text.replace('"', "'").replace("[", "(").replace("]", ")")
def _tro_mermaid(analysis: Analysis) -> list[str]:
"""TRO-Fluss als Mermaid-Flowchart (Zeilen inklusive ```-Zaun).
Knoten je TRO (Farbe nach Katalogtyp) und Systemgrenze, Kanten aus
analysis.edges, gestrichelt TROs auf derselben Bahn (colocated).
"""
out = ["```mermaid", "flowchart LR"]
out.extend(f" {line}" for line in mermaid_classdefs())
@@ -2447,6 +2767,20 @@ def render_doc(
svg_file: Path | None,
error_file: Path | None,
) -> str:
"""TRO-Dokumentation <name>_tro_doc.md (--doc) als Markdown-Text.
Parameter:
analysis: Ergebnis von analyse_tros.
graph: Materialflussgraph (Host-Namen und -Arten fuer die Tabellen).
csv_file: Eingabe-CSV (Titel, Quellenangabe).
dot_file: erzeugte DOT-Datei, wird verlinkt.
svg_file: erzeugtes SVG oder None (ohne --tosvg).
error_file: Fehlerdatei oder None (fehlerfreier Lauf).
Rueckgabe:
Der Markdown-Text: TRO-Liste mit Typ und Begruendung, Mermaid-Fluss,
Verbindungen, Typdefinition aus dem Katalog, Findings.
"""
out: list[str] = [GENERATED_MARKER]
add = out.append
@@ -2650,6 +2984,17 @@ def report(
warnings: list[str],
engine: str = "dot",
) -> None:
"""Konsolenbericht eines Laufs: TRO-Zahl je Typ, Findings, Warnungen,
erzeugte Dateien.
Parameter:
analysis: Ergebnis von analyse_tros.
csv_file: Eingabe-CSV.
dot_file: erzeugte DOT-Datei.
svg_file / error_file / doc_file: erzeugte Dateien oder None.
warnings: Warnungen aus material_flow (Einlesen, Graphaufbau).
engine: Graphviz-Programm des SVG ("dot" oder "neato", mit -n).
"""
types_used: dict[str, int] = {}
for tro in analysis.tros:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
@@ -2709,6 +3054,7 @@ def report(
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Kommandozeile auswerten; argv=None liest sys.argv[1:]."""
parser = argparse.ArgumentParser(
prog="tro_flow",
description="Ermittelt aus einem CSV-Export der Anlagenobjekte die "
@@ -2866,6 +3212,16 @@ def report_ids(tro_ids: TroIds, status: str) -> None:
def main(argv: list[str] | None = None) -> int:
"""CLI-Einstieg: CSV lesen, Graph bauen, analyse_tros, Ausgaben schreiben.
Parameter:
argv: Argumente ohne Programmnamen; None = sys.argv[1:].
Rueckgabe:
Exitcode: 0 = Erfolg, 1 = Eingabe- oder Ausgabefehler, 2 = SVG nicht
erzeugt, 3 = TRO-Pruefung fehlgeschlagen (isolierte TROs oder
unbekannte Typen, siehe Fehlerdatei).
"""
args = parse_args(argv)
csv_file = resolve_input(args.file)
+4 -3
View File
@@ -37,6 +37,8 @@ from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from parameter import get_float
__all__ = [
"SECTION",
"DEFAULT_TOLERANCE",
@@ -57,9 +59,8 @@ 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
# wiedergefunden gilt - aus cfg/parameter.ini [tro_ids] (lib/parameter.py).
DEFAULT_TOLERANCE = get_float("tro_ids", "default_tolerance")
# Eine ID wird zum SCL-Bezeichner (Instanz #<ID>, "DB_TRO_TEST".<ID>).
_ID_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""Tests fuer lib/kreisel_junctions.py - Weichenknoten zwischen Kreiseln.
Die Geometrie stammt aus der Testanlage data/export.csv (9 Kreisel, 10
Separatoren, 5 ILS Weichen; drei Beruehrpunkte fehlen im Export als Weiche).
"""
from __future__ import annotations
import math
import pytest
from kreisel_junctions import find_contacts, find_junctions, kreisel_loop
from material_flow import Element
def _el(teile_id: str, kind: str, pos: tuple[float, float],
bbox: tuple[float, float] | None = None, **merkmale: object) -> Element:
return Element(
number=teile_id, kind=kind, teile_art=kind, teile_id=teile_id,
name=f"{kind} {teile_id}", plan_square="", neighbours=[], track_ids=[],
merkmale=dict(merkmale), row=0, position=(pos[0], pos[1], 2000.0),
bbox=(bbox[0], bbox[1], 1.0) if bbox else None,
)
def _kreisel(teile_id: str, pos: tuple[float, float], bbox: tuple[float, float],
drehrichtung: str, drehung: float) -> Element:
return _el(teile_id, "Kreisel", pos, bbox, Drehrichtung=drehrichtung, Drehung=drehung)
# (Id, Position, Boundingbox, Drehrichtung, Drehung) aus data/export.csv
KREISEL = [
("0001", (-2011.74, 994.84), (3100.00, 800.00), "UZS", 0),
("0002", (4940.03, -4953.25), (800.00, 5300.00), "UZS", 90),
("0003", (5739.13, -9850.80), (800.00, 6095.71), "GUZ", 90),
("0004", (6533.60, -3258.73), (800.00, 8689.42), "UZS", 90),
("0005", (11425.39, 1485.99), (10583.58, 800.00), "GUZ", 0),
("0006", (6533.60, 4145.31), (800.00, 4518.63), "UZS", 90),
("0007", (4940.59, -453.25), (800.00, 3700.00), "GUZ", 90),
("0008", (2440.85, 505.79), (4200.00, 800.00), "UZS", 0),
("0009", (-61.76, 1425.92), (800.00, 4800.00), "GUZ", 90),
]
SEPARATOREN = [
("0010", (-461.76, 1526.13)), ("0011", (1244.39, 105.79)),
("0012", (3699.43, 905.79)), ("0013", (4540.59, -1493.47)),
("0014", (4540.03, -3169.65)), ("0015", (5340.03, -6534.21)),
("0016", (6933.60, -6618.23)), ("0017", (6133.60, 302.58)),
("0018", (7149.18, 1885.99)), ("0019", (6933.60, 2908.49)),
]
WEICHEN = [
("0022", (-461.76, 994.84)), ("0023", (4540.59, 505.79)),
("0024", (4940.31, -2303.25)), ("0025", (6533.60, 1885.99)),
("0026", (6533.60, 1085.78)),
]
@pytest.fixture
def testanlage() -> list[Element]:
return ([_kreisel(*k) for k in KREISEL]
+ [_el(i, "Separator", p) for i, p in SEPARATOREN]
+ [_el(i, "Weiche", p) for i, p in WEICHEN])
@pytest.mark.parametrize("drehrichtung", ["UZS", "GUZ"])
def test_loop_point_project_roundtrip(drehrichtung: str) -> None:
loop = kreisel_loop(_kreisel("K", (100.0, 200.0), (800.0, 3000.0), drehrichtung, 90))
assert loop is not None
for i in range(40):
s = i * loop.length / 40
x, y = loop.point(s)
s2, dist, _sec = loop.project(x, y)
assert dist < 1e-6
assert min(abs(s2 - s), loop.length - abs(s2 - s)) < 1e-6
def test_loop_direction_follows_drehrichtung() -> None:
# Vertikaler Kreisel (Drehung 90), Bahnen bei x = -400 und x = +400:
# UZS laeuft auf der linken (westlichen) Bahn nach Norden.
uzs = kreisel_loop(_kreisel("K", (0.0, 0.0), (800.0, 3000.0), "UZS", 90))
guz = kreisel_loop(_kreisel("K", (0.0, 0.0), (800.0, 3000.0), "GUZ", 90))
s_uzs, _d, _sec = uzs.project(-400.0, 0.0)
s_guz, _d, _sec = guz.project(-400.0, 0.0)
assert uzs.direction(s_uzs) == pytest.approx((0.0, 1.0), abs=1e-6)
assert guz.direction(s_guz) == pytest.approx((0.0, -1.0), abs=1e-6)
def test_contacts_match_exported_weichen(testanlage: list[Element]) -> None:
findings: list[str] = []
_loops, contacts = find_contacts(testanlage, findings)
by_pair = {c.kreisel: c for c in contacts}
assert sorted(by_pair) == [
("0001", "0009"), ("0002", "0003"), ("0002", "0007"), ("0003", "0004"),
("0004", "0005"), ("0005", "0006"), ("0007", "0008"), ("0008", "0009"),
]
assert by_pair[("0002", "0007")].weiche_id == "0024"
assert by_pair[("0005", "0006")].weiche_id == "0025"
# Im Export fehlende Weichen bekommen eine abgeleitete Id und ein Finding
assert by_pair[("0008", "0009")].weiche_id == "W0008_0009"
assert not by_pair[("0008", "0009")].exported
assert sum("ohne ILS Weiche im Export" in f for f in findings) == 3
def test_junction_types_testanlage(testanlage: list[Element]) -> None:
junctions = find_junctions(testanlage, [])
result = sorted((j.type_name, tuple(j.separators),
tuple(c.weiche_id for c in j.contacts)) for j in junctions)
assert result == [
("1Sep1Swi", ("0010",), ("0022",)),
("1Sep1Swi", ("0011",), ("W0008_0009",)),
("1Sep1Swi", ("0012",), ("0023",)),
("2Sep1Swi", ("0013", "0014"), ("0024",)),
("2Sep2Swi", ("0015", "0016"), ("W0002_0003", "W0003_0004")),
("3Sep2Swi", ("0017", "0018", "0019"), ("0025", "0026")),
]
def test_separator_behind_other_weiche_does_not_control(testanlage: list[Element]) -> None:
# 0010 liegt auf Kreisel 9 vor Weiche 0022; die Weiche 9/8 folgt erst
# danach - sie steuert 0011 von Kreisel 8 aus, nicht 0010.
junctions = find_junctions(testanlage, [])
j98 = next(j for j in junctions if j.contacts[0].weiche_id == "W0008_0009")
assert j98.separators == ["0011"]
assert j98.sep_lane["0011"] == "0008-R"
def test_separator_lane_is_physical_not_zuordnung(testanlage: list[Element]) -> None:
# 0015 ist im Export Kreisel 0003 zugeordnet, liegt aber auf Kreisel 0002
junctions = find_junctions(testanlage, [])
j = next(j for j in junctions if "0015" in j.separators)
assert j.sep_kreisel["0015"] == "0002"
assert j.sep_lane["0015"] == "0002-R"
def test_counter_rotating_contact_is_no_weiche() -> None:
# Zwei UZS-Kreisel Stirn an Stirn laufen am Beruehrpunkt gegeneinander
a = _kreisel("A", (0.0, 0.0), (3000.0, 800.0), "UZS", 0)
b = _kreisel("B", (3000.0, 0.0), (3000.0, 800.0), "UZS", 0)
findings: list[str] = []
_loops, contacts = find_contacts([a, b], findings)
assert contacts == []
assert any("gegenlaeufig" in f for f in findings)
def test_co_rotating_end_contact_is_weiche() -> None:
a = _kreisel("A", (0.0, 0.0), (3000.0, 800.0), "UZS", 0)
b = _kreisel("B", (3000.0, 0.0), (3000.0, 800.0), "GUZ", 0)
_loops, contacts = find_contacts([a, b], [])
assert len(contacts) == 1
assert math.hypot(contacts[0].x - 1500.0, contacts[0].y) < 5.0
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Tests fuer lib/parameter.py und cfg/parameter.ini."""
from __future__ import annotations
import configparser
import re
from pathlib import Path
import pytest
import parameter
from parameter import ParameterError, get_float, get_int
ROOT = Path(__file__).resolve().parent.parent
BASE = ROOT / "cfg" / parameter.PARAMETER_FILE
@pytest.fixture
def frisch(monkeypatch: pytest.MonkeyPatch):
"""Parameter-Cache leeren, damit SKEL_CFG im Test wirkt - und danach wieder."""
parameter._parser.cache_clear()
yield monkeypatch
parameter._parser.cache_clear()
def _base() -> configparser.ConfigParser:
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
parser.read(BASE, encoding="utf-8")
return parser
def test_base_values_are_read(frisch: pytest.MonkeyPatch) -> None:
frisch.delenv("SKEL_CFG", raising=False)
assert get_float("kreisel_junctions", "contact_tolerance") == 50.0
assert get_int("tro_flow", "min_store_lanes") == 2
def test_skel_cfg_overrides_single_value(frisch: pytest.MonkeyPatch, tmp_path: Path) -> None:
(tmp_path / parameter.PARAMETER_FILE).write_text(
"[kreisel_junctions]\ncontact_tolerance = 75 ; Anlage X\n", encoding="utf-8")
frisch.setenv("SKEL_CFG", str(tmp_path))
assert get_float("kreisel_junctions", "contact_tolerance") == 75.0
# nicht ueberschriebene Werte kommen weiter aus der Grunddatei
assert get_float("kreisel_junctions", "separator_tolerance") == 100.0
def test_missing_key_is_an_error(frisch: pytest.MonkeyPatch) -> None:
frisch.delenv("SKEL_CFG", raising=False)
with pytest.raises(ParameterError, match=r"\[tro_flow\] gibt_es_nicht fehlt"):
get_float("tro_flow", "gibt_es_nicht")
def test_non_number_is_an_error(frisch: pytest.MonkeyPatch, tmp_path: Path) -> None:
(tmp_path / parameter.PARAMETER_FILE).write_text(
"[tro_flow]\nmin_store_lanes = zwei\n", encoding="utf-8")
frisch.setenv("SKEL_CFG", str(tmp_path))
with pytest.raises(ParameterError, match="keine ganze Zahl"):
get_int("tro_flow", "min_store_lanes")
def test_every_key_is_used_by_its_module() -> None:
"""Jeder Schluessel der Grunddatei wird in lib/<Abschnitt>.py gelesen -
keine verwaisten Werte, die nichts mehr steuern."""
parser = _base()
for section in parser.sections():
source = (ROOT / "lib" / f"{section}.py").read_text(encoding="utf-8")
for key in parser.options(section):
assert re.search(rf'"{key}"', source), f"[{section}] {key} wird nicht gelesen"
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""Tests fuer die Mouseover-Texte des --track-Graphen (material_flow.py)."""
from __future__ import annotations
from material_flow import TrackNode, _tooltip_context, _track_node_tooltip
from test_kreisel_junctions import KREISEL, SEPARATOREN, WEICHEN, _el, _kreisel
def _anlage() -> list:
kreisel = [_kreisel(*k) for k in KREISEL]
for k in kreisel:
k.name = f"Kreisel{int(k.teile_id)}" # wie im Export
kreisel[2].track_ids = ["0015"] # Kreisel 0003 laut Export
kreisel[4].track_ids = ["0025", "0026", "0020"] # Kreisel 0005
seps = []
for teile_id, pos in SEPARATOREN:
zuordnung = {"0015": "0003-L", "0010": "0009-L"}.get(teile_id, "")
seps.append(_el(teile_id, "Separator", pos, Zuordnung=zuordnung))
weichen = [_el(i, "Weiche", p) for i, p in WEICHEN]
weichen[0].merkmale.update({"Kreisel A": "0009", "Kreisel B": "0001"})
beladung = _el("0020", "Beladung", (14374.25, 1186.94))
return kreisel + seps + weichen + [beladung]
def _tooltip(elements: list, teile_id: str, kind: str) -> list[str]:
ctx = _tooltip_context(elements)
el = next(e for e in elements if e.teile_id == teile_id)
return _track_node_tooltip(TrackNode(teile_id, kind, el), ctx).split("\\n")
def test_separator_shows_zuordnung_kette_and_geometry_conflict() -> None:
lines = _tooltip(_anlage(), "0015", "Separator")
assert "Zuordnung: Kreisel3 (0003) Bahn L" in lines
assert "TrackIds-Kette: Kreisel3 (0003)" in lines
assert "Geometrie: liegt auf Kreisel2 (0002) Bahn R" in lines
assert "! Zuordnung Kreisel 0003, liegt aber auf Kreisel 0002" in lines
def test_separator_without_conflict_has_no_warning() -> None:
lines = _tooltip(_anlage(), "0010", "Separator")
assert "Geometrie: liegt auf Kreisel9 (0009) Bahn L" in lines
assert not any(line.startswith("! Zuordnung") for line in lines)
def test_weiche_shows_both_kreisel() -> None:
lines = _tooltip(_anlage(), "0022", "Weiche")
assert "Kreisel A: Kreisel9 (0009)" in lines
assert "Kreisel B: Kreisel1 (0001)" in lines
assert "Geometrie: Beruehrpunkt Kreisel 0001 / 0009" in lines
def test_beladung_shows_its_kreisel() -> None:
lines = _tooltip(_anlage(), "0020", "Beladung")
assert "TrackIds-Kette: Kreisel5 (0005)" in lines
assert lines[-1].startswith("Geometrie: naechster Kreisel Kreisel5 (0005)")