Files
dxfmakros/lib/export_graph.py
s.ayadi 85438a9ee8 [ADD] export_graph.py: Materialfluss-Graph aus dem CSV-Export
Baut aus export.csv ein gerichtetes Graph-Modell der Anlage und schreibt
es als .json (vollstaendig), .dot (GraphViz) und .mmd (Mermaid).

Statt der CSV-Spalte "Nachbarn" (ungerichtet, Strecken werden dort
absichtlich nicht gegeneinander getestet, und ein langer Kreisel
ueberdeckt flaechig die halbe Anlage) arbeitet das Modul mit
Anschlusspunkten: eine Kante entsteht nur, wenn ein Elementende im
Grundriss auf der Flaeche eines anderen Elements liegt.

Richtung exakt aus K1/K2 (KS_EIN/KS_AUS), sobald der Export diese
Spalten fuellt; solange sie leer sind heuristisch aus Schwerkraft
(Gefaellestrecke) bzw. dem Merkmal Antriebfahrtrichtung (Foerderer).
Jede Kante traegt die verwendete Quelle in "richtung_quelle".

Warnt zudem, wenn Insertpoint-Werte an der 24-Bit-Fixed-Point-Grenze
von csv:kos-encode (+-8388.607 Einheiten) abgeschnitten sind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:58:02 +02:00

