hw_import + merge_hw_layout: Hardwareplanung einlesen und mit dem Layout zusammenfuehren

Stufen 2 und 3 aus doc/Konzept_Layout_Hardware_SCL.md:

- lib/hw_import.py: TIA-Import (*_TIA.xlsx, ohne openpyxl gelesen) +
  Positionsdatei (*_positionsconv.json) -> <projekt>_hw.json: je BMK
  Adresse, Index-Konstante, VERW, Station, Lage, Foerderer (K0102, #106 ->
  K0106); Antriebe MA/FC/GF; Gegenpruefung beider Quellen.
- lib/merge_hw_layout.py: <csv>_tro.json + <projekt>_hw.json ->
  <csv>_anlage.json (model.AnlageDocument): bindings je TRO-ID + Slot +
  Rolle mit Konstante und Adresse, Foerderer per Mehrheit (Mubea: K0102 =
  Kreisel3, K0104 = Kreisel2, K0105 = Kreisel1), Handkorrekturen
  cfg/hw_overrides.ini. Zuordnungslogik aus e_planning.py hierher
  verschoben; e_planning re-exportiert sie, Ergebnis unveraendert.
- TRO-JSON traegt optional `parts` (Lage/Huellbox, tro_flow --json),
  PinStore bekommt Slots line<n>.
- Gespeicherter Lagebezug mit SHA-256 der Positionsdatei; Unterordner in
  Eingabenamen relativ zu SKEL_DATA.
