latest changes
This commit is contained in:
+472
-10
@@ -77,6 +77,35 @@ 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"}
|
||||
@@ -104,6 +133,8 @@ 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")
|
||||
|
||||
@@ -152,6 +183,25 @@ class Element:
|
||||
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."""
|
||||
@@ -323,6 +373,25 @@ def parse_neighbours(raw: str) -> list[tuple[str, str | None]]:
|
||||
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:
|
||||
@@ -343,6 +412,14 @@ def read_elements(path: Path, warnings: list[str]) -> list[Element]:
|
||||
|
||||
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 = {}
|
||||
@@ -395,6 +472,12 @@ def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node
|
||||
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"
|
||||
@@ -611,6 +694,160 @@ def _orient(
|
||||
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)
|
||||
@@ -650,6 +887,12 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
|
||||
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.
|
||||
@@ -685,6 +928,14 @@ 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}]")
|
||||
@@ -716,12 +967,47 @@ def _node_label(node: Node) -> str:
|
||||
return _dot_label(lines)
|
||||
|
||||
|
||||
def render_dot(graph: Graph) -> str:
|
||||
# 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 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",')
|
||||
@@ -755,7 +1041,8 @@ def render_dot(graph: Graph) -> str:
|
||||
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}];')
|
||||
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("")
|
||||
@@ -763,7 +1050,8 @@ def render_dot(graph: Graph) -> str:
|
||||
for node in plain:
|
||||
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}];')
|
||||
add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
|
||||
f'{_pos_attr(node, use_coords)}];')
|
||||
add("")
|
||||
|
||||
for edge in graph.edges:
|
||||
@@ -774,17 +1062,30 @@ def render_dot(graph: Graph) -> str:
|
||||
'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}"]')
|
||||
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' "{edge.src}" -> "{edge.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(' legende [shape=plaintext, style="", fillcolor="none", label=<'
|
||||
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>'
|
||||
@@ -800,6 +1101,120 @@ def render_dot(graph: Graph) -> str:
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SVG erzeugen
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -851,9 +1266,13 @@ def graph_to_svg(
|
||||
f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n"
|
||||
f"{(result.stderr or result.stdout).strip()}"
|
||||
)
|
||||
# neato meldet fehlende Positionen nur als Warnung auf stderr
|
||||
if result.stderr.strip():
|
||||
raise RuntimeError(f"'{engine}' meldet: {result.stderr.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:
|
||||
@@ -1164,12 +1583,28 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
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.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -1210,13 +1645,26 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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), encoding="utf-8")
|
||||
dot_file.write_text(render_dot(graph, use_coords=args.use_cords), encoding="utf-8")
|
||||
|
||||
svg_error: str | None = None
|
||||
if args.tosvg:
|
||||
try:
|
||||
dot_to_svg(dot_file, svg_file)
|
||||
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
|
||||
@@ -1229,6 +1677,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user