diff --git a/CLAUDE.md b/CLAUDE.md index ae48630..af34dd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/_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[]`, diff --git a/cad/tro_types.lsp b/cad/tro_types.lsp index e96f17d..9ad0faa 100644 --- a/cad/tro_types.lsp +++ b/cad/tro_types.lsp @@ -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) diff --git a/cfg/e_planning.ini b/cfg/e_planning.ini index 2d5bf61..6f4856a 100644 --- a/cfg/e_planning.ini +++ b/cfg/e_planning.ini @@ -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 diff --git a/cfg/parameter.ini b/cfg/parameter.ini new file mode 100644 index 0000000..f5400f3 --- /dev/null +++ b/cfg/parameter.ini @@ -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 diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 53405c4..50c9c84 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -265,7 +265,7 @@ See `doc/track.md` for the fuller derivation notes. | `--connect` | — | off | Also write the topology as `_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 `_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 `` 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 diff --git a/lib/dxf_registration.py b/lib/dxf_registration.py index c07d7cc..157afcc 100644 --- a/lib/dxf_registration.py +++ b/lib/dxf_registration.py @@ -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): diff --git a/lib/e_planning.py b/lib/e_planning.py index c2e78d3..ad001bf 100644 --- a/lib/e_planning.py +++ b/lib/e_planning.py @@ -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: diff --git a/lib/kreisel_junctions.py b/lib/kreisel_junctions.py new file mode 100644 index 0000000..3820904 --- /dev/null +++ b/lib/kreisel_junctions.py @@ -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 diff --git a/lib/material_flow.py b/lib/material_flow.py index 37b9b76..25b5d87 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -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" diff --git a/lib/parameter.py b/lib/parameter.py new file mode 100644 index 0000000..f845d7d --- /dev/null +++ b/lib/parameter.py @@ -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 diff --git a/lib/tro_annotate.py b/lib/tro_annotate.py index bd4a9fb..7985ba8 100644 --- a/lib/tro_annotate.py +++ b/lib/tro_annotate.py @@ -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]: diff --git a/lib/tro_catalog.py b/lib/tro_catalog.py index 83964c8..0cdd4a2 100644 --- a/lib/tro_catalog.py +++ b/lib/tro_catalog.py @@ -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} diff --git a/lib/tro_flow.py b/lib/tro_flow.py index a3e66fa..8f52acf 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -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) diff --git a/lib/tro_ids.py b/lib/tro_ids.py index d70bab4..7e23f9f 100644 --- a/lib/tro_ids.py +++ b/lib/tro_ids.py @@ -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_]*$") diff --git a/tests/test_kreisel_junctions.py b/tests/test_kreisel_junctions.py new file mode 100644 index 0000000..aac2cbb --- /dev/null +++ b/tests/test_kreisel_junctions.py @@ -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 diff --git a/tests/test_parameter.py b/tests/test_parameter.py new file mode 100644 index 0000000..184748c --- /dev/null +++ b/tests/test_parameter.py @@ -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" diff --git a/tests/test_track_tooltip.py b/tests/test_track_tooltip.py new file mode 100644 index 0000000..cdd1228 --- /dev/null +++ b/tests/test_track_tooltip.py @@ -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)")