TRO-JSON v4: Slots je TRO und Staubereiche (jamAreas)

- model: Slot (separator/switch/scanner/drive + Rollen), JamArea (J<Separator>,
  Eingang eines TRO mit allen Vorgaengern), derive_slots, derive_jam_areas;
  Schema sps_skel-tro-v4, v3-Dateien werden weiter gelesen
- scl_context: ein JamEntr je eigenem Staubereich statt je Vorgaenger (wie
  UH01-UH05), Ausgaenge nennen den Staubereich des Nachfolgers
- Golden Files neu erzeugt, Tests fuer Slots/Staubereiche, Doku nachgezogen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-11 22:44:02 +02:00
co-authored by Claude Opus 5.5
parent dd2d98f2ee
commit 0115ef68b7
14 changed files with 489 additions and 267 deletions
+145 -5
View File
@@ -21,6 +21,11 @@ Klassen:
Connection eine gerichtete Verbindung zwischen zwei TROs/Stationen
TroCandidate eine im Review gemeldete Stelle, an der ein TRO erwartet
wird, die aber (noch) keiner ist
Slot eine Stelle eines TRO, an die Hardware gehoert (Separator,
Weiche, Scanner, Antrieb) mit den Rollen, die die
Hardwareplanung dort fuellen muss
JamArea ein Staubereich: Eingang genau eines TRO (je Separator
laut Katalog), mit allen Vorgaengern, die dort einfahren
Analysis Arbeitsergebnis von tro_flow.analyse_tros
TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json
geschrieben, von scl_skeleton gelesen und geprueft
@@ -47,22 +52,28 @@ from pydantic import (
model_validator,
)
from tro_catalog import TroDefinition, get_tro
from tro_catalog import TroDefinition, TroItem, get_tro
__all__ = [
"SCHEMA",
"TroBase",
"Slot",
"JamArea",
"DerivedTro",
"Connection",
"TroCandidate",
"Analysis",
"TroDocument",
"derive_slots",
"derive_jam_areas",
]
# Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung),
# v2 die von tro_flow --json (aus dem CSV, ueber dieses Modell), v3 wie v2 mit
# `candidates` statt `openPoints`.
SCHEMA = "sps_skel-tro-v3"
# `candidates` statt `openPoints`, v4 wie v3 mit `slots` je TRO und `jamAreas`.
# Eine v3-Datei liest sich weiter (die neuen Felder fehlen dann und werden beim
# Rendern abgeleitet, siehe TroDocument.jam_areas_or_derived).
SCHEMA = "sps_skel-tro-v4"
# Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
@@ -191,6 +202,50 @@ class TroBase(_Positioned):
return data
class Slot(BaseModel):
"""
Eine Stelle eines TRO, an die Hardware gehoert (Konzept Umbau 1).
``slot`` ist der stabile Schluessel innerhalb des TRO (``separator1``,
``switch1``, ``scanner1``, ``drive``); zusammen mit der TRO-ID adressiert er
die Zuordnung der Hardwareplanung. ``layout_id`` ist die TeileId des
Bauteils im aktuellen Export - nur zur Nachverfolgung, nicht stabil ("",
wenn das Bauteil im Layout nicht bekannt ist). Rollen mit ``?`` sind
optional: fehlt das Geraet, wird der zugehoerige xPlugged*-Schalter FALSE.
"""
model_config = _CONFIG
slot: str
layout_id: str = Field(default="", alias="layoutId")
roles: list[str] = Field(default_factory=list)
class JamArea(_Positioned):
"""
Ein Staubereich: die Warteschlange vor einem Separator, Eingang genau eines
TRO (``tro``, Eingang Nummer ``entry``).
Kennung ``J<Separator>`` (Mubea: Sep1024 -> J1024). Mehrere Vorgaenger
koennen in denselben Staubereich einfahren (Zusammenfuehrung); deren
Ausgang zeigt dann auf diesen Staubereich. Der SCL-Name
``"DB_JamArea<NNNN>".stJam<NNNN>_<k>`` (Standard UH01-UH05) braucht die
Foerderernummer aus der Hardwareplanung und steht deshalb nicht hier.
"""
model_config = _CONFIG
jam_id: str = Field(alias="id")
tro: str
entry: int = 1
separator: str = ""
# tragender Foerderer (erster Host des TRO im Layout)
host: str = ""
predecessors: list[str] = Field(default_factory=list)
note: str = ""
position: tuple[float, float, float] | None = None
class DerivedTro(TroBase):
"""
Ein von tro_flow aus dem CSV abgeleiteter TRO (oder eine Systemgrenze).
@@ -207,6 +262,8 @@ class DerivedTro(TroBase):
# Mittelpunkt der Bauteile, aus denen dieser TRO gebildet wurde
position_from: str = Field(default="", alias="positionFrom")
position_points: int = Field(default=0, alias="positionPoints")
# Stellen fuer die Hardwareplanung (derive_slots); leer bei Systemgrenzen
slots: list[Slot] = Field(default_factory=list)
@property
def connected(self) -> bool:
@@ -288,16 +345,23 @@ class TroDocument(BaseModel):
boundaries: list[DerivedTro] = Field(default_factory=list)
connections: list[Connection] = Field(default_factory=list)
candidates: list[TroCandidate] = Field(default_factory=list)
jam_areas: list[JamArea] = Field(default_factory=list, alias="jamAreas")
findings: list[str] = Field(default_factory=list)
@classmethod
def from_analysis(cls, analysis: Analysis, source: str, note: str = "") -> TroDocument:
"""Dokument aus dem Ergebnis von tro_flow.analyse_tros bilden."""
"""Dokument aus dem Ergebnis von tro_flow.analyse_tros bilden.
Slots und Staubereiche werden hier abgeleitet, damit die JSON sie
traegt und nicht erst das Rendern sie kennt.
"""
tros = [t.model_copy(update={"slots": derive_slots(t)}) for t in analysis.tros]
return cls(
source=source,
note=note,
tros=analysis.tros,
tros=tros,
boundaries=analysis.boundaries,
jam_areas=derive_jam_areas(tros),
connections=[
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
for src, dst in analysis.edges
@@ -310,6 +374,10 @@ class TroDocument(BaseModel):
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
return cls.model_validate_json(text)
def jam_areas_or_derived(self) -> list[JamArea]:
"""jamAreas aus der Datei; bei einer v3-Datei (ohne jamAreas) abgeleitet."""
return self.jam_areas or derive_jam_areas(self.tros)
def to_json(self) -> str:
"""Als JSON-Text mit den Schluesseln der TRO-JSON (id, type, fbType ...)."""
return self.model_dump_json(by_alias=True, indent=2) + "\n"
@@ -319,3 +387,75 @@ class TroDocument(BaseModel):
if tro.tro_id == tro_id:
return tro
return None
# ---------------------------------------------------------------------------
# Ableitungen aus Katalogtyp und Topologie
# ---------------------------------------------------------------------------
# Rollen je Slot-Art (doc/Konzept_Layout_Hardware_SCL.md, Umbau 1); "?" = optional
_SEPARATOR_ROLES = ("sensor", "stopper", "jamSensor?", "partSensor?")
_SWITCH_ROLES = ("valve", "exitSensor?")
_SCANNER_ROLES = ("reader",)
_DRIVE_ROLES = ("motor", "motorProtection", "senCarrInPos", "senFinger",
"senLastPos", "senJam")
def _at(values: list[str], index: int) -> str:
return values[index] if index < len(values) else ""
def derive_slots(tro: DerivedTro) -> list[Slot]:
"""Slots eines TRO: Anzahl aus dem Katalog, Layout-ID aus dem TRO.
Weichen tragen noch keine Layout-ID - tro_flow speichert das Weichen-
bzw. Ausschleuselement heute nicht am TRO.
"""
items = tro.items
slots: list[Slot] = []
for i in range(items.get(TroItem.SEPARATOR, 0)):
slots.append(Slot(slot=f"separator{i + 1}", layout_id=_at(tro.separators, i),
roles=list(_SEPARATOR_ROLES)))
for i in range(items.get(TroItem.SWITCH, 0)):
slots.append(Slot(slot=f"switch{i + 1}", roles=list(_SWITCH_ROLES)))
for i in range(items.get(TroItem.SCANNER, 0)):
slots.append(Slot(slot=f"scanner{i + 1}", layout_id=_at(tro.scanners, i),
roles=list(_SCANNER_ROLES)))
if items.get(TroItem.VARIO_DRIVE):
slots.append(Slot(slot="drive", layout_id=_at(tro.hosts, 0),
roles=list(_DRIVE_ROLES)))
return slots
def derive_jam_areas(tros: list[DerivedTro]) -> list[JamArea]:
"""Staubereiche: je Separator laut Katalog ein Eingang des TRO.
Kennung J<Separator>; fehlt der Separator im Layout, J<TRO-ID>_<n>. Alle
Vorgaenger fahren in den ersten Eingang ein - bei zwei Eingaengen
(2Sep-Typen) ist die Aufteilung aus dem Layout nicht ableitbar und als
Hinweis vermerkt.
"""
areas: list[JamArea] = []
for tro in tros:
n_entries = max(1, tro.items.get(TroItem.SEPARATOR, 1))
if n_entries > 1 and tro.predecessors:
note = "Aufteilung der Vorgaenger auf die Eingaenge offen"
elif tro.items.get(TroItem.STORAGE_LINE):
# Die Separatoren eines Speichers sitzen auf den Lagerlinien; der
# Eingang liegt davor und hat im Layout keinen eigenen Separator.
note = "Eingang des Speichers - Kennung vorlaeufig nach erstem Linien-Separator"
else:
note = ""
for n in range(1, n_entries + 1):
sep = _at(tro.separators, n - 1)
areas.append(JamArea(
jam_id=f"J{sep}" if sep else f"J{tro.tro_id}_{n}",
tro=tro.tro_id,
entry=n,
separator=sep,
host=_at(tro.hosts, 0),
predecessors=list(tro.predecessors) if n == 1 else [],
note=note,
position=tro.position if n == 1 else None,
))
return areas