- Doku: Python_Scripts.md 5d/5e, Konzept, E-Planung_Bruecke (Abgleich
  mubea_layout.dxf / CSV / E-Planung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-10-15 16:16:10 +02:00
co-authored by Claude Opus 5.5
parent c4122b4497
commit df24cf5adb
21 changed files with 3226 additions and 442 deletions
+224 -4
View File
@@ -26,9 +26,17 @@ Klassen:
Hardwareplanung dort fuellen muss
JamArea ein Staubereich: Eingang genau eines TRO (je Separator
laut Katalog), mit allen Vorgaengern, die dort einfahren
LayoutPart ein Bauteil des Layouts mit Lage und Huellbox - das Ziel
der raeumlichen Zuordnung in merge_hw_layout (die TRO-JSON traegt
es, damit merge_hw_layout das CSV nicht lesen muss)
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
Binding eine Zuordnung Hardware -> TRO-Slot-Rolle (merge_hw_layout)
ConveyorBinding ein Foerderer der Hardwareplanung (K0102) an einem
Layout-Foerderer, mit Antrieb, Motorschutz, Geber
AnlageDocument die Anlagen-JSON (<csv>_anlage.json): Layout unveraendert
plus bindings[] - von merge_hw_layout geschrieben
In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`,
`fbType`, `candidates`, ...); im Python-Code heissen die Felder weiter
@@ -59,22 +67,44 @@ __all__ = [
"TroBase",
"Slot",
"JamArea",
"LayoutPart",
"DerivedTro",
"Connection",
"TroCandidate",
"Analysis",
"TroDocument",
"Registration",
"Binding",
"ConveyorBinding",
"AnlageDocument",
"ANLAGE_SCHEMA",
"BINDING_STATUS",
"derive_slots",
"derive_jam_areas",
"storage_lines",
]
# 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`, 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).
# Rendern abgeleitet, siehe TroDocument.jam_areas_or_derived). Seit 2026-10-15
# traegt v4 optional `parts` (Lage der Layout-Bauteile fuer merge_hw_layout); eine
# v4-Datei ohne `parts` liest sich weiter, nur merge_hw_layout braucht sie.
SCHEMA = "sps_skel-tro-v4"
# Kennung der Anlagen-JSON (<csv>_anlage.json, merge_hw_layout)
ANLAGE_SCHEMA = "sps_skel-anlage-v1"
# Zustand einer Zuordnung (doc/Konzept_Layout_Hardware_SCL.md, Umbau 3):
# zugeordnet ein BMK steht fest
# offen Pflichtrolle eines Slots ohne BMK -> TODO(E-Planung)
# widerspruch BMK steht fest, passt aber nicht (fehlt im TIA-Import,
# doppelt vergeben, Handkorrektur auf unbekanntes BMK)
# ohne_slot E-Bauteil einem Layout-Bauteil zugeordnet, das (noch) an
# keinem TRO-Slot haengt (z. B. Ausschleuselemente)
BINDING_STATUS = ("zugeordnet", "offen", "widerspruch", "ohne_slot")
# Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
# still auf einen Standardwert zurueckzufallen), Aliase und Feldnamen gelten
@@ -246,6 +276,34 @@ class JamArea(_Positioned):
position: tuple[float, float, float] | None = None
class LayoutPart(_Positioned):
"""
Ein Bauteil des Layouts (eine CSV-Zeile mit Lage), z. B. Separator,
Ausschleuselement, Kreisel.
Ziel der raeumlichen Zuordnung in merge_hw_layout: die Slots nennen nur die
``layout_id``, die Lage steht hier. ``bbox`` ist die achsparallele
Ausdehnung (mm) um ``position`` aus der CSV-Spalte "Boundingbox" -
gebraucht, um Geraete einem langen Foerderer (Kreisel) zuzuordnen.
"""
model_config = _CONFIG
layout_id: str = Field(alias="layoutId")
kind: str
name: str = ""
position: tuple[float, float, float] | None = None
bbox: tuple[float, float, float] | None = None
def distance_to_box(self, x: float, y: float) -> float | None:
"""Abstand eines Punkts zur Huellbox (0 innerhalb); None ohne Lage/Box."""
if not self.position or not self.bbox:
return None
dx = max(abs(x - self.position[0]) - self.bbox[0] / 2, 0.0)
dy = max(abs(y - self.position[1]) - self.bbox[1] / 2, 0.0)
return (dx * dx + dy * dy) ** 0.5
class DerivedTro(TroBase):
"""
Ein von tro_flow aus dem CSV abgeleiteter TRO (oder eine Systemgrenze).
@@ -346,14 +404,18 @@ class TroDocument(BaseModel):
connections: list[Connection] = Field(default_factory=list)
candidates: list[TroCandidate] = Field(default_factory=list)
jam_areas: list[JamArea] = Field(default_factory=list, alias="jamAreas")
# Layout-Bauteile mit Lage (fuer merge_hw_layout); leer in aelteren Dateien
parts: list[LayoutPart] = Field(default_factory=list)
findings: list[str] = Field(default_factory=list)
@classmethod
def from_analysis(cls, analysis: Analysis, source: str, note: str = "") -> TroDocument:
def from_analysis(cls, analysis: Analysis, source: str, note: str = "",
parts: list[LayoutPart] | None = None) -> TroDocument:
"""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.
traegt und nicht erst das Rendern sie kennt. ``parts`` (Lage der
Layout-Bauteile) gibt tro_flow aus dem CSV mit.
"""
tros = [t.model_copy(update={"slots": derive_slots(t)}) for t in analysis.tros]
return cls(
@@ -362,6 +424,7 @@ class TroDocument(BaseModel):
tros=tros,
boundaries=analysis.boundaries,
jam_areas=derive_jam_areas(tros),
parts=list(parts or []),
connections=[
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
for src, dst in analysis.edges
@@ -369,6 +432,11 @@ class TroDocument(BaseModel):
findings=analysis.findings,
)
def part(self, layout_id: str) -> LayoutPart | None:
"""Layout-Bauteil zur TeileId; ein Bahn-Suffix ("0010-R") zaehlt nicht."""
base = layout_id.split("-", 1)[0]
return next((p for p in self.parts if p.layout_id == base), None)
@classmethod
def from_json(cls, text: str) -> TroDocument:
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
@@ -389,6 +457,138 @@ class TroDocument(BaseModel):
return None
# ---------------------------------------------------------------------------
# Anlagen-JSON: Layout + Zuordnungen der Hardwareplanung (merge_hw_layout)
# ---------------------------------------------------------------------------
class Registration(BaseModel):
"""Lagebezug E-Zeichnung -> Layout, mit dem die Zuordnung lief (Nachweis).
Kopie der Kennwerte aus dxf_registration.Transform - model.py importiert
dxf_registration bewusst nicht (Schichten, siehe Kopf).
"""
model_config = _CONFIG
dx: float
dy: float
rotation: float = 0.0
source: str = ""
matched: int = 0
candidates: int = 0
residual_max: float = Field(default=0.0, alias="residualMax")
description: str = ""
class Binding(BaseModel):
"""
Eine Zuordnung: Rolle ``role`` im Slot ``slot`` des TRO ``tro`` <- BMK.
Stabiler Schluessel ist TRO-ID + Slot + Rolle (TRO-IDs aus der ID-Datei);
``layout_id`` dient nur zur Nachverfolgung im aktuellen Layoutstand. Bei
``status == "ohne_slot"`` sind ``tro``/``slot`` leer und ``role`` ist die
Regel (``Ausschleus.stau``). ``constant``/``address`` kommen aus dem
TIA-Import und sind das, was im SCL steht (``cInBG0532``, ``%E053.2``).
"""
model_config = _CONFIG
tro: str = ""
slot: str = ""
role: str
layout_id: str = Field(default="", alias="layoutId")
bmk: str = ""
constant: str = ""
address: str = ""
# positionsdraw | hw_overrides.ini
source: str = ""
# raeumlich | nummer | hand
rule: str = ""
distance: float | None = None
runner_up: float | None = Field(default=None, alias="runnerUp")
status: str = "zugeordnet"
note: str = ""
@field_validator("status")
@classmethod
def _known_status(cls, value: str) -> str:
if value not in BINDING_STATUS:
raise ValueError(f"status '{value}' unbekannt, erlaubt: {', '.join(BINDING_STATUS)}")
return value
class ConveyorBinding(BaseModel):
"""
Ein Foerderer der Hardwareplanung (``K0102``, ``K0106``) an einem
Layout-Foerderer (``layout_id`` "0010" = Kreisel3).
Die Namenswelten passen nicht zusammen (Layout: Kreisel1..3, E-Planung:
K0102/K0104/K0105), deshalb raeumlich: alle Feldgeraete, deren
Bezeichnung den Foerderer nennt, stimmen fuer den naechsten
Layout-Foerderer ab (``votes`` von ``total``).
"""
model_config = _CONFIG
eplan_name: str = Field(alias="eplanName")
layout_id: str = Field(default="", alias="layoutId")
layout_name: str = Field(default="", alias="layoutName")
votes: int = 0
total: int = 0
motor: str = ""
protection: str = ""
encoder: str = ""
# mehrheit | hand
rule: str = ""
status: str = "zugeordnet"
note: str = ""
class AnlageDocument(BaseModel):
"""
Die Anlagen-JSON ``<csv>_anlage.json``: das Layout (TRO-JSON) unveraendert
unter ``layout``, daneben die Zuordnungen der Hardwareplanung.
Geschrieben von merge_hw_layout, kuenftig die eine Eingabe von scl_skeleton
(doc/Konzept_Layout_Hardware_SCL.md, Umbau 4).
"""
model_config = _CONFIG
schema_id: str = Field(default=ANLAGE_SCHEMA, alias="schema")
created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
layout_file: str = Field(default="", alias="layoutFile")
hw_file: str = Field(default="", alias="hwFile")
project: str = ""
registration: Registration | None = None
layout: TroDocument
bindings: list[Binding] = Field(default_factory=list)
conveyors: list[ConveyorBinding] = Field(default_factory=list)
# Feldgeraete mit Lage, die keine Zuordnung bekommen haben (BMK)
unassigned: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
def binding(self, tro_id: str, slot: str, role: str) -> Binding | None:
"""Zuordnung einer Slot-Rolle (Rolle ohne "?"), None wenn keine."""
role = role.rstrip("?")
return next((b for b in self.bindings
if b.tro == tro_id and b.slot == slot and b.role == role), None)
def conveyor_for(self, layout_id: str) -> ConveyorBinding | None:
"""Foerderer der Hardwareplanung an einem Layout-Foerderer ("0010-R" geht)."""
base = layout_id.split("-", 1)[0]
return next((c for c in self.conveyors
if c.layout_id == base and c.status == "zugeordnet"), None)
def to_json(self) -> str:
return self.model_dump_json(by_alias=True, indent=2) + "\n"
@classmethod
def from_json(cls, text: str) -> AnlageDocument:
return cls.model_validate_json(text)
# ---------------------------------------------------------------------------
# Ableitungen aus Katalogtyp und Topologie
# ---------------------------------------------------------------------------
@@ -399,23 +599,43 @@ _SWITCH_ROLES = ("valve", "exitSensor?")
_SCANNER_ROLES = ("reader",)
_DRIVE_ROLES = ("motor", "motorProtection", "senCarrInPos", "senFinger",
"senLastPos", "senJam")
_STORAGE_LINE_ROLES = ("sensor", "stopper", "jamSensor?")
def _at(values: list[str], index: int) -> str:
return values[index] if index < len(values) else ""
def storage_lines(tro: TroBase) -> list[str]:
"""Separatoren der Lagerlinien eines Speicher-TRO.
tro_flow legt sie als [steuernde ..., Linien ...] ab; die Linien sind die
letzten len(hosts) (eine je Speicherbahn). Fehlen Hosts, zaehlt jeder
Separator als Linie - dieselbe Regel wie scl_context._store.
"""
hosts = list(getattr(tro, "hosts", []) or [])
seps = list(tro.separators)
count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps)
return seps[len(seps) - count:]
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.
bzw. Ausschleuselement heute nicht am TRO. Ein Speicher bekommt je
Lagerlinie einen Slot ``line<n>``; der erste Separator-Slot und
``line1`` koennen dasselbe Bauteil sein (wie im PinStore-Template).
"""
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)))
if items.get(TroItem.STORAGE_LINE):
for n, sep in enumerate(storage_lines(tro), start=1):
slots.append(Slot(slot=f"line{n}", layout_id=sep,
roles=list(_STORAGE_LINE_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)):