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>
682 lines
26 KiB
Python
682 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
model.py - Gemeinsames Datenmodell der TRO-Instanzen und der TRO-JSON.
|
|
|
|
Schichten (wer darf wen importieren):
|
|
|
|
tro_catalog.py Typ-Ebene: was ein TRO-*Typ* ist (Bauteile, FB, Farbe, Symbol)
|
|
model.py Instanz-Ebene: ein konkreter TRO, die ganze TRO-JSON <- hier
|
|
importiert nur tro_catalog
|
|
tro_flow.py Ableitung: CSV-Graph -> Analysis -> TroDocument
|
|
scl_skeleton.py Ausgabe: TroDocument -> FB_Main.scl
|
|
tro_annotate.py Ausgabe: Analysis -> annotierte Zeichnung (nur Ansicht)
|
|
|
|
Klassen:
|
|
|
|
TroBase was jeder TRO hat, egal woher er stammt: ID, Typ, Separatoren,
|
|
Vorgaenger/Nachfolger, Position. FB-Baustein und Bauteile
|
|
werden aus dem Katalog gelesen, nie gespeichert.
|
|
DerivedTro TroBase + was tro_flow bei der Ableitung zusaetzlich weiss
|
|
(Hosts, Scanner, Begruendung, Positionsquelle ...)
|
|
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
|
|
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
|
|
`tro_id`/`type_name` (Aliase, `populate_by_name`). Gleiche Daten heissen in
|
|
allen Klassen gleich (auch in tro_overrides.py): `type_name`, `separators`,
|
|
`hosts`, `position`, `reason`, `confidence`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
computed_field,
|
|
field_serializer,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from tro_catalog import TroDefinition, TroItem, get_tro
|
|
|
|
__all__ = [
|
|
"SCHEMA",
|
|
"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). 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
|
|
# beide.
|
|
_CONFIG = ConfigDict(
|
|
validate_assignment=True,
|
|
extra="forbid",
|
|
populate_by_name=True,
|
|
)
|
|
|
|
|
|
class _Positioned(BaseModel):
|
|
"""
|
|
Anlagenkoordinate (mm) als Feld ``position``, in der JSON {"x", "y", "z"}.
|
|
|
|
Das Feld selbst deklariert jede Unterklasse, damit es dort in der
|
|
JSON-Reihenfolge an seiner Stelle steht.
|
|
"""
|
|
|
|
if TYPE_CHECKING:
|
|
position: Any
|
|
|
|
@field_validator("position", mode="before", check_fields=False)
|
|
@classmethod
|
|
def _position_from_json(cls, value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return (value["x"], value["y"], value.get("z", 0.0))
|
|
return value
|
|
|
|
@field_serializer("position", when_used="json-unless-none", check_fields=False)
|
|
def _position_to_json(self, value: tuple[float, float, float]) -> dict[str, float]:
|
|
return {"x": round(value[0], 2), "y": round(value[1], 2), "z": round(value[2], 2)}
|
|
|
|
@property
|
|
def x(self) -> float | None:
|
|
return self.position[0] if self.position else None
|
|
|
|
@property
|
|
def y(self) -> float | None:
|
|
return self.position[1] if self.position else None
|
|
|
|
@property
|
|
def z(self) -> float | None:
|
|
return self.position[2] if self.position else None
|
|
|
|
def describe_position(self) -> str:
|
|
"""Koordinate als Text, z. B. '1030, 4700'."""
|
|
if not self.position:
|
|
return "-"
|
|
return f"{self.position[0]:.0f}, {self.position[1]:.0f}"
|
|
|
|
|
|
class TroBase(_Positioned):
|
|
"""
|
|
Kern eines TRO (Instanz in einer Anlage), unabhaengig von seiner Quelle.
|
|
|
|
Gegenstueck zu :class:`tro_catalog.TroDefinition`: die Definition beschreibt
|
|
den *Typ* (FB-Baustein, Bauteile), ein TRO die konkrete Stelle im Layout.
|
|
Verknuepft ueber ``type_name``, siehe :meth:`definition`.
|
|
|
|
FB-Baustein und Bauteile haengen nur am Typ. Sie werden nicht gespeichert,
|
|
sondern bei jedem Zugriff aus dem Katalog gelesen (``fb_block``, ``items``)
|
|
und so auch in die JSON geschrieben. Steht beim Einlesen einer JSON ein
|
|
Wert drin, der nicht zum Katalog passt, ist das ein Fehler - den Typ
|
|
aendern, nicht die Bauteile.
|
|
|
|
Listenfelder werden beim Pruefen kopiert; ``tro.hosts.append(...)`` aendert
|
|
also nur diesen TRO.
|
|
"""
|
|
|
|
model_config = _CONFIG
|
|
|
|
tro_id: str = Field(alias="id")
|
|
type_name: str = Field(alias="type")
|
|
confidence: str = ""
|
|
separators: list[str] = Field(default_factory=list)
|
|
predecessors: list[str] = Field(default_factory=list)
|
|
successors: list[str] = Field(default_factory=list)
|
|
# Anlagenkoordinate (mm); in der JSON als {"x": .., "y": .., "z": ..}
|
|
position: tuple[float, float, float] | None = None
|
|
|
|
# -- aus dem Katalog -----------------------------------------------------
|
|
|
|
def definition(self) -> TroDefinition | None:
|
|
"""Typdefinition aus tro_catalog (None wenn Typ unbekannt)."""
|
|
return get_tro(self.type_name)
|
|
|
|
@computed_field(alias="fbType") # type: ignore[prop-decorator]
|
|
@property
|
|
def fb_block(self) -> str:
|
|
"""FB-Baustein laut Katalog, z. B. 'FB_ILS_MTRO_1Sep' ("" wenn Typ unbekannt)."""
|
|
definition = self.definition()
|
|
return definition.get_fb_block() if definition else ""
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def items(self) -> dict[str, int]:
|
|
"""Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt)."""
|
|
definition = self.definition()
|
|
return definition.get_items() if definition else {}
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def _check_catalog_fields(cls, data: Any) -> Any:
|
|
"""`items`/`fbType` aus einer JSON gegen den Katalog pruefen und entfernen."""
|
|
if not isinstance(data, dict) or not ({"items", "fbType", "fb_block"} & data.keys()):
|
|
return data
|
|
data = dict(data)
|
|
items = data.pop("items", None)
|
|
fb_block = data.pop("fbType", data.pop("fb_block", None))
|
|
type_name = data.get("type", data.get("type_name"))
|
|
definition = get_tro(type_name) if isinstance(type_name, str) else None
|
|
expected_items = definition.get_items() if definition else {}
|
|
expected_fb = definition.get_fb_block() if definition else ""
|
|
if items is not None and dict(items) != expected_items:
|
|
raise ValueError(
|
|
f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog "
|
|
f"({expected_items}) - den Typ aendern, nicht die Bauteile"
|
|
)
|
|
if fb_block is not None and (fb_block or "") != expected_fb:
|
|
raise ValueError(
|
|
f"fbType '{fb_block}' passt nicht zum Typ '{type_name}' laut "
|
|
f"tro_catalog ('{expected_fb}')"
|
|
)
|
|
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 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).
|
|
|
|
Zusaetzlich zum Kern: aus welchen Bauteilen er gebildet wurde, warum er
|
|
diesen Typ hat, und woher seine Position stammt.
|
|
"""
|
|
|
|
label: str
|
|
hosts: list[str]
|
|
scanners: list[str]
|
|
reason: str
|
|
colocated: list[str] = Field(default_factory=list)
|
|
# 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:
|
|
return bool(self.successors or self.predecessors or self.colocated)
|
|
|
|
|
|
class Connection(BaseModel):
|
|
"""Gerichtete Verbindung zwischen zwei TROs/Stationen (JSON: from/to/kind)."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
src: str = Field(alias="from")
|
|
dst: str = Field(alias="to")
|
|
# transfer / umlauf / weiche / unbestimmt / normal (siehe tro_flow.link_tros)
|
|
kind: str = ""
|
|
|
|
|
|
class TroCandidate(_Positioned):
|
|
"""
|
|
Eine im Review gemeldete Stelle, an der ein TRO erwartet wird.
|
|
|
|
Kein TRO: keine Separatoren, keine Verbindungen, keine SCL. Das Bauteil
|
|
steckt heute in einem anderen TRO (``belongs_to``). ``type_name`` ist der
|
|
hier erwartete Katalogtyp.
|
|
"""
|
|
|
|
model_config = _CONFIG
|
|
|
|
name: str
|
|
type_name: str = Field(default="", alias="type")
|
|
position: tuple[float, float, float]
|
|
belongs_to: str = Field(default="", alias="belongsTo")
|
|
note: str = ""
|
|
|
|
|
|
class Analysis(BaseModel):
|
|
"""Ergebnis von tro_flow.analyse_tros: TROs, Kanten, Befunde."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
tros: list[DerivedTro] = Field(default_factory=list)
|
|
edges: list[tuple[str, str]] = Field(default_factory=list)
|
|
# (von, nach) -> Kantenart (transfer / umlauf / weiche / unbestimmt / normal)
|
|
edge_kinds: dict[tuple[str, str], str] = Field(default_factory=dict)
|
|
isolated: list[DerivedTro] = Field(default_factory=list)
|
|
unknown_types: list[str] = Field(default_factory=list)
|
|
findings: list[str] = Field(default_factory=list)
|
|
# Wie Host/Fanout ermittelt wurden (Doku/Bericht)
|
|
method: str = "Materialfluss-Graph (Zuordnung/Nachbarn)"
|
|
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
|
|
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
|
|
boundaries: list[DerivedTro] = Field(default_factory=list)
|
|
|
|
def by_id(self, tro_id: str) -> DerivedTro | None:
|
|
for tro in self.tros + self.boundaries:
|
|
if tro.tro_id == tro_id:
|
|
return tro
|
|
return None
|
|
|
|
|
|
class TroDocument(BaseModel):
|
|
"""
|
|
Die TRO-JSON einer Anlage (``<csv>_tro.json``).
|
|
|
|
Geschrieben von ``tro_flow --json`` (:meth:`from_analysis`, :meth:`to_json`),
|
|
gelesen von scl_skeleton (:meth:`from_json`). Weil das Modell beide Seiten
|
|
definiert, faellt ein umbenannter oder falscher Schluessel beim Einlesen
|
|
als Fehler auf.
|
|
"""
|
|
|
|
model_config = _CONFIG
|
|
|
|
schema_id: str = Field(default=SCHEMA, alias="schema")
|
|
source: str
|
|
created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
|
|
note: str = ""
|
|
tros: list[DerivedTro] = Field(default_factory=list)
|
|
# Systemgrenzen (SYSn): Materialein-/ausgang, kein TRO - keine SCL
|
|
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")
|
|
# 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 = "",
|
|
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. ``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(
|
|
source=source,
|
|
note=note,
|
|
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
|
|
],
|
|
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)."""
|
|
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"
|
|
|
|
def by_id(self, tro_id: str) -> DerivedTro | None:
|
|
for tro in self.tros + self.boundaries:
|
|
if tro.tro_id == tro_id:
|
|
return tro
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# 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")
|
|
_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. 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)):
|
|
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
|