2074 lines
82 KiB
Python
2074 lines
82 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
|
|
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
|
|
# Der Interpreter wird von bin/material_flow.bat bzw. .sh gesetzt.
|
|
"""
|
|
material_flow.py - Materialfluss-Graph aus einem CSV-Export erzeugen.
|
|
|
|
Liest einen CSV-Export der Anlagenobjekte (ILS 2.0) aus dem data-Ordner und
|
|
erzeugt daraus einen gerichteten Graphen des Materialflusses.
|
|
|
|
Knotenmodell
|
|
------------
|
|
* Gefaellestrecke / Strecke (Foerderer) -> je ein Knoten
|
|
* Kreisel -> zwei Knoten (-L / -R); die Richtung
|
|
zwischen ihnen kommt aus dem Merkmal
|
|
"Drehrichtung": UZS = rechts nach
|
|
links, GUZ = links nach rechts
|
|
* Separator / Scanner -> kein eigener Knoten; sie werden
|
|
ueber "Zuordnung" am Label des
|
|
uebergeordneten Knotens angezeigt
|
|
|
|
Kantenmodell
|
|
------------
|
|
Die Spalte "Nachbarn" liefert die Verbindungen (symmetrisch, ohne Richtung).
|
|
Die Flussrichtung wird aus Hoehe und Antriebsrichtung abgeleitet:
|
|
|
|
* Gefaellestrecke -> bergab: vom hoechsten Nachbarn weg
|
|
* Strecke, Antriebfahrtrichtung -> "Auf": bergauf, zum hoechsten Nachbarn hin
|
|
"Ab": bergab, vom hoechsten Nachbarn weg
|
|
|
|
Verbindungen, deren Richtung sich so nicht bestimmen laesst (z. B. zwischen
|
|
zwei Kreiseln auf gleicher Hoehe), werden als beidseitige Kante gezeichnet und
|
|
im Bericht als Warnung ausgegeben.
|
|
|
|
Abgrenzung
|
|
----------
|
|
Dieses Programm behandelt ausschliesslich den Materialfluss der *mechanischen
|
|
Objekte*. Die Ableitung der Steuerungsobjekte (TROs) und ihr Flussdiagramm
|
|
liegen im Schwesterprogramm lib/tro_flow.py, das die hier definierten
|
|
Bausteine (read_elements, build_graph) weiterverwendet.
|
|
|
|
Ausgabe (alles in SKEL_RESULTS)
|
|
-------------------------------
|
|
<name>_material_flow.dot immer
|
|
<name>_material_flow.svg mit --tosvg (Graphviz)
|
|
<name>_material_flow.md mit --doc
|
|
|
|
Aufruf ueber bin/material_flow.bat bzw. bin/material_flow.sh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konstanten
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CSV_DELIMITER = ";"
|
|
CSV_ENCODING = "utf-8-sig"
|
|
TEILEART_PREFIX = "ILS 2.0 "
|
|
|
|
# Dekodierung der CSV-Spalten K1-K4 (Anschluss-Koordinatensysteme eines Blocks):
|
|
# 12-Zeichen-Base64-Strings mit je drei 24-Bit-Fixed-Point-Werten (x, y, z),
|
|
# nur Position ohne Rotation. Kodierung siehe csv:trans-encode in
|
|
# dxfmakros/Lisp/export.lsp (Faktor 10, gleiches Base64-Alphabet wie der
|
|
# Insertpoint). Rueckgabe in mm im selben Koordinatensystem wie 'Position'.
|
|
KX_B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
KX_B64_INDEX = {c: i for i, c in enumerate(KX_B64_CHARS)}
|
|
KX_TRANS_FAKTOR = 10.0
|
|
|
|
# Objektarten, die je einen Materialfluss-Knoten bilden
|
|
TRANSPORT_KINDS = ("Gefaellestrecke", "Strecke")
|
|
# Objektarten, die als Kreis aus zwei Bahnen (links/rechts) modelliert werden
|
|
CIRCLE_KINDS = ("Kreisel",)
|
|
# Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten)
|
|
DEVICE_KINDS = ("Separator", "Scanner")
|
|
|
|
# Ein-/Ausgabestationen (BTMT Be-/Entladung) = Materialquelle/-senke, d.h. die
|
|
# Ein-/Ausgangspunkte des Materialflusses. Sie werden in sps_skel erkannt;
|
|
# dxfmakros exportiert sie derzeit NICHT (Blockmuster fehlt), die Erkennung
|
|
# greift also, sobald eine passende Zeile im CSV auftaucht - erkannt an
|
|
# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung" -> Eingang, "Entladung"/
|
|
# "SC_Entladung"/"Abwurf" -> Ausgang.
|
|
ENTRY_KINDS = ("Beladung",) # BTMT-Beladung -> Materialeingang (Quelle)
|
|
EXIT_KINDS = ("Entladung",) # SC_Entladung -> Materialausgang (Senke)
|
|
STATION_KINDS = ENTRY_KINDS + EXIT_KINDS
|
|
|
|
# Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring)
|
|
STATION_PATTERNS = (
|
|
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")),
|
|
("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")),
|
|
)
|
|
|
|
# Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen
|
|
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
|
|
# Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel)
|
|
# Einschleuselement = Strecke -> Kreisel (am Streckenende, Material laeuft in Kreisel)
|
|
# Weiche = Uebergang zwischen zwei Kreiseln
|
|
# Merkmale: {"Kreisel","Strecke","Streckenende"} bzw. {"Kreisel A","Kreisel B"}.
|
|
TRANSFER_KINDS = ("Einschleus", "Ausschleus", "Weiche")
|
|
TRANSFER_PATTERNS = (
|
|
("Ausschleus", ("ausschleus",)),
|
|
("Einschleus", ("einschleus",)),
|
|
("Weiche", ("weiche",)),
|
|
)
|
|
|
|
LANE_LEFT = "L"
|
|
LANE_RIGHT = "R"
|
|
LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"}
|
|
|
|
UNASSIGNED = "nicht zugeordnet"
|
|
|
|
# Drehrichtung eines Kreisels (Merkmal "Drehrichtung"). Sie bestimmt, in welche
|
|
# Richtung das Material innerhalb des Kreisels zwischen den beiden Bahnen laeuft:
|
|
# UZS im Uhrzeigersinn -> von rechts nach links (R -> L)
|
|
# GUZ gegen den Uhrzeigersinn -> von links nach rechts (L -> R)
|
|
ROTATION_CW = "UZS"
|
|
ROTATION_CCW = "GUZ"
|
|
ROTATION_FLOW = {ROTATION_CW: "rechts nach links", ROTATION_CCW: "links nach rechts"}
|
|
|
|
# "0009-L" -> base 0009, lane L ; "0034" -> base 0034, lane None
|
|
NEIGHBOUR_RE = re.compile(r"^(?P<base>[^\s-]+)(?:-(?P<lane>[LR]))?$")
|
|
|
|
# Kantenarten
|
|
EDGE_FLOW = "flow"
|
|
EDGE_CIRCLE = "circle"
|
|
EDGE_UNRESOLVED = "unresolved"
|
|
|
|
# Darstellung je Objektart
|
|
NODE_STYLES = {
|
|
"Kreisel": dict(fillcolor="#2f5597", fontcolor="#ffffff", color="#1f3864"),
|
|
"Gefaellestrecke": dict(fillcolor="#fbe5d6", fontcolor="#833c00", color="#c55a11"),
|
|
"Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"),
|
|
"Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
|
|
"Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
|
|
}
|
|
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
|
|
|
|
# Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei
|
|
# Kreisel-Bahnen (gleicher Zulauf, gleicher Ablauf, gleiche Steigung) werden
|
|
# sie im Materialfluss-Diagramm zu einem Block zusammengefasst - sonst wird
|
|
# eine grosse Linienbank (Speicher) unlesbar breit.
|
|
MIN_GEFAELLE_GROUP = 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datenmodell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Element:
|
|
"""Eine Zeile des CSV-Exports."""
|
|
|
|
number: str
|
|
kind: str
|
|
teile_art: str
|
|
teile_id: str
|
|
name: str
|
|
plan_square: str
|
|
neighbours: list[tuple[str, str | None]]
|
|
merkmale: dict
|
|
row: int
|
|
position: tuple[float, float, float] | None = None
|
|
bbox: tuple[float, float, float] | None = None
|
|
# Anschluss-Koordinatensysteme K1..K4 aus den gleichnamigen CSV-Spalten,
|
|
# als (label, (x, y)) in mm; leere Spalten fehlen. Nur fuer --show-kx.
|
|
kpoints: list[tuple[str, tuple[float, float]]] = field(default_factory=list)
|
|
|
|
@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
|
|
|
|
@property
|
|
def bbox_x(self) -> float | None:
|
|
"""Boundingbox-Ausdehnung in X (mm), aus der Spalte 'Boundingbox'."""
|
|
return self.bbox[0] if self.bbox else None
|
|
|
|
@property
|
|
def bbox_y(self) -> float | None:
|
|
"""Boundingbox-Ausdehnung in Y (mm)."""
|
|
return self.bbox[1] if self.bbox else None
|
|
|
|
@property
|
|
def is_transport(self) -> bool:
|
|
return self.kind in TRANSPORT_KINDS
|
|
|
|
@property
|
|
def is_circle(self) -> bool:
|
|
return self.kind in CIRCLE_KINDS
|
|
|
|
@property
|
|
def is_device(self) -> bool:
|
|
return self.kind in DEVICE_KINDS
|
|
|
|
@property
|
|
def is_entry(self) -> bool:
|
|
"""BTMT-Beladung: Materialeingang (Quelle)."""
|
|
return self.kind in ENTRY_KINDS
|
|
|
|
@property
|
|
def is_exit(self) -> bool:
|
|
"""SC_Entladung: Materialausgang (Senke)."""
|
|
return self.kind in EXIT_KINDS
|
|
|
|
@property
|
|
def is_station(self) -> bool:
|
|
return self.kind in STATION_KINDS
|
|
|
|
@property
|
|
def is_transfer(self) -> bool:
|
|
"""Ein-/Ausschleuselement oder Weiche (liefert Richtung, kein Knoten)."""
|
|
return self.kind in TRANSFER_KINDS
|
|
|
|
@property
|
|
def height(self) -> float | None:
|
|
"""Montage-/Einbauhoehe in Meter, falls ableitbar."""
|
|
if self.is_circle:
|
|
return _as_float(_merkmal(self.merkmale, "Höhe in m", "Hoehe in m"))
|
|
return _as_float(_merkmal(self.merkmale, "Montagehoehe_m", "Montagehöhe_m"))
|
|
|
|
@property
|
|
def drive_dir(self) -> str:
|
|
return str(_merkmal(self.merkmale, "Antriebfahrtrichtung") or "").strip()
|
|
|
|
@property
|
|
def rotation(self) -> str:
|
|
"""Drehrichtung eines Kreisels: UZS (im Uhrzeigersinn) oder GUZ."""
|
|
return str(_merkmal(self.merkmale, "Drehrichtung") or "").strip().upper()
|
|
|
|
@property
|
|
def height_from(self) -> float | None:
|
|
"""Hoehe am Anfang des Objekts in mm (Merkmal Hoehe_Von_mm)."""
|
|
return _as_float(_merkmal(self.merkmale, "Hoehe_Von_mm", "Höhe_Von_mm"))
|
|
|
|
@property
|
|
def height_to(self) -> float | None:
|
|
"""Hoehe am Ende des Objekts in mm (Merkmal Hoehe_Bis_mm)."""
|
|
return _as_float(_merkmal(self.merkmale, "Hoehe_Bis_mm", "Höhe_Bis_mm"))
|
|
|
|
@property
|
|
def rise(self) -> float | None:
|
|
"""
|
|
Hoehenaenderung in Foerderrichtung in mm.
|
|
|
|
Positiv = das Objekt foerdert nach oben, negativ = nach unten. Die Werte
|
|
koennen absolut sein (Gefaellestrecke: 1941 -> 1466) oder relativ zum
|
|
Objekt (Strecke: 0 -> 474); nur das Vorzeichen wird ausgewertet.
|
|
"""
|
|
start, end = self.height_from, self.height_to
|
|
if start is None or end is None:
|
|
return None
|
|
return end - start
|
|
|
|
@property
|
|
def assignment(self) -> str:
|
|
return str(_merkmal(self.merkmale, "Zuordnung") or "").strip()
|
|
|
|
def describe(self) -> str:
|
|
return f"Zeile {self.row} ({self.kind} {self.teile_id} '{self.name}')"
|
|
|
|
|
|
@dataclass
|
|
class Node:
|
|
"""Ein Knoten des Materialfluss-Graphen."""
|
|
|
|
node_id: str
|
|
kind: str
|
|
element: Element
|
|
lane: str | None = None
|
|
devices: dict[str, list[str]] = field(default_factory=dict)
|
|
|
|
@property
|
|
def height(self) -> float | None:
|
|
return self.element.height
|
|
|
|
|
|
@dataclass
|
|
class Edge:
|
|
src: str
|
|
dst: str
|
|
kind: str
|
|
note: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Graph:
|
|
nodes: dict[str, Node] = field(default_factory=dict)
|
|
edges: list[Edge] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
unassigned: list[Element] = field(default_factory=list)
|
|
source: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hilfsfunktionen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _merkmal(merkmale: dict, *keys: str):
|
|
"""Merkmal ueber mehrere Schreibweisen hinweg lesen."""
|
|
for key in keys:
|
|
if key in merkmale:
|
|
return merkmale[key]
|
|
return None
|
|
|
|
|
|
def _as_float(value) -> float | None:
|
|
"""Zahl tolerant lesen ('1.94' und '1,94'). Listen wie '2.298,1' -> None."""
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
if text.count(",") == 1 and "." not in text:
|
|
text = text.replace(",", ".")
|
|
try:
|
|
return float(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _dot_escape(text: str) -> str:
|
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
def _dot_label(lines: list[str]) -> str:
|
|
return "\\n".join(_dot_escape(line) for line in lines if line)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pfade / Umgebung
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def project_root() -> Path:
|
|
env = os.environ.get("SPS_SKEL")
|
|
if env:
|
|
return Path(env)
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def env_dir(var: str, fallback: str) -> Path:
|
|
env = os.environ.get(var)
|
|
return Path(env) if env else project_root() / fallback
|
|
|
|
|
|
def resolve_input(name: str) -> Path:
|
|
"""Dateinamen gegen SKEL_DATA aufloesen; Pfadangaben bleiben unveraendert."""
|
|
candidate = Path(name)
|
|
if candidate.is_absolute() or candidate.parent != Path("."):
|
|
return candidate
|
|
return env_dir("SKEL_DATA", "data") / name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CSV einlesen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def decode_kpoint(raw: str) -> tuple[float, float] | None:
|
|
"""
|
|
K1-K4-Base64-String -> (x, y) in mm, oder None bei leer/ungueltig.
|
|
|
|
Die Spalten K1..K4 enthalten je 12 Zeichen (drei 24-Bit-Werte x, y, z);
|
|
fuer die Draufsicht interessiert nur die xy-Lage. Kodierung siehe
|
|
csv:trans-encode in dxfmakros/Lisp/export.lsp (vgl. KX_* oben).
|
|
"""
|
|
text = (raw or "").strip()
|
|
if len(text) < 8: # mindestens x und y (2 * 4 Zeichen)
|
|
return None
|
|
values: list[int] = []
|
|
for start in range(0, len(text) - 3, 4):
|
|
word = 0
|
|
for char in text[start:start + 4]:
|
|
if char not in KX_B64_INDEX:
|
|
return None
|
|
word = (word << 6) | KX_B64_INDEX[char]
|
|
if word >= 8388608: # Zweierkomplement (24 Bit)
|
|
word -= 16777216
|
|
values.append(word)
|
|
if len(values) < 2:
|
|
return None
|
|
return (values[0] / KX_TRANS_FAKTOR, values[1] / KX_TRANS_FAKTOR)
|
|
|
|
|
|
def parse_position(raw: str) -> tuple[float, float, float] | None:
|
|
"""'9510.00, -4882.95, 1942.50' -> (9510.0, -4882.95, 1942.5)"""
|
|
parts = [p.strip() for p in (raw or "").split(",")]
|
|
if len(parts) != 3:
|
|
return None
|
|
values = [_as_float(p) for p in parts]
|
|
if any(v is None for v in values):
|
|
return None
|
|
return (values[0], values[1], values[2])
|
|
|
|
|
|
def parse_neighbours(raw: str) -> list[tuple[str, str | None]]:
|
|
"""'0009-L, 0002-R' -> [('0009', 'L'), ('0002', 'R')]"""
|
|
result: list[tuple[str, str | None]] = []
|
|
for token in (raw or "").split(","):
|
|
token = token.strip()
|
|
if not token:
|
|
continue
|
|
match = NEIGHBOUR_RE.match(token)
|
|
if not match:
|
|
continue
|
|
result.append((match.group("base"), match.group("lane")))
|
|
return result
|
|
|
|
|
|
def _match_kind(patterns, *texts: str) -> str | None:
|
|
"""Erste Musterklasse, deren Schluesselwort in den Texten vorkommt."""
|
|
haystack = " ".join(t.lower() for t in texts if t)
|
|
for kind, keys in patterns:
|
|
if any(key in haystack for key in keys):
|
|
return kind
|
|
return None
|
|
|
|
|
|
def classify_station(*texts: str) -> str | None:
|
|
"""BTMT Be-/Entladestation an TeileArt/Bezeichnung erkennen (sonst None)."""
|
|
return _match_kind(STATION_PATTERNS, *texts)
|
|
|
|
|
|
def classify_transfer(*texts: str) -> str | None:
|
|
"""Ein-/Ausschleuselement bzw. Weiche erkennen (sonst None)."""
|
|
return _match_kind(TRANSFER_PATTERNS, *texts)
|
|
|
|
|
|
def read_elements(path: Path, warnings: list[str]) -> list[Element]:
|
|
"""CSV-Export einlesen."""
|
|
with path.open("r", encoding=CSV_ENCODING, newline="") as handle:
|
|
reader = csv.DictReader(handle, delimiter=CSV_DELIMITER)
|
|
if not reader.fieldnames or "TeileId" not in reader.fieldnames:
|
|
raise ValueError(
|
|
f"Unerwartete Kopfzeile in {path.name}: 'TeileId' nicht gefunden. "
|
|
f"Gefunden: {reader.fieldnames}"
|
|
)
|
|
|
|
elements: list[Element] = []
|
|
seen: dict[str, Element] = {}
|
|
|
|
for offset, row in enumerate(reader, start=2):
|
|
teile_id = (row.get("TeileId") or "").strip()
|
|
if not teile_id:
|
|
continue
|
|
|
|
teile_art = (row.get("TeileArt") or "").strip()
|
|
kind = teile_art[len(TEILEART_PREFIX):] if teile_art.startswith(TEILEART_PREFIX) else teile_art
|
|
# BTMT Be-/Entladestation als Ein-/Ausgang erkennen (sps_skel-seitig)
|
|
station_kind = classify_station(teile_art, row.get("Bezeichnung") or "")
|
|
if station_kind:
|
|
kind = station_kind
|
|
# Ein-/Ausschleuselement bzw. Weiche erkennen (liefern Richtung)
|
|
transfer_kind = classify_transfer(teile_art, row.get("Bezeichnung") or "")
|
|
if transfer_kind:
|
|
kind = transfer_kind
|
|
|
|
raw_merkmale = (row.get("Merkmale") or "").strip()
|
|
merkmale: dict = {}
|
|
if raw_merkmale:
|
|
try:
|
|
parsed = json.loads(raw_merkmale)
|
|
if isinstance(parsed, dict):
|
|
merkmale = parsed
|
|
else:
|
|
warnings.append(f"Zeile {offset}: Merkmale ist kein Objekt - ignoriert")
|
|
except json.JSONDecodeError as exc:
|
|
warnings.append(f"Zeile {offset}: Merkmale nicht lesbar ({exc.msg}) - ignoriert")
|
|
|
|
element = Element(
|
|
number=(row.get("Elementnummer") or "").strip(),
|
|
kind=kind,
|
|
teile_art=teile_art,
|
|
teile_id=teile_id,
|
|
name=(row.get("Bezeichnung") or "").strip(),
|
|
plan_square=(row.get("Planquadrat") or "").strip(),
|
|
neighbours=parse_neighbours(row.get("Nachbarn") or ""),
|
|
merkmale=merkmale,
|
|
row=offset,
|
|
position=parse_position(row.get("Position") or ""),
|
|
bbox=parse_position(row.get("Boundingbox") or ""),
|
|
kpoints=[
|
|
(label, xy)
|
|
for label in ("K1", "K2", "K3", "K4")
|
|
if (xy := decode_kpoint(row.get(label) or "")) is not None
|
|
],
|
|
)
|
|
|
|
if teile_id in seen:
|
|
warnings.append(
|
|
f"{element.describe()}: TeileId doppelt (bereits in Zeile {seen[teile_id].row}) - ignoriert"
|
|
)
|
|
continue
|
|
|
|
seen[teile_id] = element
|
|
elements.append(element)
|
|
|
|
return elements
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph aufbauen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node]:
|
|
nodes: dict[str, Node] = {}
|
|
for element in elements:
|
|
if element.is_transport:
|
|
nodes[element.teile_id] = Node(element.teile_id, element.kind, element)
|
|
elif element.is_circle:
|
|
for lane in (LANE_LEFT, LANE_RIGHT):
|
|
node_id = f"{element.teile_id}-{lane}"
|
|
nodes[node_id] = Node(node_id, element.kind, element, lane=lane)
|
|
elif element.is_station:
|
|
nodes[element.teile_id] = Node(element.teile_id, element.kind, element)
|
|
elif element.is_transfer:
|
|
# Ein-/Ausschleuselement / Weiche liefern nur die (gerichtete)
|
|
# Verbindung, siehe _apply_transfers - kein eigener Knoten.
|
|
continue
|
|
elif not element.is_device:
|
|
warnings.append(
|
|
f"{element.describe()}: unbekannte TeileArt '{element.teile_art}' - kein Knoten erzeugt"
|
|
)
|
|
return nodes
|
|
|
|
|
|
def _attach_devices(
|
|
elements: list[Element], nodes: dict[str, Node], warnings: list[str]
|
|
) -> list[Element]:
|
|
"""Separatoren/Scanner ueber 'Zuordnung' an ihren Knoten haengen."""
|
|
unassigned: list[Element] = []
|
|
for element in elements:
|
|
if not element.is_device:
|
|
continue
|
|
target = element.assignment
|
|
if not target or target == UNASSIGNED:
|
|
unassigned.append(element)
|
|
continue
|
|
if target not in nodes:
|
|
warnings.append(
|
|
f"{element.describe()}: Zuordnung '{target}' zeigt auf keinen Knoten - nicht dargestellt"
|
|
)
|
|
unassigned.append(element)
|
|
continue
|
|
nodes[target].devices.setdefault(element.kind, []).append(element.teile_id)
|
|
return unassigned
|
|
|
|
|
|
def _collect_links(
|
|
elements: list[Element], by_id: dict[str, Element], warnings: list[str]
|
|
) -> dict[frozenset, dict[str, set[str]]]:
|
|
"""
|
|
Verbindungen aus der Spalte 'Nachbarn' sammeln.
|
|
|
|
Ergebnis: {frozenset({idA, idB}): {idA: {Bahnen}, idB: {Bahnen}}}
|
|
Die Bahn (-L/-R) eines Kreisels steht immer in der Zeile des *Partners*,
|
|
daher werden beide Blickrichtungen zusammengefuehrt.
|
|
"""
|
|
observed: dict[frozenset, dict[str, set[str]]] = {}
|
|
|
|
for element in elements:
|
|
if not element.neighbours:
|
|
continue
|
|
if element.is_device:
|
|
warnings.append(
|
|
f"{element.describe()}: Nachbarn an einem Anbauteil werden nicht ausgewertet"
|
|
)
|
|
continue
|
|
if not (element.is_transport or element.is_circle):
|
|
continue
|
|
|
|
for base, lane in element.neighbours:
|
|
if base == element.teile_id:
|
|
warnings.append(f"{element.describe()}: Nachbar verweist auf sich selbst - ignoriert")
|
|
continue
|
|
partner = by_id.get(base)
|
|
if partner is None:
|
|
warnings.append(f"{element.describe()}: unbekannter Nachbar '{base}' - ignoriert")
|
|
continue
|
|
if partner.is_device or not (partner.is_transport or partner.is_circle):
|
|
warnings.append(
|
|
f"{element.describe()}: Nachbar '{base}' ({partner.kind}) ist kein Flussobjekt - ignoriert"
|
|
)
|
|
continue
|
|
|
|
key = frozenset((element.teile_id, base))
|
|
entry = observed.setdefault(key, {element.teile_id: set(), base: set()})
|
|
if lane:
|
|
entry[base].add(lane)
|
|
|
|
return observed
|
|
|
|
|
|
def _endpoints(
|
|
base: str, lanes: set[str], by_id: dict[str, Element], warnings: list[str], partner: str
|
|
) -> list[str]:
|
|
"""Basis-Id auf konkrete Knoten aufloesen (Kreisel: eine Bahn je Knoten)."""
|
|
element = by_id[base]
|
|
if not element.is_circle:
|
|
return [base]
|
|
if lanes:
|
|
return [f"{base}-{lane}" for lane in sorted(lanes)]
|
|
warnings.append(
|
|
f"Verbindung {base} <-> {partner}: keine Bahn (-L/-R) angegeben - "
|
|
f"beide Bahnen des Kreisels {base} werden verbunden"
|
|
)
|
|
return [f"{base}-{LANE_LEFT}", f"{base}-{LANE_RIGHT}"]
|
|
|
|
|
|
def drive_uphill(element: Element) -> tuple[bool | None, str]:
|
|
"""
|
|
Foerdert das Objekt nach oben?
|
|
|
|
True = Fluss laeuft zum hoechsten Nachbarn hin (bergauf)
|
|
False = Fluss laeuft vom hoechsten Nachbarn weg (bergab)
|
|
None = nicht bestimmbar
|
|
|
|
Bewertet wird in dieser Reihenfolge:
|
|
1. Hoehe_Von_mm / Hoehe_Bis_mm - die Hoehenaenderung des Objekts selbst
|
|
2. Antriebfahrtrichtung "Auf" (nach oben) bzw. "Ab" (nach unten)
|
|
3. Gefaellestrecke ohne Angaben laeuft per Definition bergab
|
|
|
|
Der zweite Rueckgabewert nennt die verwendete Quelle fuer Bericht und
|
|
Zeichnung.
|
|
"""
|
|
rise = element.rise
|
|
if rise is not None and rise != 0:
|
|
direction = "Auf" if rise > 0 else "Ab"
|
|
return rise > 0, (
|
|
f"Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis "
|
|
f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)"
|
|
)
|
|
|
|
drive = element.drive_dir.lower()
|
|
if drive.startswith("auf"):
|
|
return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)"
|
|
if drive.startswith("ab"):
|
|
return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)"
|
|
|
|
if element.kind == "Gefaellestrecke":
|
|
return False, "Gefaellestrecke ohne Hoehenangabe - laeuft bergab"
|
|
|
|
return None, (
|
|
f"weder Hoehe_Von/Bis noch Antriebfahrtrichtung verwertbar "
|
|
f"(Antrieb '{element.drive_dir}', Hoehen "
|
|
f"{element.height_from}/{element.height_to})"
|
|
)
|
|
|
|
|
|
def _flow_uphill(element: Element, warnings: list[str]) -> bool | None:
|
|
"""Wie drive_uphill, meldet aber unbestimmbare Faelle als Warnung."""
|
|
uphill, reason = drive_uphill(element)
|
|
if uphill is None:
|
|
warnings.append(f"{element.describe()}: {reason} - Richtung nicht bestimmbar")
|
|
return uphill
|
|
|
|
|
|
def _orient(
|
|
pairs: list[tuple[str, str]], nodes: dict[str, Node], warnings: list[str]
|
|
) -> list[Edge]:
|
|
"""Flussrichtung je Verbindung aus Hoehe und Antriebsrichtung ableiten."""
|
|
incident: dict[str, list[int]] = {}
|
|
for index, (left, right) in enumerate(pairs):
|
|
incident.setdefault(left, []).append(index)
|
|
incident.setdefault(right, []).append(index)
|
|
|
|
# Vorschlaege je Verbindung, gesammelt aus der Sicht der Transportobjekte
|
|
proposals: dict[int, set[tuple[str, str]]] = {index: set() for index in range(len(pairs))}
|
|
|
|
for node_id, node in sorted(nodes.items()):
|
|
if node.kind not in TRANSPORT_KINDS:
|
|
continue
|
|
|
|
indices = incident.get(node_id, [])
|
|
if not indices:
|
|
warnings.append(
|
|
f"{node.element.describe()}: keine Nachbarn - Knoten bleibt unverbunden"
|
|
)
|
|
continue
|
|
|
|
partners = {}
|
|
for index in indices:
|
|
left, right = pairs[index]
|
|
partners[index] = right if left == node_id else left
|
|
|
|
heights = {index: nodes[partner].height for index, partner in partners.items()}
|
|
missing = [partners[i] for i, h in heights.items() if h is None]
|
|
if missing:
|
|
warnings.append(
|
|
f"{node.element.describe()}: Hoehe von {', '.join(sorted(missing))} unbekannt - "
|
|
f"Richtung nicht bestimmbar"
|
|
)
|
|
continue
|
|
|
|
uphill = _flow_uphill(node.element, warnings)
|
|
if uphill is None:
|
|
continue
|
|
|
|
highest = max(heights.values())
|
|
if highest == min(heights.values()):
|
|
warnings.append(
|
|
f"{node.element.describe()}: alle Nachbarn auf gleicher Hoehe "
|
|
f"({highest:.3f} m) - Richtung nicht bestimmbar"
|
|
)
|
|
continue
|
|
|
|
for index, partner in partners.items():
|
|
at_top = heights[index] == highest
|
|
if uphill == at_top:
|
|
proposals[index].add((node_id, partner))
|
|
else:
|
|
proposals[index].add((partner, node_id))
|
|
|
|
edges: list[Edge] = []
|
|
for index, (left, right) in enumerate(pairs):
|
|
found = proposals[index]
|
|
if len(found) == 1:
|
|
src, dst = next(iter(found))
|
|
edges.append(Edge(src, dst, EDGE_FLOW))
|
|
elif len(found) > 1:
|
|
warnings.append(
|
|
f"Verbindung {left} <-> {right}: widerspruechliche Richtungen "
|
|
f"({', '.join(f'{s} -> {d}' for s, d in sorted(found))}) - beidseitig gezeichnet"
|
|
)
|
|
edges.append(Edge(left, right, EDGE_UNRESOLVED, "widerspruechlich"))
|
|
else:
|
|
warnings.append(
|
|
f"Verbindung {left} <-> {right}: Richtung nicht bestimmbar "
|
|
f"(kein Transportobjekt beteiligt) - beidseitig gezeichnet"
|
|
)
|
|
edges.append(Edge(left, right, EDGE_UNRESOLVED, "unbestimmt"))
|
|
|
|
return edges
|
|
|
|
|
|
def _nearest_node(station: Element, nodes: dict[str, Node]) -> str | None:
|
|
"""Naechster Nicht-Stations-Knoten zur Station (2D-Abstand)."""
|
|
if station.x is None or station.y is None:
|
|
return None
|
|
best, best_d = None, None
|
|
for node_id, node in nodes.items():
|
|
if node.element.is_station or node.element.x is None or node.element.y is None:
|
|
continue
|
|
d = (node.element.x - station.x) ** 2 + (node.element.y - station.y) ** 2
|
|
if best_d is None or d < best_d:
|
|
best, best_d = node_id, d
|
|
return best
|
|
|
|
|
|
def _attach_stations(
|
|
elements: list[Element], nodes: dict[str, Node], warnings: list[str]
|
|
) -> list[Edge]:
|
|
"""
|
|
Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden.
|
|
|
|
Eingang (Beladung) ist Quelle: Kante Station -> Ziel.
|
|
Ausgang (Entladung) ist Senke: Kante Ziel -> Station.
|
|
Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert
|
|
fuer BTMT derzeit keine), wird das naechstgelegene Objekt per Abstand genommen.
|
|
"""
|
|
edges: list[Edge] = []
|
|
for element in elements:
|
|
if not element.is_station:
|
|
continue
|
|
sid = element.teile_id
|
|
if sid not in nodes:
|
|
continue
|
|
partners: list[str] = []
|
|
for base, lane in element.neighbours:
|
|
if lane and f"{base}-{lane}" in nodes:
|
|
partners.append(f"{base}-{lane}")
|
|
elif base in nodes:
|
|
partners.append(base)
|
|
else:
|
|
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
|
|
if f"{base}-{L}" in nodes]
|
|
if not partners:
|
|
nearest = _nearest_node(element, nodes)
|
|
if nearest:
|
|
partners = [nearest]
|
|
warnings.append(
|
|
f"{element.describe()}: keine Nachbarn im Export - "
|
|
f"naechstes Objekt '{nearest}' per Abstand verbunden"
|
|
)
|
|
else:
|
|
warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden")
|
|
continue
|
|
for partner in partners:
|
|
if element.is_entry:
|
|
edges.append(Edge(sid, partner, EDGE_FLOW))
|
|
else:
|
|
edges.append(Edge(partner, sid, EDGE_FLOW))
|
|
return edges
|
|
|
|
|
|
def _kreisel_lane_node(kreisel_base: str, strecke: Element, nodes: dict[str, Node]) -> str | None:
|
|
"""Die konkrete Kreisel-Bahn (…-L/…-R), an der die Strecke haengt."""
|
|
for base, lane in strecke.neighbours:
|
|
if base == kreisel_base:
|
|
node_id = f"{base}-{lane}" if lane else base
|
|
return node_id if node_id in nodes else None
|
|
for lane in (LANE_LEFT, LANE_RIGHT): # Fallback: irgendeine Bahn
|
|
if f"{kreisel_base}-{lane}" in nodes:
|
|
return f"{kreisel_base}-{lane}"
|
|
return kreisel_base if kreisel_base in nodes else None
|
|
|
|
|
|
def _apply_transfers(
|
|
elements: list[Element], by_id: dict[str, Element],
|
|
nodes: dict[str, Node], edges: list[Edge], warnings: list[str]
|
|
) -> list[Edge]:
|
|
"""
|
|
Kreisel<->Strecke-Kanten anhand der Transferelemente gerichtet setzen.
|
|
|
|
Ausschleuselement: Kreisel -> Strecke (Streckenanfang).
|
|
Einschleuselement: Strecke -> Kreisel (Streckenende).
|
|
Weiche: Kreisel<->Kreisel als Weiche markieren.
|
|
Die so definierten Kanten sind massgeblich und ersetzen die aus Hoehe/
|
|
Nachbarn abgeleitete Richtung fuer genau diese Knotenpaare.
|
|
"""
|
|
# Transfer je Strecke sammeln: aus = Einlauf (Kreisel->Strecke, Anfang),
|
|
# ein = Auslauf (Strecke->Kreisel, Ende)
|
|
per_strecke: dict[str, dict[str, str]] = {}
|
|
weiche_pairs: list[set[str]] = []
|
|
for el in elements:
|
|
if el.kind in ("Ausschleus", "Einschleus"):
|
|
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
|
|
s = str(_merkmal(el.merkmale, "Strecke") or "").strip()
|
|
if k and s:
|
|
role = "aus" if el.kind == "Ausschleus" else "ein"
|
|
per_strecke.setdefault(s, {})[role] = k
|
|
elif el.kind == "Weiche":
|
|
a = str(_merkmal(el.merkmale, "Kreisel A") or "").strip()
|
|
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
|
|
if a and b:
|
|
weiche_pairs.append({a, b})
|
|
|
|
directed: dict[frozenset, tuple[str, str]] = {}
|
|
for s, roles in per_strecke.items():
|
|
if s not in nodes or nodes[s].element.is_circle:
|
|
continue
|
|
strecke_el = by_id.get(s)
|
|
aus_lane = _kreisel_lane_node(roles["aus"], strecke_el, nodes) if roles.get("aus") else None
|
|
ein_lane = _kreisel_lane_node(roles["ein"], strecke_el, nodes) if roles.get("ein") else None
|
|
# Ruecklauf-Strecke (beide Enden am selben Kreisel): auf die zwei Bahnen verteilen
|
|
if aus_lane and aus_lane == ein_lane:
|
|
base = aus_lane.split("-")[0]
|
|
lanes = [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) if f"{base}-{L}" in nodes]
|
|
if len(lanes) == 2:
|
|
aus_lane, ein_lane = lanes
|
|
warnings.append(
|
|
f"Strecke {s}: Ein- und Ausschleusung am selben Kreisel {base} "
|
|
f"(Ruecklauf) - Bahnen {lanes[0]}/{lanes[1]} angenommen"
|
|
)
|
|
if aus_lane: # Kreisel -> Strecke
|
|
directed[frozenset({aus_lane, s})] = (aus_lane, s, "Ausschleusung")
|
|
if ein_lane: # Strecke -> Kreisel
|
|
directed[frozenset({ein_lane, s})] = (s, ein_lane, "Einschleusung")
|
|
|
|
# Fuer Strecken mit Transferelementen sind ALLEIN diese massgeblich fuer die
|
|
# Kreisel<->Strecke-Verbindung. Jede weitere Kreisel<->Strecke-Kante aus den
|
|
# rohen Nachbarn (Bounding-Box-Ueberlappung, z. B. ein VF, der zufaellig neben
|
|
# einem dritten Kreisel liegt) ist ein Falschtreffer und wird verworfen.
|
|
covered = set(per_strecke)
|
|
result: list[Edge] = []
|
|
for e in edges:
|
|
if frozenset({e.src, e.dst}) in directed:
|
|
continue # wird gerichtet neu gesetzt
|
|
ends = {e.src, e.dst}
|
|
strecke_end = ends & covered
|
|
others = ends - strecke_end
|
|
if strecke_end and len(others) == 1:
|
|
other = next(iter(others))
|
|
if other in nodes and nodes[other].kind in CIRCLE_KINDS:
|
|
warnings.append(
|
|
f"Kante {other} <-> {next(iter(strecke_end))}: Kreisel<->Strecke "
|
|
f"ohne Transferelement - als Bounding-Box-Falschtreffer verworfen"
|
|
)
|
|
continue
|
|
result.append(e)
|
|
for (src, dst, note) in directed.values():
|
|
result.append(Edge(src, dst, EDGE_FLOW, note))
|
|
# Kreisel<->Kreisel-Kanten, die eine Weiche verbinden, markieren
|
|
for e in result:
|
|
if {e.src.split("-")[0], e.dst.split("-")[0]} in weiche_pairs:
|
|
e.note = "Weiche"
|
|
return result
|
|
|
|
|
|
def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph:
|
|
by_id = {element.teile_id: element for element in elements}
|
|
nodes = _build_nodes(elements, warnings)
|
|
unassigned = _attach_devices(elements, nodes, warnings)
|
|
|
|
observed = _collect_links(elements, by_id, warnings)
|
|
|
|
pairs: set[tuple[str, str]] = set()
|
|
for key, lanes in sorted(observed.items(), key=lambda item: sorted(item[0])):
|
|
base_a, base_b = sorted(key)
|
|
for node_a in _endpoints(base_a, lanes[base_a], by_id, warnings, base_b):
|
|
for node_b in _endpoints(base_b, lanes[base_b], by_id, warnings, base_a):
|
|
pairs.add((node_a, node_b))
|
|
|
|
edges = _orient(sorted(pairs), nodes, warnings)
|
|
|
|
# Kreisel: Richtung zwischen den beiden Bahnen aus der Drehrichtung
|
|
# UZS (im Uhrzeigersinn) -> von rechts nach links, R -> L
|
|
# GUZ (gegen den Uhrzeigersinn) -> von links nach rechts, L -> R
|
|
# Fehlt die Angabe, bleibt der Umlauf beidseitig und wird gemeldet.
|
|
for element in elements:
|
|
if not element.is_circle:
|
|
continue
|
|
left = f"{element.teile_id}-{LANE_LEFT}"
|
|
right = f"{element.teile_id}-{LANE_RIGHT}"
|
|
rotation = element.rotation
|
|
if rotation == ROTATION_CW:
|
|
edges.append(Edge(right, left, EDGE_CIRCLE, ROTATION_CW))
|
|
elif rotation == ROTATION_CCW:
|
|
edges.append(Edge(left, right, EDGE_CIRCLE, ROTATION_CCW))
|
|
else:
|
|
warnings.append(
|
|
f"{element.describe()}: Drehrichtung '{element.rotation or '-'}' "
|
|
f"unbekannt (erwartet {ROTATION_CW} oder {ROTATION_CCW}) - "
|
|
f"Umlauf beidseitig gezeichnet"
|
|
)
|
|
edges.append(Edge(left, right, EDGE_CIRCLE, "unbestimmt"))
|
|
edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt"))
|
|
|
|
# Ein-/Ausgabestationen (BTMT Be-/Entladung) als Quelle/Senke anbinden
|
|
edges.extend(_attach_stations(elements, nodes, warnings))
|
|
|
|
# Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich)
|
|
edges = _apply_transfers(elements, by_id, nodes, edges, warnings)
|
|
|
|
# Sackgassen und unerreichbare Bahnen melden. Sie entstehen, wenn ein
|
|
# Kreisel nur in einer Richtung durchlaufen wird (Drehrichtung) und die
|
|
# Gegenrichtung nicht ueber aeussere Verbindungen geschlossen ist.
|
|
outgoing = {edge.src for edge in edges}
|
|
incoming = {edge.dst for edge in edges}
|
|
for node_id in sorted(nodes):
|
|
node = nodes[node_id]
|
|
if node.lane is None:
|
|
continue
|
|
devices = node.devices.get("Separator", [])
|
|
detail = f" (Separatoren {', '.join(sorted(devices))})" if devices else ""
|
|
if node_id not in outgoing:
|
|
warnings.append(
|
|
f"Bahn {node_id}{detail}: keine abgehende Verbindung - Sackgasse. "
|
|
f"Drehrichtung {node.element.rotation or '?'} laesst das Material "
|
|
f"nur in eine Richtung laufen"
|
|
)
|
|
if node_id not in incoming:
|
|
warnings.append(
|
|
f"Bahn {node_id}{detail}: keine ankommende Verbindung - fuer das "
|
|
f"Material nicht erreichbar"
|
|
)
|
|
|
|
return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DOT erzeugen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _node_label(node: Node) -> str:
|
|
element = node.element
|
|
lines: list[str] = []
|
|
|
|
if element.is_station:
|
|
role = "Materialeingang" if element.is_entry else "Materialausgang"
|
|
lines.append(f"{element.name or element.kind} [{element.teile_id}]")
|
|
lines.append(f"{element.kind} - {role}")
|
|
if element.height is not None:
|
|
lines.append(f"h = {element.height:.3f} m")
|
|
return _dot_label(lines)
|
|
|
|
if node.lane:
|
|
name = _merkmal(element.merkmale, "Name") or element.name
|
|
lines.append(f"{name} [{node.node_id}]")
|
|
lines.append(f"Bahn {node.lane} ({LANE_NAMES[node.lane]})")
|
|
else:
|
|
lines.append(f"{element.name} [{element.teile_id}]")
|
|
lines.append(element.kind)
|
|
|
|
height = element.height
|
|
if height is not None:
|
|
lines.append(f"h = {height:.3f} m")
|
|
|
|
if element.is_transport:
|
|
uphill = drive_uphill(element)[0]
|
|
arrow = {True: "aufwaerts", False: "abwaerts", None: "Richtung offen"}[uphill]
|
|
if element.rise is not None:
|
|
lines.append(f"{element.height_from:.0f} -> {element.height_to:.0f} mm "
|
|
f"({element.rise:+.0f}) = {arrow}")
|
|
elif element.drive_dir:
|
|
lines.append(f"Antrieb: {element.drive_dir} = {arrow}")
|
|
else:
|
|
lines.append(arrow)
|
|
|
|
for kind in DEVICE_KINDS:
|
|
ids = node.devices.get(kind)
|
|
if ids:
|
|
lines.append(f"{kind} ({len(ids)}): {', '.join(sorted(ids))}")
|
|
|
|
return _dot_label(lines)
|
|
|
|
|
|
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
|
|
# Millimeter. 0.25 pt/mm (1:4) - wie in tro_flow.py, damit Material- und TRO-Graph
|
|
# im selben Massstab lagerichtig sind.
|
|
COORD_SCALE = 0.25
|
|
# Die beiden Bahnen (L/R) eines Kreisels teilen sich eine Position; im
|
|
# lagerichtigen Layout leicht versetzen, damit sie nicht uebereinander liegen.
|
|
LANE_NUDGE_MM = 350.0
|
|
|
|
|
|
def _node_xy(node: "Node") -> tuple[float, float] | None:
|
|
"""Anlagenkoordinate eines Knotens (Kreisel-Bahnen L/R leicht versetzt)."""
|
|
el = node.element
|
|
if el.x is None or el.y is None:
|
|
return None
|
|
x, y = el.x, el.y
|
|
if node.lane == "L":
|
|
y += LANE_NUDGE_MM
|
|
elif node.lane == "R":
|
|
y -= LANE_NUDGE_MM
|
|
return (x, y)
|
|
|
|
|
|
def _pos_attr(node: "Node", use_coords: bool) -> str:
|
|
"""pos-Attribut fuer 'neato -n', oder leer ohne --use-cords."""
|
|
if not use_coords:
|
|
return ""
|
|
xy = _node_xy(node)
|
|
if xy is None:
|
|
return ""
|
|
return f', pos="{xy[0] * COORD_SCALE:.2f},{xy[1] * COORD_SCALE:.2f}"'
|
|
|
|
|
|
def _flow_neighbours(node_id: str, edges: list[Edge]) -> tuple[str, str] | None:
|
|
"""
|
|
(Vorgaenger, Nachfolger) eines Knotens im Materialfluss.
|
|
|
|
None, wenn der Knoten nicht genau einen ankommenden und einen abgehenden
|
|
EDGE_FLOW hat - dann ist er kein einfacher Durchlaufknoten zwischen zwei
|
|
festen Nachbarn.
|
|
"""
|
|
incoming = {e.src for e in edges if e.dst == node_id and e.kind == EDGE_FLOW}
|
|
outgoing = {e.dst for e in edges if e.src == node_id and e.kind == EDGE_FLOW}
|
|
if len(incoming) != 1 or len(outgoing) != 1:
|
|
return None
|
|
return next(iter(incoming)), next(iter(outgoing))
|
|
|
|
|
|
def _group_parallel_gefaelle(graph: Graph) -> list[list[Node]]:
|
|
"""
|
|
Parallele Gefaellestrecken zwischen denselben zwei Kreisel-Bahnen zu
|
|
Gruppen zusammenfassen (z. B. eine Bank aus Schwerkraftlinien).
|
|
|
|
Kriterium: gleicher Zulauf- und Ablauf-Knoten (jeweils eine Kreisel-Bahn)
|
|
und gleiche Steigung (Hoehe_Von/Hoehe_Bis) - "gleich lang und gleiche
|
|
Steigung" laesst sich aus dem Export nur ueber die Hoehenangabe pruefen,
|
|
die Laenge ergibt sich daraus, dass beide Enden an denselben zwei Knoten
|
|
haengen. Rein fuer die Darstellung; Graph-Knoten/-Kanten bleiben unveraendert.
|
|
"""
|
|
groups: dict[tuple, list[Node]] = {}
|
|
for node_id in sorted(graph.nodes):
|
|
node = graph.nodes[node_id]
|
|
if node.kind != "Gefaellestrecke":
|
|
continue
|
|
neighbours = _flow_neighbours(node_id, graph.edges)
|
|
if neighbours is None:
|
|
continue
|
|
before, after = neighbours
|
|
if graph.nodes[before].kind != "Kreisel" or graph.nodes[after].kind != "Kreisel":
|
|
continue
|
|
element = node.element
|
|
key = (before, after, element.height_from, element.height_to)
|
|
groups.setdefault(key, []).append(node)
|
|
return [members for members in groups.values() if len(members) >= MIN_GEFAELLE_GROUP]
|
|
|
|
|
|
def _id_range(ids: list[str]) -> str:
|
|
"""Sortierte Ids als Bereich, z. B. ['0037', '0056'] -> '0037 - 0056'."""
|
|
ids = sorted(ids)
|
|
return ids[0] if len(ids) == 1 else f"{ids[0]} - {ids[-1]}"
|
|
|
|
|
|
def _gefaelle_group_label(members: list[Node]) -> str:
|
|
"""Sammel-Label einer Gefaellestrecken-Gruppe: Bereich der Linien-Ids,
|
|
gemeinsame Steigung, zusammengefasste Separatoren/Scanner."""
|
|
element = members[0].element
|
|
ids = sorted(m.node_id for m in members)
|
|
lines = [
|
|
f"Gefaellestrecke {ids[0]} -> {ids[-1]} ({len(ids)}x)",
|
|
"Gruppe paralleler Schwerkraftlinien",
|
|
]
|
|
if element.height is not None:
|
|
lines.append(f"h = {element.height:.3f} m")
|
|
if element.rise is not None:
|
|
uphill = drive_uphill(element)[0]
|
|
arrow = {True: "aufwaerts", False: "abwaerts", None: "Richtung offen"}[uphill]
|
|
lines.append(f"{element.height_from:.0f} -> {element.height_to:.0f} mm "
|
|
f"({element.rise:+.0f}) = {arrow}")
|
|
for kind in DEVICE_KINDS:
|
|
ids_dev = sorted({d for m in members for d in m.devices.get(kind, [])})
|
|
if ids_dev:
|
|
lines.append(f"{kind} ({len(ids_dev)}): {_id_range(ids_dev)}")
|
|
return _dot_label(lines)
|
|
|
|
|
|
def render_dot(graph: Graph, use_coords: bool = False) -> str:
|
|
out: list[str] = []
|
|
add = out.append
|
|
|
|
add("// Automatisch erzeugt von lib/material_flow.py - nicht manuell aendern.")
|
|
add(f"// Quelle: {graph.source}")
|
|
if use_coords:
|
|
add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.")
|
|
add("// Rendern mit: neato -n -Tsvg <datei>.dot -o <datei>.svg")
|
|
add("digraph Materialfluss {")
|
|
add(' graph [rankdir=LR, splines=spline, nodesep=0.25, ranksep=1.6,')
|
|
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
|
|
add(f' label="Materialfluss - {_dot_escape(graph.source)}"];')
|
|
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
|
|
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
|
|
' arrowsize=0.8];')
|
|
add("")
|
|
|
|
# Kreisel als Cluster mit den beiden Bahnen
|
|
circles: dict[str, list[Node]] = {}
|
|
plain: list[Node] = []
|
|
for node_id in sorted(graph.nodes):
|
|
node = graph.nodes[node_id]
|
|
if node.lane:
|
|
circles.setdefault(node.element.teile_id, []).append(node)
|
|
else:
|
|
plain.append(node)
|
|
|
|
for index, (teile_id, lanes) in enumerate(sorted(circles.items())):
|
|
element = lanes[0].element
|
|
name = _merkmal(element.merkmale, "Name") or element.name
|
|
rot = element.rotation
|
|
rot_text = (f" {rot} ({ROTATION_FLOW[rot]})" if rot in ROTATION_FLOW
|
|
else f" Drehrichtung {rot or '?'}")
|
|
add(f" subgraph cluster_kreisel_{index} {{")
|
|
add(f' label="{_dot_escape(element.kind)} {_dot_escape(str(name))} '
|
|
f'[{teile_id}]{_dot_escape(rot_text)}";')
|
|
add(' style="rounded,filled"; fillcolor="#eef2fa"; color="#2f5597";')
|
|
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#1f3864"; margin=12;')
|
|
style = NODE_STYLES.get(element.kind, DEFAULT_NODE_STYLE)
|
|
for node in sorted(lanes, key=lambda n: n.lane or ""):
|
|
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
|
|
add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
|
|
f'{_pos_attr(node, use_coords)}];')
|
|
add(" { rank=same; " + " ".join(f'"{n.node_id}";' for n in lanes) + " }")
|
|
add(" }")
|
|
add("")
|
|
|
|
# Parallele Gefaellestrecken (gleicher Zu-/Ablauf, gleiche Steigung) zu
|
|
# je einem Block zusammenfassen, damit eine Linienbank nicht als N
|
|
# einzelne Kaesten gezeichnet wird.
|
|
gefaelle_groups = _group_parallel_gefaelle(graph)
|
|
group_of: dict[str, str] = {}
|
|
for members in gefaelle_groups:
|
|
group_id = f"grp_{members[0].node_id}"
|
|
for member in members:
|
|
group_of[member.node_id] = group_id
|
|
grouped_ids = set(group_of)
|
|
|
|
for node in plain:
|
|
if node.node_id in grouped_ids:
|
|
continue
|
|
style = NODE_STYLES.get(node.kind, DEFAULT_NODE_STYLE)
|
|
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
|
|
add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
|
|
f'{_pos_attr(node, use_coords)}];')
|
|
|
|
for members in gefaelle_groups:
|
|
group_id = f"grp_{members[0].node_id}"
|
|
style = NODE_STYLES.get("Gefaellestrecke", DEFAULT_NODE_STYLE)
|
|
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
|
|
pos = ""
|
|
if use_coords:
|
|
xs = [xy[0] for m in members if (xy := _node_xy(m))]
|
|
ys = [xy[1] for m in members if (xy := _node_xy(m))]
|
|
if xs and ys:
|
|
pos = (f', pos="{sum(xs) / len(xs) * COORD_SCALE:.2f},'
|
|
f'{sum(ys) / len(ys) * COORD_SCALE:.2f}"')
|
|
add(f' "{group_id}" [label="{_gefaelle_group_label(members)}", {attrs}, '
|
|
f'peripheries=2{pos}];')
|
|
add("")
|
|
|
|
seen_group_edges: set[tuple[str, str]] = set()
|
|
for edge in graph.edges:
|
|
src = group_of.get(edge.src, edge.src)
|
|
dst = group_of.get(edge.dst, edge.dst)
|
|
if edge.src in grouped_ids or edge.dst in grouped_ids:
|
|
if src == dst or (src, dst) in seen_group_edges:
|
|
continue
|
|
seen_group_edges.add((src, dst))
|
|
if edge.kind == EDGE_CIRCLE:
|
|
# Richtung kommt aus der Drehrichtung und steht als Label an der Kante
|
|
label = f', xlabel="{_dot_escape(edge.note)}"' if edge.note else ""
|
|
attrs = ('[color="#8ea9db", style=dashed, penwidth=1.2, constraint=false, '
|
|
'arrowsize=0.9, fontcolor="#5b7fc7", '
|
|
f'tooltip="Kreisel-Umlauf {edge.note}"{label}]')
|
|
elif edge.kind == EDGE_UNRESOLVED:
|
|
weiche = ', xlabel="Weiche", fontcolor="#cc8800"' if edge.note == "Weiche" else ""
|
|
attrs = ('[color="#bf8f00", style=dashed, dir=both, '
|
|
f'tooltip="Richtung {edge.note}"{weiche}]')
|
|
elif edge.note == "Weiche":
|
|
attrs = ('[color="#cc8800", penwidth=1.6, xlabel="Weiche", '
|
|
'fontcolor="#cc8800", tooltip="Weiche (Kreisel-Uebergang)"]')
|
|
else:
|
|
attrs = "[]"
|
|
add(f' "{src}" -> "{dst}" {attrs};')
|
|
add("")
|
|
|
|
legend_pos = ""
|
|
if use_coords:
|
|
xs = [xy[0] for n in graph.nodes.values() if (xy := _node_xy(n))]
|
|
ys = [xy[1] for n in graph.nodes.values() if (xy := _node_xy(n))]
|
|
if xs and ys:
|
|
lx, ly = min(xs), min(ys) - 3000.0 # etwas unterhalb der Anlage
|
|
legend_pos = (f' pos="{lx * COORD_SCALE:.2f},'
|
|
f'{ly * COORD_SCALE:.2f}",')
|
|
|
|
add(" subgraph cluster_legende {")
|
|
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
|
|
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
|
|
add(f' legende [shape=plaintext, style="", fillcolor="none",{legend_pos} label=<'
|
|
'<table border="0" cellborder="0" cellspacing="2" cellpadding="2">'
|
|
'<tr><td align="left">durchgezogen</td><td align="left">Materialfluss</td></tr>'
|
|
'<tr><td align="left"><font color="#8ea9db">gestrichelt blau</font></td>'
|
|
'<td align="left">Kreisel-Umlauf, Richtung aus Drehrichtung '
|
|
f'({ROTATION_CW} = {ROTATION_FLOW[ROTATION_CW]}, '
|
|
f'{ROTATION_CCW} = {ROTATION_FLOW[ROTATION_CCW]})</td></tr>'
|
|
'<tr><td align="left"><font color="#bf8f00">gestrichelt gelb</font></td>'
|
|
'<td align="left">Richtung nicht bestimmbar</td></tr>'
|
|
'<tr><td align="left">doppelte Umrandung</td>'
|
|
'<td align="left">Gruppe paralleler Gefaellestrecken (gleicher Zu-/Ablauf, '
|
|
"gleiche Steigung)</td></tr>"
|
|
"</table>>];")
|
|
add(" }")
|
|
add("}")
|
|
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# connect.ini (Item-/Flussebene) erzeugen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _connect_kind(edge: Edge) -> str:
|
|
"""Kantenart fuer connect.ini."""
|
|
if edge.kind == EDGE_CIRCLE:
|
|
return "umlauf"
|
|
if edge.note == "Weiche":
|
|
return "weiche"
|
|
if edge.kind == EDGE_UNRESOLVED:
|
|
return "unbestimmt"
|
|
if edge.note in ("Einschleusung", "Ausschleusung"):
|
|
return edge.note.lower()
|
|
return "normal"
|
|
|
|
|
|
def render_connect_ini(graph: Graph, section: str = "Anlage", overrides=None) -> str:
|
|
"""
|
|
Materialfluss-Topologie als connect.ini (Item-/Flussebene).
|
|
|
|
Zwischenstand fuer die TRO-Ableitung: Knoten = Flussobjekte (Kreisel-Bahnen,
|
|
Strecke, Gefaellestrecke), Kanten = gerichteter Materialfluss. Von Hand
|
|
editierbar. Aufbau angelehnt an doc/TRO_Katalog/connect.ini.
|
|
|
|
`overrides` (aus cfg/tro_overrides.ini) wird als eigener Abschnitt
|
|
`[<Anlage>.manual]` angehaengt. Die Datei hier wird bei jedem Lauf neu
|
|
geschrieben - von Hand eingetragene Weichen waeren sonst weg. Der Abschnitt
|
|
beschreibt bewusst nur, WAS vor Ort bestaetigt wurde; er erfindet keine
|
|
Flusskanten, denn welche Kante zur Weiche gehoert, ist aus dem Layout nicht
|
|
ableitbar.
|
|
"""
|
|
out: list[str] = []
|
|
add = out.append
|
|
add("; ============================================================")
|
|
add("; connect.ini - Materialfluss-Topologie (Item-/Flussebene)")
|
|
add(f"; Automatisch erzeugt von lib/material_flow.py aus '{graph.source}'.")
|
|
add("; Zwischenstand fuer die TRO-Ableitung; von Hand editierbar.")
|
|
add(";")
|
|
add("; [<Anlage>.nodes] <Knoten> = <Art> | <Bezeichnung>")
|
|
add("; [<Anlage>.connections] cNNN = <von> -> <nach> | <JamArea> | <kind>")
|
|
add("; kind: normal | umlauf | weiche | einschleusung | ausschleusung | unbestimmt")
|
|
add("; [<Anlage>.externals] eNNN = EXTERN -> <Knoten> | <Info> (Zulauf/Beladung)")
|
|
add("; eNNN = <Knoten> -> EXTERN | <Info> (Ablauf/Entladung)")
|
|
add("; [<Anlage>.manual] vor Ort bestaetigte Angaben aus cfg/tro_overrides.ini")
|
|
add("; ============================================================")
|
|
add("")
|
|
add(f"[{section}.nodes]")
|
|
for node_id in sorted(graph.nodes):
|
|
node = graph.nodes[node_id]
|
|
el = node.element
|
|
if node.lane:
|
|
name = _merkmal(el.merkmale, "Name") or el.name
|
|
comment = f"{name} (Bahn {node.lane})"
|
|
else:
|
|
comment = el.name or el.kind
|
|
add(f"{node_id} = {node.kind} | {comment}")
|
|
add("")
|
|
add(f"[{section}.connections]")
|
|
for i, edge in enumerate(sorted(graph.edges, key=lambda e: (e.src, e.dst)), start=1):
|
|
add(f"c{i:03d} = {edge.src} -> {edge.dst} | | {_connect_kind(edge)}")
|
|
add("")
|
|
add(f"[{section}.externals]")
|
|
stations = [n for n in graph.nodes.values() if n.element.is_station]
|
|
if stations:
|
|
for j, node in enumerate(sorted(stations, key=lambda n: n.node_id), start=1):
|
|
if node.element.is_entry:
|
|
add(f"e{j:03d} = EXTERN -> {node.node_id} | {node.element.name}")
|
|
else:
|
|
add(f"e{j:03d} = {node.node_id} -> EXTERN | {node.element.name}")
|
|
else:
|
|
add("; Noch keine BTMT Be-/Entladestation im Layout - Ein-/Ausgang offen.")
|
|
add("; Kandidaten (offene Enden aus dem Materialfluss):")
|
|
incoming = {e.dst for e in graph.edges}
|
|
outgoing = {e.src for e in graph.edges}
|
|
for node_id in sorted(graph.nodes):
|
|
node = graph.nodes[node_id]
|
|
if node.element.is_station:
|
|
continue
|
|
if node_id not in incoming:
|
|
add(f"; Beladung? EXTERN -> {node_id} ({node.kind}, keine Zufuhr)")
|
|
if node_id not in outgoing:
|
|
add(f"; Entladung? {node_id} -> EXTERN ({node.kind}, kein Abfluss)")
|
|
add("")
|
|
|
|
if overrides is not None and (overrides.tros or overrides.splits
|
|
or overrides.open_points):
|
|
add(f"[{section}.manual]")
|
|
add("; Vor Ort bestaetigt, im CSV-Export nicht enthalten.")
|
|
add(f"; Quelle: {overrides.source}")
|
|
add("; Diese Zeilen werden aus der Override-Datei erzeugt - nicht hier")
|
|
add("; editieren, sonst sind sie beim naechsten Lauf wieder weg.")
|
|
for section_key, override in sorted(overrides.tros.items()):
|
|
if not override.type_name:
|
|
continue
|
|
wie = f"Separator {override.key}" if override.by_separator else override.key
|
|
add(f"{section_key} = {override.type_name} | {wie} | {override.reason}")
|
|
for spec in overrides.splits:
|
|
add(f"{spec.name} = NEU {spec.type_name} "
|
|
f"| Separator {', '.join(spec.separators)}"
|
|
+ (f" + Scanner {', '.join(spec.scanners)}" if spec.scanners else "")
|
|
+ (f" | Bahn {spec.host}" if spec.host else "")
|
|
+ f" | {spec.reason}")
|
|
for point in overrides.open_points:
|
|
add(f"{point.name} = OFFEN {point.expect or '?'} "
|
|
f"| x={point.x:.2f} y={point.y:.2f} "
|
|
f"| heute Teil von {point.belongs_to or '-'} "
|
|
f"| {point.note}")
|
|
add("")
|
|
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2D-Draufsicht der Boundingboxen (--show-bbs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Farben je Objektart fuer die Boundingbox-Draufsicht (Fuellung, Rahmen, Text).
|
|
# Eigene Palette statt NODE_STYLES, weil hier auch die Anbauteile (Separator,
|
|
# Scanner, Ein-/Ausschleuselement, Weiche) eine eigene Farbe brauchen.
|
|
BBS_STYLES: dict[str, dict[str, str]] = {
|
|
"Kreisel": dict(fill="#2f5597", stroke="#1f3864", text="#ffffff"),
|
|
"Gefaellestrecke": dict(fill="#fbe5d6", stroke="#c55a11", text="#833c00"),
|
|
"Strecke": dict(fill="#e2f0d9", stroke="#548235", text="#375623"),
|
|
"Beladung": dict(fill="#d4f0d4", stroke="#338833", text="#1e5b1e"),
|
|
"Entladung": dict(fill="#f4d4d4", stroke="#cc3333", text="#7a1f1f"),
|
|
"Separator": dict(fill="#fff2cc", stroke="#bf8f00", text="#7f6000"),
|
|
"Scanner": dict(fill="#d9d2e9", stroke="#674ea7", text="#351c75"),
|
|
"Einschleus": dict(fill="#cfe2f3", stroke="#3d85c6", text="#1155cc"),
|
|
"Ausschleus": dict(fill="#f9cb9c", stroke="#e69138", text="#b45f06"),
|
|
"Weiche": dict(fill="#ead1dc", stroke="#a64d79", text="#741b47"),
|
|
}
|
|
BBS_DEFAULT_STYLE = dict(fill="#f2f2f2", stroke="#808080", text="#404040")
|
|
|
|
# Rand um die Anlage herum und minimale Schriftgroesse, jeweils in mm.
|
|
BBS_MARGIN_MM = 500.0
|
|
BBS_MIN_FONT_MM = 40.0
|
|
|
|
# Darstellung der Anschlusspunkte K1-K4 (--show-kx): Groesse des Kreuzes und
|
|
# der Beschriftung in mm, Verbindungslinie K1->K2->K3->K4 in Blau.
|
|
KX_CROSS_MM = 120.0
|
|
KX_FONT_MM = 90.0
|
|
KX_CROSS_COLOR = "#c00000"
|
|
KX_LINK_COLOR = "#1f5fd0"
|
|
|
|
# Physisches Papierformat der SVG-Ausgabe: DIN A3, Quer- oder Hochformat je
|
|
# nach Seitenverhaeltnis der Anlage. Die viewBox bleibt in Anlage-mm - nur
|
|
# width/height (das gedruckte Format) werden auf A3 skaliert.
|
|
DINA3_LANDSCAPE_MM = (420.0, 297.0)
|
|
DINA3_PORTRAIT_MM = (297.0, 420.0)
|
|
|
|
|
|
def _xml_escape(text: str) -> str:
|
|
return (
|
|
text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
|
|
def render_bbs_svg(
|
|
elements: list[Element],
|
|
source: str,
|
|
warnings: list[str],
|
|
show_kx: bool = False,
|
|
) -> str:
|
|
"""
|
|
2D-Draufsicht der ganzen Szene: ein achsparalleles Rechteck je CSV-Zeile,
|
|
aus 'Position' (Mittelpunkt) und 'Boundingbox' (Breite/Tiefe in X/Y - die
|
|
Z-Ausdehnung ist fuer eine Draufsicht ohne Bedeutung), mit der Bezeichnung
|
|
im Zentrum. Keine Rotation - reine Lageuebersicht auf Basis der Rohdaten,
|
|
kein Ersatz fuer die CAD-Zeichnung.
|
|
|
|
Anlage-Y waechst nach oben (wie im CSV/DXF), SVG-Y nach unten - deshalb
|
|
wird Y beim Zeichnen gespiegelt, damit die Draufsicht nicht auf dem Kopf
|
|
steht.
|
|
|
|
Die viewBox bleibt in Anlage-mm (Koordinaten, Schrift, Strichstaerke
|
|
bleiben so zueinander proportional); das gedruckte Format (width/height)
|
|
wird auf DIN A3 (Quer- oder Hochformat je nach Seitenverhaeltnis) skaliert
|
|
- sonst waere die SVG-Datei bei einer 25 m breiten Anlage 25 m breit.
|
|
|
|
show_kx zeichnet zusaetzlich je Objekt die Anschlusspunkte K1-K4 (rotes
|
|
Kreuz + kleine Beschriftung) und verbindet sie in der Reihenfolge
|
|
K1->K2->K3->K4 mit einer blauen Linie.
|
|
"""
|
|
items: list[tuple[Element, float, float, float, float]] = []
|
|
for element in elements:
|
|
if element.position is None or element.bbox is None:
|
|
warnings.append(
|
|
f"{element.describe()}: keine Position/Boundingbox - in --show-bbs nicht gezeichnet"
|
|
)
|
|
continue
|
|
px, py = element.x, element.y
|
|
bw, bd = element.bbox_x, element.bbox_y
|
|
if not bw or not bd or bw <= 0 or bd <= 0:
|
|
warnings.append(
|
|
f"{element.describe()}: Boundingbox {bw}, {bd} mm ungueltig - in --show-bbs nicht gezeichnet"
|
|
)
|
|
continue
|
|
items.append((element, px, py, bw, bd))
|
|
|
|
if not items:
|
|
raise ValueError("keine Objekte mit Position und Boundingbox gefunden")
|
|
|
|
min_x = min(item[1] - item[3] / 2 for item in items) - BBS_MARGIN_MM
|
|
max_x = max(item[1] + item[3] / 2 for item in items) + BBS_MARGIN_MM
|
|
min_y = min(item[2] - item[4] / 2 for item in items) - BBS_MARGIN_MM
|
|
max_y = max(item[2] + item[4] / 2 for item in items) + BBS_MARGIN_MM
|
|
width, height = max_x - min_x, max_y - min_y
|
|
|
|
# Groesste Objekte zuerst zeichnen, damit kleine Anbauteile (Separator,
|
|
# Scanner, ...) sichtbar obenauf liegen statt unter einer Strecke/einem
|
|
# Kreisel zu verschwinden.
|
|
items.sort(key=lambda item: item[3] * item[4], reverse=True)
|
|
|
|
page_w, page_h = DINA3_LANDSCAPE_MM if width >= height else DINA3_PORTRAIT_MM
|
|
|
|
out: list[str] = []
|
|
add = out.append
|
|
add('<?xml version="1.0" encoding="UTF-8"?>')
|
|
add(f'<svg xmlns="http://www.w3.org/2000/svg" width="{page_w:.0f}mm" height="{page_h:.0f}mm" '
|
|
f'viewBox="0 0 {width:.2f} {height:.2f}" preserveAspectRatio="xMidYMid meet">')
|
|
add(" <!-- Automatisch erzeugt von lib/material_flow.py (Option show-bbs) - nicht manuell aendern. -->")
|
|
add(f" <!-- Quelle: {_xml_escape(source)} -->")
|
|
add(f' <rect x="0" y="0" width="{width:.2f}" height="{height:.2f}" fill="#ffffff"/>')
|
|
|
|
for element, px, py, bw, bd in items:
|
|
style = BBS_STYLES.get(element.kind, BBS_DEFAULT_STYLE)
|
|
x = (px - bw / 2) - min_x
|
|
y = max_y - (py + bd / 2)
|
|
name = element.name or element.teile_id
|
|
font_size = max(BBS_MIN_FONT_MM, min(bw, bd) * 0.28)
|
|
add(f' <rect x="{x:.2f}" y="{y:.2f}" width="{bw:.2f}" height="{bd:.2f}" '
|
|
f'fill="{style["fill"]}" fill-opacity="0.85" stroke="{style["stroke"]}" stroke-width="6"/>')
|
|
add(f' <text x="{x + bw / 2:.2f}" y="{y + bd / 2:.2f}" '
|
|
f'font-family="Segoe UI, sans-serif" font-size="{font_size:.1f}" fill="{style["text"]}" '
|
|
f'text-anchor="middle" dominant-baseline="middle">{_xml_escape(name)}</text>')
|
|
|
|
# Anschlusspunkte K1-K4 zuletzt, damit sie ueber den Rechtecken liegen.
|
|
if show_kx:
|
|
half = KX_CROSS_MM / 2
|
|
for element, *_rest in items:
|
|
# Anlage- in viewBox-Koordinaten (X verschoben, Y gespiegelt).
|
|
pts = [(label, kx - min_x, max_y - ky) for label, (kx, ky) in element.kpoints]
|
|
# Verbindungslinie K1->K2->K3->K4 (nur bei mindestens zwei Punkten).
|
|
for (_, x1, y1), (_, x2, y2) in zip(pts, pts[1:]):
|
|
add(f' <line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
|
f'stroke="{KX_LINK_COLOR}" stroke-width="8"/>')
|
|
for label, sx, sy in pts:
|
|
add(f' <line x1="{sx - half:.2f}" y1="{sy:.2f}" x2="{sx + half:.2f}" y2="{sy:.2f}" '
|
|
f'stroke="{KX_CROSS_COLOR}" stroke-width="8"/>')
|
|
add(f' <line x1="{sx:.2f}" y1="{sy - half:.2f}" x2="{sx:.2f}" y2="{sy + half:.2f}" '
|
|
f'stroke="{KX_CROSS_COLOR}" stroke-width="8"/>')
|
|
add(f' <text x="{sx + half + 20:.2f}" y="{sy - half:.2f}" '
|
|
f'font-family="Segoe UI, sans-serif" font-size="{KX_FONT_MM:.1f}" '
|
|
f'fill="{KX_CROSS_COLOR}" dominant-baseline="middle">{label}</text>')
|
|
|
|
add("</svg>")
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SVG erzeugen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def find_dot() -> str | None:
|
|
env = os.environ.get("GRAPHVIZ_DOT")
|
|
if env:
|
|
return env if Path(env).is_file() else shutil.which(env)
|
|
return shutil.which("dot")
|
|
|
|
|
|
def find_engine(engine: str) -> str | None:
|
|
"""Layout-Programm von Graphviz suchen ('dot', 'neato', ...)."""
|
|
if engine == "dot":
|
|
return find_dot()
|
|
return shutil.which(engine)
|
|
|
|
|
|
def graph_to_svg(
|
|
dot_file: Path, svg_file: Path, engine: str = "dot", no_op: int = 0
|
|
) -> None:
|
|
"""
|
|
SVG aus der .dot-Datei erzeugen.
|
|
|
|
engine Layout-Programm ('dot' fuer die berechnete Anordnung, 'neato' fuer
|
|
vorgegebene Koordinaten)
|
|
no_op nur fuer neato: 1 oder 2 setzt den No-op-Schalter (-n / -n2), dann
|
|
werden die pos-Attribute der Knoten als Position uebernommen und
|
|
nur die Kanten berechnet. Alle Knoten brauchen dann ein pos.
|
|
"""
|
|
executable = find_engine(engine)
|
|
if not executable:
|
|
raise RuntimeError(
|
|
f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren "
|
|
f"(https://graphviz.org/download/)"
|
|
+ (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen."
|
|
if engine == "dot" else ".")
|
|
)
|
|
|
|
command = [executable]
|
|
if no_op:
|
|
command.append(f"-n{no_op}")
|
|
command += ["-Tsvg", str(dot_file), "-o", str(svg_file)]
|
|
|
|
result = subprocess.run(command, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n"
|
|
f"{(result.stderr or result.stdout).strip()}"
|
|
)
|
|
# 'neato -n' gibt harmlose Warnungen auf stderr aus (z. B. sich beruehrende
|
|
# Knoten -> gerade Kanten). Nur dann scheitern, wenn wirklich keine SVG
|
|
# entstanden ist; sonst ist die Datei gueltig und die Warnung unkritisch.
|
|
if not svg_file.is_file() or svg_file.stat().st_size == 0:
|
|
raise RuntimeError(
|
|
f"'{engine}' hat keine SVG erzeugt: {result.stderr.strip() or '(keine Meldung)'}"
|
|
)
|
|
|
|
|
|
def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
|
|
"""SVG mit der berechneten Anordnung von 'dot' erzeugen."""
|
|
graph_to_svg(dot_file, svg_file, engine="dot")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plausibilitaet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_elements(graph: Graph, elements: list[Element]) -> list[str]:
|
|
"""
|
|
Merkmal-Stueckzahlen gegen die tatsaechlich vorhandenen Anbauteile pruefen.
|
|
|
|
Rein objektbezogen - alles, was TROs betrifft, liegt in lib/tro_flow.py.
|
|
"""
|
|
findings: list[str] = []
|
|
counted: dict[str, dict[str, int]] = {}
|
|
for node_id, node in graph.nodes.items():
|
|
for kind, ids in node.devices.items():
|
|
counted.setdefault(node_id, {})[kind] = len(ids)
|
|
|
|
# Widerspruch zwischen Hoehenangabe und Antriebfahrtrichtung
|
|
for element in elements:
|
|
if not element.is_transport:
|
|
continue
|
|
rise, drive = element.rise, element.drive_dir.lower()
|
|
if rise is None or rise == 0 or not drive:
|
|
continue
|
|
stated_up = drive.startswith("auf")
|
|
if stated_up != (rise > 0):
|
|
findings.append(
|
|
f"{element.kind} {element.teile_id} '{element.name}': "
|
|
f"Antriebfahrtrichtung '{element.drive_dir}' passt nicht zu "
|
|
f"Hoehe_Von {element.height_from:.0f} -> Hoehe_Bis "
|
|
f"{element.height_to:.0f} mm ({rise:+.0f}) - die Hoehenangabe gilt"
|
|
)
|
|
|
|
for element in elements:
|
|
if not (element.is_transport or element.is_circle):
|
|
continue
|
|
node_ids = (
|
|
[element.teile_id]
|
|
if element.is_transport
|
|
else [f"{element.teile_id}-{LANE_LEFT}", f"{element.teile_id}-{LANE_RIGHT}"]
|
|
)
|
|
for label, keys in (
|
|
("Separator", ("Anzahl_Separator", "Anzahl der Separatoren")),
|
|
("Scanner", ("Anzahl_Scanner", "Anzahl der Scanner")),
|
|
):
|
|
expected = _as_float(_merkmal(element.merkmale, *keys))
|
|
if expected is None:
|
|
continue
|
|
actual = sum(counted.get(n, {}).get(label, 0) for n in node_ids)
|
|
if int(expected) != actual:
|
|
findings.append(
|
|
f"{element.kind} {element.teile_id} '{element.name}': Merkmal "
|
|
f"'{keys[0] if keys[0] in element.merkmale else keys[1]}' = "
|
|
f"{int(expected)}, tatsaechlich {actual} {label}"
|
|
)
|
|
findings.sort()
|
|
return findings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dokumentation (Markdown)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
GENERATED_MARKER = "<!-- erzeugt von lib/material_flow.py -->"
|
|
|
|
|
|
def render_doc(
|
|
graph: Graph,
|
|
findings: list[str],
|
|
csv_file: Path,
|
|
dot_file: Path,
|
|
svg_file: Path | None,
|
|
) -> str:
|
|
"""Dokumentation des Materialflusses (nur Objekte, keine TROs)."""
|
|
out: list[str] = [GENERATED_MARKER]
|
|
add = out.append
|
|
|
|
add(f"# Materialfluss - {csv_file.name}")
|
|
add("")
|
|
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
|
|
f"`lib/material_flow.py` ")
|
|
add(f"**Quelle:** `{csv_file}`")
|
|
add("")
|
|
add("> Materialfluss der mechanischen Objekte. Die daraus abgeleiteten "
|
|
"Steuerungsobjekte (TROs) dokumentiert `lib/tro_flow.py --doc`.")
|
|
add("")
|
|
|
|
kinds: dict[str, int] = {}
|
|
for node in graph.nodes.values():
|
|
kinds[node.kind] = kinds.get(node.kind, 0) + 1
|
|
devices: dict[str, int] = {}
|
|
for node in graph.nodes.values():
|
|
for kind, ids in node.devices.items():
|
|
devices[kind] = devices.get(kind, 0) + len(ids)
|
|
|
|
flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW)
|
|
circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE)
|
|
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
|
|
|
|
add("## 1. Kurzfassung")
|
|
add("")
|
|
add("| | |")
|
|
add("|---|---|")
|
|
add(f"| Flussknoten | {len(graph.nodes)} "
|
|
f"({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))}) |")
|
|
add(f"| Flusskanten | {flow} gerichtet, {circle} Kreisel-Umlauf, "
|
|
f"{open_dir} Richtung unbestimmt |")
|
|
add(f"| Anbauteile | "
|
|
f"{', '.join(f'{k}: {v}' for k, v in sorted(devices.items())) or '-'} |")
|
|
add(f"| Plausibilitaetshinweise | {len(findings)} |")
|
|
add("")
|
|
add(f"Graph: [`{dot_file.name}`]({dot_file.name})"
|
|
+ (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else ""))
|
|
add("")
|
|
add("Ein **Kreisel** ist ein Umlauf und wird als zwei Knoten modelliert - eine "
|
|
"Bahn je Fahrtrichtung (`-L` links, `-R` rechts), die zu einem Kreis "
|
|
"verbunden sind.")
|
|
add("")
|
|
|
|
add("## 2. Anlagenobjekte")
|
|
add("")
|
|
add("| Knoten | Art | Bezeichnung | Planquadrat | Hoehe [m] | Separatoren | Scanner |")
|
|
add("|---|---|---|---|---|---|---|")
|
|
for node_id in sorted(graph.nodes):
|
|
node = graph.nodes[node_id]
|
|
height = f"{node.height:.3f}" if node.height is not None else "-"
|
|
name = node.element.name
|
|
logical = _merkmal(node.element.merkmale, "Name")
|
|
if logical and str(logical).strip() and str(logical).strip() != name:
|
|
name = f"{name} / {str(logical).strip()}"
|
|
add(f"| `{node_id}` | {node.kind} | {name}"
|
|
f"{f' (Bahn {node.lane})' if node.lane else ''} | "
|
|
f"{node.element.plan_square or '-'} | {height} | "
|
|
f"{', '.join(sorted(node.devices.get('Separator', []))) or '-'} | "
|
|
f"{', '.join(sorted(node.devices.get('Scanner', []))) or '-'} |")
|
|
add("")
|
|
add("Bezeichnung: `Bezeichnung` aus dem CSV; wo das Merkmal `Name` abweicht, "
|
|
"steht es dahinter (die beiden Nummerierungen laufen nicht zwangslaeufig "
|
|
"gleich).")
|
|
add("")
|
|
add("Hoehe: bei Kreiseln aus dem Merkmal `Höhe in m`, bei Gefaellestrecke und "
|
|
"Strecke aus `Montagehoehe_m` - unveraendert aus dem CSV uebernommen. Fuer "
|
|
"die Richtungsableitung werden nur die Hoehen der **Nachbarn** verglichen, "
|
|
"nie die des Transportobjekts selbst.")
|
|
add("")
|
|
|
|
if graph.unassigned:
|
|
add("**Anbauteile ohne Zuordnung**")
|
|
add("")
|
|
for element in graph.unassigned:
|
|
add(f"- {element.kind} {element.teile_id} '{element.name}' "
|
|
f"(Zuordnung: `{element.assignment or '-'}`)")
|
|
add("")
|
|
|
|
add("## 3. Verbindungen")
|
|
add("")
|
|
add("| von | nach | Art |")
|
|
add("|---|---|---|")
|
|
kind_text = {EDGE_FLOW: "Materialfluss", EDGE_CIRCLE: "Kreisel-Umlauf",
|
|
EDGE_UNRESOLVED: "Richtung unbestimmt"}
|
|
for edge in graph.edges:
|
|
note = f" ({edge.note})" if edge.note else ""
|
|
add(f"| `{edge.src}` | `{edge.dst}` | "
|
|
f"{kind_text.get(edge.kind, edge.kind)}{note} |")
|
|
add("")
|
|
add(f"Die Richtung innerhalb eines Kreisels kommt aus dem Merkmal "
|
|
f"`Drehrichtung`: **{ROTATION_CW}** (im Uhrzeigersinn) laesst das Material "
|
|
f"{ROTATION_FLOW[ROTATION_CW]} laufen, **{ROTATION_CCW}** (gegen den "
|
|
f"Uhrzeigersinn) {ROTATION_FLOW[ROTATION_CCW]}.")
|
|
add("")
|
|
|
|
add("## 4. Plausibilitaetshinweise")
|
|
add("")
|
|
if findings:
|
|
add("Abweichungen zwischen den Merkmalen des CSV-Exports und den "
|
|
"tatsaechlich vorhandenen Objekten.")
|
|
add("")
|
|
for finding in findings:
|
|
add(f"- {finding}")
|
|
else:
|
|
add("_Keine Abweichungen gefunden._")
|
|
add("")
|
|
|
|
add("## 5. Warnungen des Laufs")
|
|
add("")
|
|
if graph.warnings:
|
|
for warning in graph.warnings:
|
|
add(f"- {warning}")
|
|
else:
|
|
add("_Keine._")
|
|
add("")
|
|
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bericht
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def report(
|
|
graph: Graph,
|
|
findings: list[str],
|
|
csv_file: Path,
|
|
dot_file: Path,
|
|
svg_file: Path | None,
|
|
doc_file: Path | None,
|
|
stale_svg: Path | None = None,
|
|
bbs_file: Path | None = None,
|
|
) -> None:
|
|
kinds: dict[str, int] = {}
|
|
for node in graph.nodes.values():
|
|
kinds[node.kind] = kinds.get(node.kind, 0) + 1
|
|
|
|
flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW)
|
|
circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE)
|
|
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
|
|
|
|
devices = 0
|
|
for node in graph.nodes.values():
|
|
devices += sum(len(ids) for ids in node.devices.values())
|
|
|
|
print("")
|
|
print("================================================================")
|
|
print("MATERIALFLUSS")
|
|
print("================================================================")
|
|
print(f"Eingabe = {csv_file}")
|
|
print(f"Knoten = {len(graph.nodes)}"
|
|
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))})"
|
|
if kinds else ""))
|
|
print(f"Kanten = {len(graph.edges)} (Fluss: {flow}, "
|
|
f"Kreisel-Umlauf: {circle}, unbestimmt: {open_dir})")
|
|
print(f"Anbauteile = {devices} an Knoten, "
|
|
f"{len(graph.unassigned)} ohne Zuordnung")
|
|
print(f"DOT = {dot_file}")
|
|
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
|
|
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
|
|
print(f"BB-Draufsicht = {bbs_file if bbs_file else '- (ohne --show-bbs)'}")
|
|
print("================================================================")
|
|
|
|
if stale_svg is not None:
|
|
print("")
|
|
print(f"HINWEIS: {stale_svg}")
|
|
print(" stammt aus einem frueheren Lauf und passt nicht zur neuen "
|
|
"DOT-Datei.")
|
|
print(" Mit --tosvg neu erzeugen.")
|
|
|
|
if graph.unassigned:
|
|
print("")
|
|
print(f"Ohne Zuordnung ({len(graph.unassigned)}):")
|
|
for element in graph.unassigned:
|
|
print(f" - {element.kind} {element.teile_id} '{element.name}'")
|
|
|
|
if findings:
|
|
print("")
|
|
print(f"Plausibilitaetshinweise ({len(findings)}):")
|
|
for finding in findings:
|
|
print(f" ? {finding}")
|
|
|
|
if graph.warnings:
|
|
print("")
|
|
print(f"Warnungen ({len(graph.warnings)}):")
|
|
for warning in graph.warnings:
|
|
print(f" ! {warning}")
|
|
print("")
|
|
print("TRO-Liste und TRO-Flussdiagramm: bin/tro_flow.bat "
|
|
f"--file {csv_file.name} --tosvg --doc")
|
|
print("")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="material_flow",
|
|
description="Erzeugt aus einem CSV-Export der Anlagenobjekte einen "
|
|
"gerichteten Materialfluss-Graphen der mechanischen Objekte "
|
|
"(DOT, optional SVG). Die TROs ermittelt tro_flow.py.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="Beispiele:\n"
|
|
" material_flow.bat --file mubea.csv\n"
|
|
" material_flow.bat --file mubea.csv --tosvg\n"
|
|
" material_flow.bat --file mubea.csv --tosvg --doc\n"
|
|
" material_flow.bat --file HundM05.csv --show-bbs\n"
|
|
"\n"
|
|
"Exit-Codes:\n"
|
|
" 0 ok\n"
|
|
" 1 Eingabe- oder Aufruffehler\n"
|
|
" 2 SVG konnte nicht erzeugt werden (Graphviz)\n",
|
|
)
|
|
parser.add_argument(
|
|
"--file",
|
|
default="export.csv",
|
|
metavar="NAME",
|
|
help="Name der CSV-Eingabedatei in %%SKEL_DATA%% "
|
|
"(oder ein vollstaendiger Pfad). Standard: %(default)s",
|
|
)
|
|
parser.add_argument(
|
|
"--tosvg",
|
|
action="store_true",
|
|
help="Aus der erzeugten DOT-Datei zusaetzlich eine SVG-Datei erzeugen "
|
|
"(benoetigt Graphviz 'dot').",
|
|
)
|
|
parser.add_argument(
|
|
"--use-cords",
|
|
"--use-coords",
|
|
dest="use_cords",
|
|
action="store_true",
|
|
help="Jeden Knoten auf seine Anlagenkoordinate setzen und das SVG von "
|
|
f"Graphviz 'neato -n' erzeugen statt von 'dot' (lagerichtig, "
|
|
f"{COORD_SCALE} pt/mm). Nur mit --tosvg wirksam.",
|
|
)
|
|
parser.add_argument(
|
|
"--doc",
|
|
action="store_true",
|
|
help="Dokumentation des Materialflusses als Markdown in %%SKEL_RESULTS%% "
|
|
"erzeugen (Anlagenobjekte, Verbindungen, Pruefungen).",
|
|
)
|
|
parser.add_argument(
|
|
"--connect",
|
|
action="store_true",
|
|
help="Materialfluss-Topologie zusaetzlich als connect.ini (Item-/Flussebene) "
|
|
"nach %%SKEL_RESULTS%% schreiben (Knoten, gerichtete Verbindungen, "
|
|
"Ein-/Ausgang-Kandidaten) - Zwischenstand fuer die TRO-Ableitung.",
|
|
)
|
|
parser.add_argument(
|
|
"--show-bbs",
|
|
dest="show_bbs",
|
|
action="store_true",
|
|
help="2D-Draufsicht der ganzen Szene als <name>_bbs.svg nach %%SKEL_RESULTS%% "
|
|
"schreiben: ein achsparalleles Rechteck je CSV-Zeile aus 'Position' "
|
|
"(Mittelpunkt) und 'Boundingbox' (Breite/Tiefe), mit der Bezeichnung "
|
|
"im Zentrum, skaliert auf DIN A3. Braucht kein Graphviz.",
|
|
)
|
|
parser.add_argument(
|
|
"--show-kx",
|
|
dest="show_kx",
|
|
action="store_true",
|
|
help="Nur zusammen mit --show-bbs: je Objekt die Anschlusspunkte K1-K4 als "
|
|
"kleines Kreuz mit Beschriftung einzeichnen und in der Reihenfolge "
|
|
"K1->K2->K3->K4 mit einer blauen Linie verbinden.",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
|
|
csv_file = resolve_input(args.file)
|
|
if csv_file.suffix.lower() != ".csv":
|
|
print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: "
|
|
f"{csv_file}", file=sys.stderr)
|
|
return 1
|
|
if not csv_file.is_file():
|
|
print(f"FEHLER: Eingabedatei nicht gefunden: {csv_file}", file=sys.stderr)
|
|
print(f" Suchpfad fuer Dateinamen: {env_dir('SKEL_DATA', 'data')}",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
results = env_dir("SKEL_RESULTS", "results")
|
|
results.mkdir(parents=True, exist_ok=True)
|
|
|
|
warnings: list[str] = []
|
|
try:
|
|
elements = read_elements(csv_file, warnings)
|
|
except (OSError, ValueError, csv.Error) as exc:
|
|
print(f"FEHLER: {csv_file} konnte nicht gelesen werden: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
if not elements:
|
|
print(f"FEHLER: {csv_file} enthaelt keine Objekte.", file=sys.stderr)
|
|
return 1
|
|
|
|
stem = csv_file.stem
|
|
|
|
if args.show_kx and not args.show_bbs:
|
|
print("FEHLER: --show-kx ist nur zusammen mit --show-bbs moeglich.",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
bbs_file = None
|
|
if args.show_bbs:
|
|
bbs_target = results / f"{stem}_bbs.svg"
|
|
try:
|
|
bbs_target.write_text(
|
|
render_bbs_svg(elements, csv_file.name, warnings, show_kx=args.show_kx),
|
|
encoding="utf-8",
|
|
)
|
|
except ValueError as exc:
|
|
print(f"FEHLER: --show-bbs: {exc}", file=sys.stderr)
|
|
return 1
|
|
bbs_file = bbs_target
|
|
|
|
graph = build_graph(elements, csv_file.name, warnings)
|
|
if not graph.nodes:
|
|
print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte "
|
|
f"({', '.join(TRANSPORT_KINDS + CIRCLE_KINDS)}).", file=sys.stderr)
|
|
return 1
|
|
|
|
findings = check_elements(graph, elements)
|
|
|
|
dot_file = results / f"{stem}_material_flow.dot"
|
|
svg_file = results / f"{stem}_material_flow.svg"
|
|
doc_target = results / f"{stem}_material_flow.md"
|
|
|
|
# --use-cords braucht an jedem Knoten eine Koordinate
|
|
if args.use_cords:
|
|
without = [n.node_id for n in graph.nodes.values() if _node_xy(n) is None]
|
|
if without:
|
|
print(f"FEHLER: --use-cords nicht moeglich, {len(without)} Knoten ohne "
|
|
f"Koordinate: {', '.join(without)}", file=sys.stderr)
|
|
print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
existing_svg = svg_file if svg_file.is_file() else None
|
|
dot_file.write_text(render_dot(graph, use_coords=args.use_cords), encoding="utf-8")
|
|
|
|
svg_error: str | None = None
|
|
if args.tosvg:
|
|
try:
|
|
if args.use_cords:
|
|
graph_to_svg(dot_file, svg_file, engine="neato", no_op=1)
|
|
else:
|
|
dot_to_svg(dot_file, svg_file)
|
|
except RuntimeError as exc:
|
|
svg_error = str(exc)
|
|
created_svg = svg_file if args.tosvg and svg_error is None else None
|
|
|
|
doc_file = None
|
|
if args.doc:
|
|
doc_target.write_text(
|
|
render_doc(graph, findings, csv_file, dot_file, created_svg),
|
|
encoding="utf-8",
|
|
)
|
|
doc_file = doc_target
|
|
|
|
connect_file = None
|
|
if args.connect:
|
|
# Spaet importiert: tro_overrides zieht tro_catalog nach, das
|
|
# material_flow sonst nicht braucht.
|
|
from tro_overrides import load_overrides
|
|
|
|
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
|
|
connect_target = results / f"{stem}_connect.ini"
|
|
connect_target.write_text(
|
|
render_connect_ini(graph, section=stem, overrides=overrides),
|
|
encoding="utf-8",
|
|
)
|
|
connect_file = connect_target
|
|
|
|
report(
|
|
graph,
|
|
findings,
|
|
csv_file,
|
|
dot_file,
|
|
created_svg,
|
|
doc_file,
|
|
stale_svg=None if args.tosvg and svg_error is None else existing_svg,
|
|
bbs_file=bbs_file,
|
|
)
|
|
|
|
if svg_error:
|
|
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|