- doc/Klassenhierarchie.md: Kapitel "Daten" - alle Daten der SCL-Referenzen nach Quelle (Layout, E-Planung, Namenskonvention, Parameter) - doc/E-Planung_Bruecke.md: Bruecke ueber *_positionsdraw.json des Kabellaengen-Tools, Befunde an Mubea - lib/e_planning.py + cfg/e_planning.ini + bin/e_planning.bat/.sh: Lagebezug E-Zeichnung -> CSV, Zuordnung BMK -> Layout-Bauteil nach Regeln (Abstand, Sollabstand, Nummernregel MB = BG + 1), <csv>_eplan.json + Bericht - scl_skeleton --eplan: Separator-Sensor und Stopperventil eingesetzt, ohne Schalter unveraenderte Ausgabe (Golden-Files gleich) - tests/test_e_planning.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
14 KiB
Python
418 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
scl_context.py - Render-Kontext fuer das FB_Main-Geruest.
|
|
|
|
Schritt 2 aus doc/SCL_Templates.md: alles, was scl_skeleton aus der TRO-JSON
|
|
*ableitet*, steht hier - Reihenfolge, Bauteilbloecke, Staubereiche,
|
|
Weichenziele, Namen. Die Ausgabe (heute die emit_*-Funktionen, spaeter die
|
|
Templates in lib/scl_templates/) liest nur noch diesen Kontext und rechnet
|
|
nichts mehr selbst.
|
|
|
|
Schichten:
|
|
|
|
model.py TroDocument (die TRO-JSON)
|
|
scl_context.py TroDocument -> SclFile <- hier
|
|
scl_skeleton.py SclFile -> SCL-Text
|
|
|
|
Namen (Separator-, Weichen-, MainTro-Nummer, Adressen ...) sind ein
|
|
:class:`SclName`: Wert plus Merker, ob er noch aus der E-Planung kommen muss.
|
|
Ohne E-Planung sind alle Platzhalter ``todo=True``. Mit einer Zuordnung aus
|
|
lib/e_planning.py (``eplan``) werden Separator-Sensor und Stopperventil der
|
|
zugeordneten Separatoren eingesetzt und ``todo`` geloescht; alles andere
|
|
bleibt offen. Die Namensregeln aus Schritt 4 (cfg/scl_naming.ini) arbeiten
|
|
spaeter genauso: Wert ersetzen, Merker loeschen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import deque
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from model import TroBase, TroDocument
|
|
from tro_catalog import TroItem
|
|
|
|
if TYPE_CHECKING:
|
|
from e_planning import EplanDocument
|
|
|
|
__all__ = [
|
|
"TODO",
|
|
"SclName",
|
|
"SclSeparator",
|
|
"SclSwitch",
|
|
"SclSwitchExit",
|
|
"SclScanner",
|
|
"SclJam",
|
|
"SclTro",
|
|
"SclFile",
|
|
"build_context",
|
|
"flow_order",
|
|
"reachable_via",
|
|
]
|
|
|
|
TODO = "TODO(E-Planung)"
|
|
|
|
_CONFIG = ConfigDict(extra="forbid")
|
|
|
|
|
|
class SclName(BaseModel):
|
|
"""Ein Name/eine Adresse in der SCL und ob er noch offen ist."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
value: str
|
|
# True: Platzhalter, muss aus der E-Planung kommen -> TODO-Kommentar
|
|
todo: bool = True
|
|
|
|
|
|
def _offen(platzhalter: str) -> SclName:
|
|
return SclName(value=platzhalter, todo=True)
|
|
|
|
|
|
def _bekannt(wert: str) -> SclName:
|
|
return SclName(value=wert, todo=False)
|
|
|
|
|
|
# Rolle in e_planning.ini -> wie der Name in der SCL geschrieben wird
|
|
# (Sensor als Konstante der DB_Inputs-Sensorliste, Ventil als Variable)
|
|
_SENSOR = "cIn{bmk}"
|
|
_VENTIL = "{bmk}"
|
|
|
|
|
|
class SclSeparator(BaseModel):
|
|
"""Ein stInSeparator<n>-Block."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
n: int
|
|
# Separatornummer aus dem Layout ("?" wenn der Typ mehr Separatoren hat
|
|
# als im Layout gefunden wurden)
|
|
layout: str
|
|
no: SclName
|
|
sensor: SclName
|
|
stopper: SclName
|
|
|
|
|
|
class SclSwitchExit(BaseModel):
|
|
"""Ein Abgang eines Weichen-TRO und die darueber erreichbaren TROs."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
via: str
|
|
targets: list[str]
|
|
|
|
|
|
class SclSwitch(BaseModel):
|
|
"""Ein stInSwitch<n>-Block plus sein FC_Direction-Aufruf."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
n: int
|
|
no: SclName
|
|
valve: SclName
|
|
# None: mehrere Weichen teilen sich die Abgaenge - Aufteilung ist eine
|
|
# Planungsentscheidung und wird nicht geraten
|
|
to1_destinations: int | None
|
|
# Ziel von nOutDirection, z. B. "#TRO07.nInDirection" bzw. "...nInDirection2"
|
|
direction_target: str
|
|
# Staubereich, dessen erster Traeger in FC_Direction ausgewertet wird
|
|
jam_db: SclName
|
|
|
|
|
|
class SclScanner(BaseModel):
|
|
"""Ein stInBarcodeReader-Block ("" als Suffix, wenn es nur einen gibt)."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
n: int
|
|
suffix: str
|
|
|
|
|
|
class SclJam(BaseModel):
|
|
"""Ein Staubereich am Ein- oder Ausgang (JamEntr<n> / JamExit<n>)."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
n: int
|
|
# Nachbar als Text fuer den Kommentar, SYSn als Station markiert
|
|
neighbour: str
|
|
db: SclName
|
|
# Stausensor des Ausgangs (stInSenJam<n>); None an Eingaengen
|
|
sensor: SclName | None = None
|
|
|
|
|
|
class SclTro(BaseModel):
|
|
"""Alles, was eine REGION fuer einen TRO braucht."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
id: str
|
|
# Nummernteil der ID, wie geschrieben ("TRO01" -> "01"); "" ohne Ziffern
|
|
nr: str
|
|
type: str
|
|
fb: str
|
|
region_title: str
|
|
layout_separators: list[str]
|
|
position: tuple[float, float] | None
|
|
predecessors: list[str]
|
|
successors: list[str]
|
|
confidence: str
|
|
|
|
separators: list[SclSeparator]
|
|
switches: list[SclSwitch]
|
|
switch_exits: list[SclSwitchExit]
|
|
has_vario: bool
|
|
# Vario-Sensoren in Aufrufreihenfolge (Parametername -> Sensor); leer ohne Vario
|
|
vario_sensors: dict[str, SclName]
|
|
scanners: list[SclScanner]
|
|
storage_lines: int
|
|
jam_entries: list[SclJam]
|
|
jam_exits: list[SclJam]
|
|
|
|
main_tro: SclName
|
|
safety: SclName
|
|
release: str
|
|
motor_protection: SclName
|
|
vario_motor: SclName
|
|
machine_state: SclName
|
|
# Nur in den Templates (Referenz-Aufrufstellen), nicht im generischen Aufbau:
|
|
# UDT des Prioritaetsmanagers ("udt_TRO107") und ob Richtung 2 Vorrang hat
|
|
priority_udt: SclName
|
|
prio_dir2: SclName
|
|
# Kreisel-Freigabe (xInCarouselRun1). Ob ein TRO am Kreisel liegt, steht
|
|
# nicht in der TRO-JSON - deshalb immer offen (doc/SCL_Templates.md §6).
|
|
carousel_run: SclName
|
|
|
|
@property
|
|
def has_scanner(self) -> bool:
|
|
return bool(self.scanners)
|
|
|
|
|
|
class SclFile(BaseModel):
|
|
"""Kontext der ganzen Datei: Kopf und die TROs in Fliessrichtung."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
source: str
|
|
created: str
|
|
start_reason: str
|
|
tros: list[SclTro] = Field(default_factory=list)
|
|
# Zuordnung zur E-Planung, aus der Namen eingesetzt wurden ("" = keine)
|
|
eplan_source: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def flow_order(tros: list[TroBase], start_id: str = "") -> tuple[list[TroBase], str]:
|
|
"""TROs in Fliessrichtung sortieren.
|
|
|
|
Startpunkt ist ein TRO ohne Vorgaenger - also die Stelle, an der Material
|
|
in die Anlage kommt. Solange Be-/Entladestation fehlen, ist der Fluss ein
|
|
geschlossener Kreis und es gibt keinen solchen TRO; dann muss der Kreis
|
|
irgendwo aufgetrennt werden. Das ist eine willkuerliche Wahl, deshalb wird
|
|
sie zurueckgegeben und im Kopf der SCL vermerkt (und laesst sich mit
|
|
--start ueberschreiben). Nicht erreichte TROs haengt die Funktion hinten
|
|
an, damit nie einer verlorengeht.
|
|
"""
|
|
nach_id = {t.tro_id: t for t in tros}
|
|
ohne_vorgaenger = sorted(t.tro_id for t in tros if not t.predecessors)
|
|
# Materialeingang ueber eine Station (SYSn von tro_flow.py): der TRO, den
|
|
# sie speist, ist der natuerliche Anfang - auch wenn er zusaetzlich einen
|
|
# TRO-Vorgaenger hat (Kreisel-Umlauf).
|
|
eingaenge = sorted({
|
|
t.tro_id for t in tros
|
|
for vor in t.predecessors if vor not in nach_id
|
|
})
|
|
|
|
if start_id and start_id in nach_id:
|
|
start, grund = [start_id], "per --start gesetzt"
|
|
elif ohne_vorgaenger:
|
|
start, grund = ohne_vorgaenger, "TRO ohne Vorgaenger (Materialeingang)"
|
|
elif eingaenge:
|
|
stationen = sorted({
|
|
vor for i in eingaenge for vor in nach_id[i].predecessors
|
|
if vor not in nach_id
|
|
})
|
|
start = eingaenge
|
|
grund = f"Materialeingang ueber {', '.join(stationen)}"
|
|
else:
|
|
start = [min(nach_id)]
|
|
grund = ("Kreislauf ohne Eingang - willkuerlich aufgetrennt; sobald "
|
|
"Be-/Entladestation im Export stehen, ergibt sich der Anfang")
|
|
|
|
gesehen: list[str] = []
|
|
queue = deque(start)
|
|
while queue:
|
|
tro_id = queue.popleft()
|
|
if tro_id in gesehen:
|
|
continue
|
|
gesehen.append(tro_id)
|
|
for nxt in nach_id[tro_id].successors if tro_id in nach_id else []:
|
|
# SYSn (Materialausgang) ist kein TRO - keine REGION, keine Instanz.
|
|
if nxt in nach_id and nxt not in gesehen:
|
|
queue.append(nxt)
|
|
for tro_id in sorted(nach_id):
|
|
if tro_id not in gesehen:
|
|
gesehen.append(tro_id)
|
|
return [nach_id[i] for i in gesehen], grund
|
|
|
|
|
|
def reachable_via(start: str, first: str, tros: dict[str, TroBase]) -> list[str]:
|
|
"""Welche TROs sind ueber diesen Abgang erreichbar?
|
|
|
|
Das ist die Zielliste eines Weichenabgangs: von `first` aus weiterlaufen,
|
|
ohne ueber die Weiche selbst zurueckzugehen. Genau die Information, die
|
|
FC_Direction braucht, um zu entscheiden - und sie steht im Graphen.
|
|
"""
|
|
gesehen: set[str] = set()
|
|
queue = deque([first])
|
|
while queue:
|
|
node = queue.popleft()
|
|
if node in gesehen or node == start:
|
|
continue
|
|
gesehen.add(node)
|
|
if node in tros:
|
|
queue.extend(tros[node].successors)
|
|
return sorted(gesehen)
|
|
|
|
|
|
def _nachbar(node: str, alle: dict[str, TroBase]) -> str:
|
|
"""Nachbar fuer Kommentare; SYSn als Station kenntlich machen."""
|
|
return node if node in alle else f"{node} (Station, kein TRO)"
|
|
|
|
|
|
def _region_title(tro: TroBase) -> str:
|
|
seps = ", ".join(tro.separators)
|
|
teile = f" Sep {seps}" if seps else ""
|
|
return f"{tro.tro_id} ({tro.type_name}){teile}"
|
|
|
|
|
|
def _nummer(tro_id: str) -> str:
|
|
treffer = re.search(r"(\d+)$", tro_id)
|
|
return treffer.group(1) if treffer else ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kontext bauen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _separator(i: int, layout: str, eplan: EplanDocument | None) -> SclSeparator:
|
|
rollen = eplan.for_layout(layout) if eplan is not None and layout != "?" else {}
|
|
sensor = rollen.get("sensor")
|
|
stopper = rollen.get("stopper")
|
|
return SclSeparator(
|
|
n=i,
|
|
layout=layout,
|
|
no=_offen("cSep_____"),
|
|
sensor=_bekannt(_SENSOR.format(bmk=sensor.bmk)) if sensor else _offen("cInBG____"),
|
|
stopper=_bekannt(_VENTIL.format(bmk=stopper.bmk)) if stopper else _offen("MB____"),
|
|
)
|
|
|
|
|
|
def _tro_context(tro: TroBase, alle: dict[str, TroBase],
|
|
eplan: EplanDocument | None = None) -> SclTro:
|
|
items = tro.items
|
|
n_sep = items.get(TroItem.SEPARATOR, 1)
|
|
n_swi = items.get(TroItem.SWITCH, 0)
|
|
n_scan = items.get(TroItem.SCANNER, 0)
|
|
tro_id = tro.tro_id
|
|
jam_db = '"DB_JamArea____".stJam____'
|
|
|
|
separators = [
|
|
_separator(i, tro.separators[i - 1] if i <= len(tro.separators) else "?", eplan)
|
|
for i in range(1, n_sep + 1)
|
|
]
|
|
switches = [
|
|
SclSwitch(
|
|
n=j,
|
|
no=_offen("cSwi_____"),
|
|
valve=_offen("MB____"),
|
|
# Eine Weiche bedient alle Abgaenge - direkt ableitbar.
|
|
to1_destinations=len(tro.successors) if n_swi == 1 else None,
|
|
direction_target=(f"#{tro_id}.nInDirection" if n_swi == 1
|
|
else f"#{tro_id}.nInDirection{j}"),
|
|
jam_db=_offen(jam_db),
|
|
)
|
|
for j in range(1, n_swi + 1)
|
|
]
|
|
has_vario = bool(items.get(TroItem.VARIO_DRIVE))
|
|
vario_sensors = {
|
|
name: _offen("cInBG____")
|
|
for name in ("stSenCarrInPos", "stSenFinger", "stSenLastPos", "stSenJam")
|
|
} if has_vario else {}
|
|
switch_exits = [
|
|
SclSwitchExit(via=abgang, targets=reachable_via(tro_id, abgang, alle))
|
|
for abgang in tro.successors
|
|
] if n_swi else []
|
|
|
|
return SclTro(
|
|
id=tro_id,
|
|
nr=_nummer(tro_id),
|
|
type=tro.type_name,
|
|
fb=tro.fb_block or "FB_UNBEKANNT",
|
|
region_title=_region_title(tro),
|
|
layout_separators=list(tro.separators),
|
|
position=(tro.x, tro.y) if tro.position else None,
|
|
predecessors=list(tro.predecessors),
|
|
successors=list(tro.successors),
|
|
confidence=tro.confidence,
|
|
separators=separators,
|
|
switches=switches,
|
|
switch_exits=switch_exits,
|
|
has_vario=has_vario,
|
|
vario_sensors=vario_sensors,
|
|
scanners=[
|
|
SclScanner(n=i, suffix="" if n_scan == 1 else str(i))
|
|
for i in range(1, n_scan + 1)
|
|
],
|
|
storage_lines=items.get(TroItem.STORAGE_LINE, 0),
|
|
# Staubereiche: je ankommender/abgehender Verbindung einer. Welche
|
|
# DB-Struktur dahintersteht, ist eine Planungsentscheidung - hier steht
|
|
# nur, WIE VIELE es sein muessen und zu welchem Nachbarn sie gehoeren.
|
|
jam_entries=[
|
|
SclJam(n=n, neighbour=_nachbar(vor, alle), db=_offen(jam_db))
|
|
for n, vor in enumerate(tro.predecessors, start=1)
|
|
],
|
|
jam_exits=[
|
|
SclJam(n=n, neighbour=_nachbar(nach, alle), db=_offen(jam_db),
|
|
sensor=_offen("cInBG____"))
|
|
for n, nach in enumerate(tro.successors, start=2)
|
|
],
|
|
main_tro=_offen("cMainTro____"),
|
|
safety=_offen('"DB_InterfaceSafety".stFromSafety.EStopMain.Q'),
|
|
release=f'"DB_TRO_TEST".{tro_id}',
|
|
motor_protection=_offen('"FC____"'),
|
|
vario_motor=_offen("MA____"),
|
|
# Schaltschrank ist anlagenspezifisch ("...OF1" in der Referenz)
|
|
machine_state=_offen("#stInOutControlUnitCabinet"),
|
|
priority_udt=_offen("udt_____"),
|
|
# Vorschlag aus der Referenz TRO107, Prioritaet bleibt Planungsentscheidung
|
|
prio_dir2=_offen("TRUE"),
|
|
carousel_run=_offen("#fbCarousel____.xOutConveyorFullSpeed"),
|
|
)
|
|
|
|
|
|
def build_context(document: TroDocument, source: str, start_id: str = "",
|
|
created: str | None = None,
|
|
eplan: EplanDocument | None = None) -> SclFile:
|
|
"""TroDocument -> SclFile (TROs in Fliessrichtung).
|
|
|
|
eplan: Zuordnung Layout -> E-Planung (e_planning.py); ohne sie bleiben
|
|
alle Namen offen und die Ausgabe ist die bisherige.
|
|
"""
|
|
alle = {t.tro_id: t for t in document.tros}
|
|
geordnet, startgrund = flow_order(document.tros, start_id)
|
|
return SclFile(
|
|
source=source,
|
|
created=created or datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
start_reason=startgrund,
|
|
tros=[_tro_context(t, alle, eplan) for t in geordnet],
|
|
eplan_source=(f"{eplan.eplan_file} (Zuordnung vom {eplan.created})"
|
|
if eplan is not None else ""),
|
|
)
|