model.py: gemeinsames TRO-Modell; Hauptweg CSV -> tro_flow --json -> scl_skeleton
- lib/model.py (neu, pydantic): TroBase -> DerivedTro, Connection, OpenPoint, Analysis (aus tro_flow verschoben), TroDocument = TRO-JSON (Schema sps_skel-tro-v2). items/fbType werden immer ueber den Typ aus tro_catalog gelesen, nie gespeichert; abweichende Werte in einer JSON sind ein Fehler. JSON-Schluessel wie bisher (id, type, fbType ...), extra="forbid". - tro_flow: Tro/Analysis aus model.py; neuer Schalter --json schreibt <name>_tro.json (TroDocument) - scl_skeleton: liest die JSON ueber TroDocument statt per Dict-Zugriff, Bauteile ueber TroItem-Konstanten; ungueltige JSON -> Exit 1 ohne SCL, alte tro_extract-JSON (v1) mit Hinweis auf tro_flow --json - tro_annotate: nur noch Ansicht; derselbe analyse_tros-Aufruf wie tro_flow (inkl. sepliste-Gegenprobe), damit Zeichnung und JSON uebereinstimmen - tro_extract: geparkt (unveraendert) - tests/test_model.py + conftest.py, pytest in requirements.txt aktiviert - Doku: Python_Scripts.md (§6b model.py), Commandlines.md, CLAUDE.md, Aufrufkommentare in bin/ (scl_skeleton.bat zeigte tro_flow-Aufrufe) Geprueft: alle tro_flow/tro_annotate-Ausgaben (8 CSVs) bis auf Zeitstempel identisch zum vorherigen Stand; scl_skeleton alt/neu auf derselben JSON byte-identisch (export, mubea, HundM05); 10 Tests gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
90cafec99c
commit
97a0db6707
+306
@@ -0,0 +1,306 @@
|
||||
# -*- 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
|
||||
OpenPoint eine gemeldete Stelle, die noch kein TRO ist
|
||||
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
|
||||
|
||||
In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`,
|
||||
`fbType`, `openPoints`, ...); im Python-Code heissen die Felder weiter
|
||||
`tro_id`/`type_name` (Aliase, `populate_by_name`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
computed_field,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from tro_catalog import TroDefinition, get_tro
|
||||
|
||||
__all__ = [
|
||||
"SCHEMA",
|
||||
"TroBase",
|
||||
"DerivedTro",
|
||||
"Connection",
|
||||
"OpenPoint",
|
||||
"Analysis",
|
||||
"TroDocument",
|
||||
]
|
||||
|
||||
# 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).
|
||||
SCHEMA = "sps_skel-tro-v2"
|
||||
|
||||
# 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 TroBase(BaseModel):
|
||||
"""
|
||||
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
|
||||
|
||||
# -- Position ------------------------------------------------------------
|
||||
|
||||
@field_validator("position", mode="before")
|
||||
@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")
|
||||
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 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")
|
||||
|
||||
@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 OpenPoint(BaseModel):
|
||||
"""Eine im Review gemeldete Stelle, die noch kein eigener TRO ist."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
name: str
|
||||
x: float
|
||||
y: float
|
||||
z: float | None = None
|
||||
expect: str = ""
|
||||
belongs_to: str = Field(default="", alias="belongsTo")
|
||||
note: str = ""
|
||||
|
||||
def describe_position(self) -> str:
|
||||
return f"{self.x:.0f}, {self.y:.0f}"
|
||||
|
||||
|
||||
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)
|
||||
open_points: list[OpenPoint] = Field(default_factory=list, alias="openPoints")
|
||||
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."""
|
||||
return cls(
|
||||
source=source,
|
||||
note=note,
|
||||
tros=analysis.tros,
|
||||
boundaries=analysis.boundaries,
|
||||
connections=[
|
||||
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
|
||||
for src, dst in analysis.edges
|
||||
],
|
||||
findings=analysis.findings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, text: str) -> TroDocument:
|
||||
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
|
||||
return cls.model_validate_json(text)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user