576 lines
22 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
export_graph.py - Baut aus dem CSV-Export (EXPORTCSV) einen Materialfluss-Graph.
Aufruf:
python export_graph.py <export.csv> [<ausgabe-basispfad>]
Erzeugt neben <basispfad>.json (vollstaendiges Graph-Modell) auch
<basispfad>.dot (GraphViz) und <basispfad>.mmd (Mermaid) zur Visualisierung.
KNOTEN = ein Anlagenelement je CSV-Zeile (Kreisel, Gefaellestrecke, Strecke).
KANTEN = eine Materialuebergabe zwischen zwei Elementen.
Warum nicht die CSV-Spalte "Nachbarn"?
-------------------------------------
Die Spalte ist bewusst KEINE Flussinformation (siehe export_neighbors.py):
* Sie ist ungerichtet - der Materialfluss braucht aber eine Richtung.
* Strecken-Teilearten werden dort absichtlich NICHT gegeneinander
getestet, nur gegen Kreisel/Eckrad.
* Sie beruht auf Bounding-Box-Ueberschneidung im Grundriss. Ein langer
Kreisel ueberdeckt dadurch praktisch die gesamte Anlage und bekommt
Nachbarn, mit denen er keinen Uebergabepunkt teilt.
Dieses Modul arbeitet stattdessen mit ANSCHLUSSPUNKTEN (Ports): jedes
Element hat entlang seiner Laengsachse zwei Enden. Eine Kante entsteht nur,
wenn das Ende eines Elements im Grundriss auf der Flaeche eines anderen
Elements liegt (Toleranz siehe PORT_TOLERANZ_MM). Das ergibt genau die
realen Uebergabestellen statt flaechiger Ueberdeckung.
Richtungsermittlung
-------------------
Sobald der Export die Spalten K1/K2 (KS_EIN/KS_AUS) fuellt, ist die Richtung
exakt: K1 = Eingang, K2 = Ausgang, Kante immer K2 -> K1. Solange diese
Spalten leer sind (aktueller Stand, siehe csv:get-k-kos-strings in
Lisp/export.lsp), wird die Richtung heuristisch abgeleitet:
* Gefaellestrecke: Schwerkraft - Fluss vom hoeher zum tiefer liegenden
Anschluss. Die Endhoehen werden aus der Hoehe des jeweils dort
angeschlossenen Partners bestimmt.
* Strecke/Foerderer: Merkmal "Antriebfahrtrichtung" ("Auf" = aufwaerts,
"Ab" = abwaerts) gegen die Partnerhoehen.
* Kreisel: Verteilknoten. Richtung ergibt sich aus den angeschlossenen
Elementen, nicht aus dem Kreisel selbst.
Jede Kante traegt daher ein Feld "richtung_quelle": "ks" (exakt, aus K1/K2),
"gefaelle", "antrieb" oder "unbestimmt".
"""
import csv
import json
import math
import os
import sys
# Base64-Alphabet von csv:kos-encode / csv:trans-encode (Lisp/export.lsp)
B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
B64_INDEX = {c: i for i, c in enumerate(B64_CHARS)}
KOS_MAX = 8388607 # Saettigungsgrenze der 24-Bit-Fixed-Point-Werte
KOS_FAKTOR = 1000.0 # csv:kos-encode (Insertpoint)
TRANS_FAKTOR = 10.0 # csv:trans-encode (K1-K4)
# Ein Anschluss gilt als verbunden, wenn das Elementende im Grundriss
# hoechstens so weit von der Partnerflaeche entfernt liegt (mm).
PORT_TOLERANZ_MM = 400.0
KREISEL_ARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"}
GEFAELLE_ARTEN = {"ILS 2.0 Gefaellestrecke"}
STRECKEN_ARTEN = {"ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"}
# ------------------------------------------------------------------
# Dekodierung der Base64-Koordinatenstrings
# ------------------------------------------------------------------
def _decode_ints(text):
"""Base64-String in 24-Bit-Integer je 4 Zeichen zurueckrechnen."""
werte = []
for start in range(0, len(text) - 3, 4):
wert = 0
for zeichen in text[start:start + 4]:
if zeichen not in B64_INDEX:
return []
wert = (wert << 6) | B64_INDEX[zeichen]
if wert >= 8388608: # Zweierkomplement (24 Bit)
wert -= 16777216
werte.append(wert)
return werte
def decode_insertpoint(text):
"""Insertpoint (24 Zeichen) -> dict mit Position, Drehung, Saettigung.
Die Drehung steckt als Halbwinkel-Quaternion (nur qz ist belegt, reine
Z-Drehung, siehe csv:z-angle-to-quat). Rueckgabe None bei ungueltigem
String.
"""
werte = _decode_ints((text or "").strip())
if len(werte) < 6:
return None
x, y, z, _qx, _qy, qz = werte[:6]
qz_norm = max(-1.0, min(1.0, qz / float(KOS_MAX)))
winkel = math.degrees(2.0 * math.asin(min(1.0, abs(qz_norm))))
if qz_norm < 0:
winkel = -winkel
return {
"x": x / KOS_FAKTOR,
"y": y / KOS_FAKTOR,
"z": z / KOS_FAKTOR,
"drehung_grad": round(winkel, 4),
# Achsen, deren Wert an der Fixed-Point-Grenze haengt und damit
# unbrauchbar ist (siehe Modul-Hinweis in report_saturation).
"gesaettigt": [name for name, wert in (("x", x), ("y", y), ("z", z))
if abs(wert) >= KOS_MAX],
}
def decode_trans(text):
"""K1-K4 (12 Zeichen, nur Position) -> (x, y, z) in mm, oder None."""
werte = _decode_ints((text or "").strip())
if len(werte) < 3:
return None
return tuple(w / TRANS_FAKTOR for w in werte[:3])
# ------------------------------------------------------------------
# CSV einlesen
# ------------------------------------------------------------------
def _zahlen(text):
"""'1, 2, 3' -> [1.0, 2.0, 3.0]; leerer Text -> []."""
text = (text or "").strip()
if not text:
return []
try:
return [float(teil) for teil in text.split(",")]
except ValueError:
return []
def load_elements(csv_pfad):
"""CSV-Zeilen in Element-dicts umwandeln (Geometrie bereits ausgewertet)."""
with open(csv_pfad, encoding="utf-8-sig", newline="") as fh:
zeilen = list(csv.DictReader(fh, delimiter=";"))
elemente = []
for zeile in zeilen:
art = (zeile.get("TeileArt") or "").strip()
# Die synthetische Summenzeile (Omniflo Sum) ist kein Bauteil.
if art.endswith("Sum"):
continue
mitte = _zahlen(zeile.get("Position"))
masse = _zahlen(zeile.get("Boundingbox"))
if len(mitte) < 3 or len(masse) < 3:
continue
try:
merkmale = json.loads(zeile.get("Merkmale") or "{}")
except (ValueError, TypeError):
merkmale = {}
element = {
"nr": (zeile.get("Elementnummer") or "").strip(),
"teileid": (zeile.get("TeileId") or "").strip(),
"teileart": art,
"bezeichnung": (zeile.get("Bezeichnung") or "").strip(),
"planquadrat": (zeile.get("Planquadrat") or "").strip(),
"mitte": {"x": mitte[0], "y": mitte[1], "z": mitte[2]},
"masse": {"dx": masse[0], "dy": masse[1], "dz": masse[2]},
"merkmale": merkmale,
"insertpoint": decode_insertpoint(zeile.get("Insertpoint")),
"ks_ein": decode_trans(zeile.get("K1")),
"ks_aus": decode_trans(zeile.get("K2")),
}
element["footprint"] = _footprint(element)
element["z_bereich"] = (mitte[2] - masse[2] / 2.0, mitte[2] + masse[2] / 2.0)
element["achse"] = "x" if masse[0] >= masse[1] else "y"
element["enden"] = _enden(element)
elemente.append(element)
return elemente
def _footprint(element):
"""Achsparallele Grundflaeche (minx, maxx, miny, maxy) in mm.
Die Boundingbox aus dem Export ist bereits WCS-achsparallel (sie kommt
von vla-getboundingbox), die Drehung ist darin also enthalten - es muss
hier nichts mehr rotiert werden.
"""
m, d = element["mitte"], element["masse"]
return (m["x"] - d["dx"] / 2.0, m["x"] + d["dx"] / 2.0,
m["y"] - d["dy"] / 2.0, m["y"] + d["dy"] / 2.0)
def _enden(element):
"""Die beiden Enden der Laengsachse als Anschlusspunkte im Grundriss."""
minx, maxx, miny, maxy = element["footprint"]
m = element["mitte"]
if element["achse"] == "x":
return {"a": (minx, m["y"]), "b": (maxx, m["y"])}
return {"a": (m["x"], miny), "b": (m["x"], maxy)}
# ------------------------------------------------------------------
# Verbindungen ermitteln
# ------------------------------------------------------------------
def _abstand_zu_flaeche(punkt, footprint):
"""Kuerzester Abstand eines Punktes zu einem Rechteck (0 = innerhalb)."""
px, py = punkt
minx, maxx, miny, maxy = footprint
dx = max(minx - px, 0.0, px - maxx)
dy = max(miny - py, 0.0, py - maxy)
return math.hypot(dx, dy)
def find_connections(elemente, toleranz_mm=PORT_TOLERANZ_MM):
"""Ungerichtete Anschluesse: Ende von A liegt auf der Flaeche von B.
Rueckgabe: Liste von dicts {"a": idx, "a_port": "a"|"b", "b": idx,
"abstand": mm}.
Auch Kreisel werden als Ausgangspunkt geprueft: ein Kreisel kann mit
seinem Bahnende an einen anderen Kreisel anschliessen (Stichbahn). Das
ist unkritisch, weil hier nur ENDPUNKTE gegen Flaechen getestet werden -
die flaechige Ueberdeckung eines 20 m langen Kreisels, die die Spalte
"Nachbarn" verfaelscht, spielt dabei keine Rolle.
"""
anschluesse = []
for idx_a, el_a in enumerate(elemente):
for port, punkt in el_a["enden"].items():
beste = None
for idx_b, el_b in enumerate(elemente):
if idx_a == idx_b:
continue
abstand = _abstand_zu_flaeche(punkt, el_b["footprint"])
if abstand > toleranz_mm:
continue
if beste is None or abstand < beste["abstand"]:
beste = {"a": idx_a, "a_port": port, "b": idx_b,
"abstand": round(abstand, 2)}
if beste:
anschluesse.append(beste)
return anschluesse
# ------------------------------------------------------------------
# Richtung ableiten
# ------------------------------------------------------------------
def _partner_hoehe(elemente, idx):
"""Referenzhoehe eines Elements (Mitte der Z-Ausdehnung, in mm)."""
return elemente[idx]["mitte"]["z"]
def derive_flow(elemente, anschluesse):
"""Aus den ungerichteten Anschluessen gerichtete Flusskanten machen.
Ein Streckenelement (Gefaellestrecke/Foerderer) hat zwei Anschluesse.
Die Richtung wird ueber die Hoehe der beiden Partner bestimmt:
* Gefaellestrecke -> vom hoeheren zum tieferen Partner (Schwerkraft).
* Foerderer mit Antriebfahrtrichtung "Auf" -> vom tieferen zum hoeheren,
"Ab" -> umgekehrt.
Liegen K1/K2 vor, gewinnt diese exakte Angabe.
"""
# Anschluesse je Streckenelement sammeln
je_element = {}
for anschluss in anschluesse:
je_element.setdefault(anschluss["a"], []).append(anschluss)
kanten = []
for idx, liste in sorted(je_element.items()):
el = elemente[idx]
art = el["teileart"]
# Exakte Richtung, sobald der Export K1/K2 liefert
if el["ks_ein"] and el["ks_aus"]:
kanten.extend(_kanten_aus_ks(elemente, idx, liste))
continue
if len(liste) < 2:
# Nur ein Anschluss -> Richtung nicht bestimmbar, aber die
# Verbindung selbst ist real und darf nicht verloren gehen.
for anschluss in liste:
kanten.append(_kante(idx, anschluss["b"], "unbestimmt",
anschluss["abstand"], gerichtet=False))
continue
# Zwei (oder mehr) Anschluesse: nach Partnerhoehe sortieren
liste = sorted(liste, key=lambda a: _partner_hoehe(elemente, a["b"]))
tief, hoch = liste[0], liste[-1]
h_tief = _partner_hoehe(elemente, tief["b"])
h_hoch = _partner_hoehe(elemente, hoch["b"])
richtung_quelle = "unbestimmt"
von, nach = None, None
if art in GEFAELLE_ARTEN and h_hoch > h_tief:
von, nach = hoch, tief # Schwerkraft: hoch -> tief
richtung_quelle = "gefaelle"
elif art in STRECKEN_ARTEN:
fahrt = (el["merkmale"].get("Antriebfahrtrichtung") or "").strip().lower()
if fahrt.startswith("auf") and h_hoch > h_tief:
von, nach = tief, hoch
richtung_quelle = "antrieb"
elif fahrt.startswith("ab") and h_hoch > h_tief:
von, nach = hoch, tief
richtung_quelle = "antrieb"
if von is None:
# Richtung unklar -> beide Anschluesse ungerichtet ausgeben
for anschluss in liste:
kanten.append(_kante(idx, anschluss["b"], "unbestimmt",
anschluss["abstand"], gerichtet=False))
continue
# Fluss laeuft Partner -> Element -> Partner
kanten.append(_kante(von["b"], idx, richtung_quelle, von["abstand"]))
kanten.append(_kante(idx, nach["b"], richtung_quelle, nach["abstand"]))
return kanten
def _kanten_aus_ks(elemente, idx, liste):
"""Kanten aus den exakten KS_EIN/KS_AUS-Positionen (Spalten K1/K2)."""
el = elemente[idx]
ein, aus = el["ks_ein"][:2], el["ks_aus"][:2]
kanten = []
for anschluss in liste:
partner = elemente[anschluss["b"]]
d_ein = _abstand_zu_flaeche(ein, partner["footprint"])
d_aus = _abstand_zu_flaeche(aus, partner["footprint"])
if d_ein <= d_aus:
kanten.append(_kante(anschluss["b"], idx, "ks", anschluss["abstand"]))
else:
kanten.append(_kante(idx, anschluss["b"], "ks", anschluss["abstand"]))
return kanten
def _kante(von, nach, quelle, abstand, gerichtet=True):
return {"von": von, "nach": nach, "richtung_quelle": quelle,
"abstand_mm": abstand, "gerichtet": gerichtet}
def _dedupe(kanten):
"""Doppelte Kanten entfernen.
Dieselbe Uebergabestelle wird von beiden Seiten gefunden (z.B. Kreisel A
findet das Ende von Kreisel B und umgekehrt). Gerichtete Kanten werden
nach (von, nach) dedupliziert, ungerichtete nach dem unsortierten
Knotenpaar - sonst erscheint eine Stichbahn zweimal. Von mehreren
Treffern gewinnt der mit dem kleinsten Abstand.
"""
beste = {}
for kante in kanten:
if kante["gerichtet"]:
schluessel = ("g", kante["von"], kante["nach"])
else:
schluessel = ("u",) + tuple(sorted((kante["von"], kante["nach"])))
vorhanden = beste.get(schluessel)
if vorhanden is None or kante["abstand_mm"] < vorhanden["abstand_mm"]:
beste[schluessel] = kante
return list(beste.values())
# ------------------------------------------------------------------
# Ausgabe
# ------------------------------------------------------------------
def _label(el):
name = el["bezeichnung"] or el["teileart"]
return f"{name} [{el['teileid']}]"
def build_graph(elemente, toleranz_mm=PORT_TOLERANZ_MM):
"""Vollstaendiges Graph-Modell (Knoten + Kanten + Kennzahlen)."""
anschluesse = find_connections(elemente, toleranz_mm)
kanten = derive_flow(elemente, anschluesse)
kanten = _dedupe(kanten)
knoten = []
for idx, el in enumerate(elemente):
knoten.append({
"id": idx,
"teileid": el["teileid"],
"teileart": el["teileart"],
"bezeichnung": el["bezeichnung"],
"planquadrat": el["planquadrat"],
"rolle": _rolle(el),
"position": el["mitte"],
"masse": el["masse"],
"hoehe_z": el["mitte"]["z"],
"merkmale": el["merkmale"],
})
eingang = {}
ausgang = {}
offen = {}
for kante in kanten:
if kante["gerichtet"]:
ausgang[kante["von"]] = ausgang.get(kante["von"], 0) + 1
eingang[kante["nach"]] = eingang.get(kante["nach"], 0) + 1
else:
# Ungerichtete Verbindung zaehlt bei beiden Knoten, damit ein nur
# ungerichtet angebundenes Element nicht wie isoliert aussieht.
for seite in (kante["von"], kante["nach"]):
offen[seite] = offen.get(seite, 0) + 1
for n in knoten:
n["grad_ein"] = eingang.get(n["id"], 0)
n["grad_aus"] = ausgang.get(n["id"], 0)
n["grad_offen"] = offen.get(n["id"], 0)
return {
"quelle": "EXPORTCSV",
"toleranz_mm": toleranz_mm,
"knoten": knoten,
"kanten": kanten,
"kennzahlen": {
"knoten": len(knoten),
"kanten": len(kanten),
"gerichtet": sum(1 for k in kanten if k["gerichtet"]),
"unbestimmt": sum(1 for k in kanten if not k["gerichtet"]),
},
}
def _rolle(el):
"""Funktion des Elements im Materialfluss."""
if el["teileart"] in KREISEL_ARTEN:
return "Verteiler"
if el["teileart"] in GEFAELLE_ARTEN:
return "Schwerkraftstrecke"
if el["teileart"] in STRECKEN_ARTEN:
return "Foerderer"
return "Element"
STIL = {
"Verteiler": ("box3d", "#1f6feb"),
"Schwerkraftstrecke": ("box", "#8250df"),
"Foerderer": ("box", "#1a7f37"),
"Element": ("ellipse", "#57606a"),
}
def to_dot(graph):
"""GraphViz-DOT. Kanten ohne bestimmte Richtung werden gestrichelt."""
zeilen = ["digraph Materialfluss {", " rankdir=LR;",
' node [style="filled,rounded" fontname="Segoe UI" fontsize=10 fillcolor="#f6f8fa"];',
' edge [fontname="Segoe UI" fontsize=8];']
for n in graph["knoten"]:
form, farbe = STIL[n["rolle"]]
beschriftung = f"{n['bezeichnung']}\\n{n['teileart']}\\nz={n['hoehe_z']:.0f} mm"
zeilen.append(f' n{n["id"]} [label="{beschriftung}" shape={form} color="{farbe}"];')
for k in graph["kanten"]:
if k["gerichtet"]:
zeilen.append(f' n{k["von"]} -> n{k["nach"]} '
f'[label="{k["richtung_quelle"]}"];')
else:
zeilen.append(f' n{k["von"]} -> n{k["nach"]} '
f'[dir=none style=dashed color="#8c959f"];')
zeilen.append("}")
return "\n".join(zeilen)
def to_mermaid(graph):
"""Mermaid-Flowchart (fuer Doku/Markdown)."""
zeilen = ["flowchart LR"]
for n in graph["knoten"]:
text = f"{n['bezeichnung']}<br/>z={n['hoehe_z']:.0f}"
if n["rolle"] == "Verteiler":
zeilen.append(f' n{n["id"]}[[{text}]]')
else:
zeilen.append(f' n{n["id"]}[{text}]')
for k in graph["kanten"]:
pfeil = "-->" if k["gerichtet"] else "---"
zeilen.append(f' n{k["von"]} {pfeil} n{k["nach"]}')
return "\n".join(zeilen)
def report_saturation(elemente):
"""Warnt, wenn Insertpoint-Werte an der Fixed-Point-Grenze haengen.
csv:kos-encode in Lisp/export.lsp rechnet mit Faktor 1000, der 24-Bit-
Wertebereich endet damit bei +-8388.607 Zeichnungseinheiten. Bei einer
Zeichnung in mm ist bei +-8.39 m Schluss - jede groessere Koordinate wird
stillschweigend abgeschnitten. Betroffene Insertpoints sind unbrauchbar.
"""
betroffen = [el for el in elemente
if el["insertpoint"] and el["insertpoint"]["gesaettigt"]]
return betroffen
# ------------------------------------------------------------------
# Kommandozeile
# ------------------------------------------------------------------
def _flusspfade(graph):
"""Zusammengefasste Flusspfade: je Verteiler die abgehenden Strecken."""
nach_id = {n["id"]: n for n in graph["knoten"]}
gruppen = {}
for kante in graph["kanten"]:
if not kante["gerichtet"]:
continue
von, nach = nach_id[kante["von"]], nach_id[kante["nach"]]
if von["rolle"] == "Verteiler":
gruppen.setdefault((von["bezeichnung"], nach["teileart"]), []).append(nach)
return gruppen
def main(argv):
if len(argv) < 2:
print(__doc__)
return 2
csv_pfad = argv[1]
basis = argv[2] if len(argv) > 2 else os.path.join(
os.path.dirname(os.path.abspath(csv_pfad)), "export_graph")
elemente = load_elements(csv_pfad)
if not elemente:
print(f"[export_graph] Keine Elemente in {csv_pfad} gefunden.")
return 1
print(f"[export_graph] {len(elemente)} Elemente aus {csv_pfad} geladen.")
gesaettigt = report_saturation(elemente)
if gesaettigt:
print(f"[export_graph] WARNUNG: {len(gesaettigt)} von {len(elemente)} "
f"Insertpoint-Werten sind an der Fixed-Point-Grenze abgeschnitten "
f"(+-{KOS_MAX / KOS_FAKTOR:.3f} Einheiten).")
print("[export_graph] Betroffene Achsen werden nicht verwendet; "
"der Graph nutzt Position/Boundingbox.")
ohne_ks = [el for el in elemente if not (el["ks_ein"] and el["ks_aus"])]
if ohne_ks:
print(f"[export_graph] HINWEIS: {len(ohne_ks)} Elemente ohne K1/K2 "
f"(KS_EIN/KS_AUS) - Richtung wird heuristisch abgeleitet.")
graph = build_graph(elemente)
kz = graph["kennzahlen"]
print(f"[export_graph] Graph: {kz['knoten']} Knoten, {kz['kanten']} Kanten "
f"({kz['gerichtet']} gerichtet, {kz['unbestimmt']} ohne Richtung).")
with open(basis + ".json", "w", encoding="utf-8") as fh:
json.dump(graph, fh, indent=2, ensure_ascii=False)
with open(basis + ".dot", "w", encoding="utf-8") as fh:
fh.write(to_dot(graph))
with open(basis + ".mmd", "w", encoding="utf-8") as fh:
fh.write(to_mermaid(graph))
for endung in (".json", ".dot", ".mmd"):
print(f"[export_graph] geschrieben: {basis}{endung}")
# Kurzuebersicht des ermittelten Flusses
print("\n=== Materialfluss (zusammengefasst) ===")
for (verteiler, art), ziele in sorted(_flusspfade(graph).items()):
print(f" {verteiler} --> {len(ziele)}x {art}")
print("\n=== Verteiler ===")
for n in graph["knoten"]:
if n["rolle"] != "Verteiler":
continue
print(f" {n['bezeichnung']:<12} z={n['hoehe_z']:7.1f} mm "
f"ein={n['grad_ein']:>2} aus={n['grad_aus']:>2} "
f"ohne Richtung={n['grad_offen']:>2}")
isoliert = [n for n in graph["knoten"]
if not (n["grad_ein"] or n["grad_aus"] or n["grad_offen"])]
if isoliert:
print(f"\n[export_graph] WARNUNG: {len(isoliert)} Element(e) ohne jede "
f"Verbindung: " + ", ".join(n["bezeichnung"] for n in isoliert))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))