Testrun-Export nach tests/output umbiegen, Sivas-Bezeichner-Datei optional, Sivas-Merkmalzuordnung in Config

SSG_RUN_ALL_TESTS_EXPORT setzte *export-test-override* nicht, dadurch
landeten CSV/Sivas-Exporte trotz Automatik-Modus in DXFM_RESULTS statt in
tests/output wie die DXF/DWG-Dateien selbst - jetzt analog zu
TEST_EXPORT_ALL behoben.

EXPORTSIVAS schrieb bisher immer zusaetzlich eine "_mit_bezeichnung.csv"
(Klartext-Kopie). Neuer Schalter "Sivas Export mit Bezeichner" im Dialog
CSV_EXPORT_KONFIG (Standard aus) macht das optional.

Die Attribut->Sivas-Merkmal-Zuordnung der build_*_details-Funktionen in
export_sivas.py (Gefaellestrecke/Kreisel/Eckrad/Geruest/VarioFoerderer,
soweit nicht von der Segmentanzahl abhaengig) liegt jetzt deklarativ in
cfg/sivas.cfg statt hartcodiert im Python - neuer Loader
lib/sivas_mapping.py. Ablauflogik (Gruppierung, Automation-Summenzeile,
Segment-Zaehlung) bleibt Code. Gegen den alten Code mit synthetischen
Testblöcken verifiziert: identische Ausgabe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-28 16:39:18 +02:00
co-authored by Claude Sonnet 5
parent 33d0688763
commit e8c5cd0a63
6 changed files with 373 additions and 101 deletions
+93 -97
View File
@@ -31,7 +31,7 @@ Neues CSV-Format:
cfg/Merkmalsstamm_in_Sivas.xlsx fuer die Bezeichnung je Nummer.
Aufruf:
python export_sivas.py <export_raw.json> <data_dir> <output.csv>
python export_sivas.py <export_raw.json> <data_dir> <output.csv> [--mit-bezeichnung]
Argumente:
export_raw.json - JSON-Datei mit allen INSERT-Entities aus der Zeichnung
@@ -39,13 +39,15 @@ Argumente:
fuer den Aufrufer export.lsp erhalten)
output.csv - Ziel-CSV-Datei (reine Merkmalnummern, fuer den
Sivas-Import)
Zusaetzlich zu output.csv wird immer eine zweite Datei
"<output>_mit_bezeichnung.csv" geschrieben: gleicher Inhalt, aber jeder
Merkmalnummer-Key im details-JSON wird durch die zugehoerige Klartext-
Bezeichnung aus cfg/Merkmalsstamm_in_Sivas.xlsx ersetzt (Wert bleibt
unveraendert) - reine Lesehilfe zur Pruefung, wird von Sivas selbst nicht
eingelesen.
--mit-bezeichnung - optional. Nur dann wird zusaetzlich zu output.csv
eine zweite Datei "<output>_mit_bezeichnung.csv"
geschrieben: gleicher Inhalt, aber jeder Merkmalnummer-
Key im details-JSON wird durch die zugehoerige Klartext-
Bezeichnung aus cfg/Merkmalsstamm_in_Sivas.xlsx ersetzt
(Wert bleibt unveraendert) - reine Lesehilfe zur
Pruefung, wird von Sivas selbst nicht eingelesen.
Schalter im Dialog CSV_EXPORT_KONFIG
("Sivas Export mit Bezeichner"), Standard aus.
"""
import json
@@ -54,6 +56,7 @@ import os
from export_blockpatterns import load_patterns, matches_any, csv_quote, safe_float, safe_int
from merkmalstamm import lade_merkmalstamm, bezeichnung_fuer
import sivas_mapping
BLOCKPATTERNS = load_patterns()
@@ -81,11 +84,10 @@ def get_id(block):
# --- Sivas-Merkmalnummern: Anzeigetexte, die nicht 1:1 aus dem Attribut
# uebernommen werden koennen (Attribute bleiben ASCII, siehe CLAUDE.md-
# Konvention "Alle Texte in Dateien ohne Umlaute" - Sivas erwartet aber die
# deutsche Schreibweise mit Umlauten/Sonderzeichen). Nur hier zentral, nicht
# ueber die uebliche ssg-text-Sprachdatei, da rein Sivas-seitig.
GERUEST_TYP_ANZEIGE = {
"Schoenenberger Geruest": "Schönenberger-Gerüst",
}
# deutsche Schreibweise mit Umlauten/Sonderzeichen). Tabelle kommt aus
# cfg/sivas.cfg [geruest_typ_anzeige], nicht ueber die uebliche ssg-text-
# Sprachdatei, da rein Sivas-seitig.
GERUEST_TYP_ANZEIGE = sivas_mapping.load_flat_mapping("geruest_typ_anzeige")
SEPARATOR_BEARBEITUNG_ANZEIGE = "ohne HARTCOAT HRC 50 ± 5"
@@ -100,6 +102,20 @@ def ja_nein(flag_1_0):
return "Ja" if flag_1_0 == "1" else "Nein"
def ist_eins(flag_1_0):
"""Wandelt ein "0"/"1"-Attribut in ein echtes bool um (True/False im
JSON, NICHT "Ja"/"Nein" wie ja_nein - siehe build_eckrad_details)."""
return flag_1_0 == "1"
# --- Transform-Namen, wie sie in cfg/sivas.cfg hinter "|" stehen koennen ---
TRANSFORMS = {
"ja_nein": ja_nein,
"geruest_typ_anzeige": geruest_typ_anzeige,
"ist_eins": ist_eins,
}
def streckengruppe_objekt_text(attribs):
"""Merkmal 4300 "Streckengruppe": "mehrere Objekte", wenn der Block ein
kombinierter Streckengruppe-Block (Gefaelle+Vario, TYP-Attribut
@@ -108,27 +124,24 @@ def streckengruppe_objekt_text(attribs):
return "mehrere Objekte" if typ == "Streckengruppe" else "ein Objekt"
SIVAS_MAPPING_GEFAELLESTRECKE = sivas_mapping.load_section_mapping("gefaellestrecke")
def build_gefaellestrecke_details(block, anzahl=1):
"""Merkmale-Dict (Sivas-Merkmalnummern) fuer eine Gruppe gleichartiger
ILS Gefaellestrecken (GF_N-Bloecke). anzahl ist die Anzahl der in dieser
Zeile zusammengefassten, baugleichen Bloecke (4311) - anders als bei
Kreisel steht hier auf der Sivas-Hauptzeile selbst immer Anzahl=1 (siehe
process_blocks), die reale Stueckzahl steckt nur in 4311."""
attribs = block.get("attribs", {})
process_blocks), die reale Stueckzahl steckt nur in 4311.
return {
"2950": attribs.get("MONTAGEHOEHE_m", "0"),
"4300": streckengruppe_objekt_text(attribs),
"4384": "Gefällestrecke",
"4311": str(anzahl),
"4318": attribs.get("L_GF_m", ""),
"4391": attribs.get("GF_WINKEL", ""),
"4337": attribs.get("ANZAHL_SEPARATOR", "0"),
"4338": SEPARATOR_BEARBEITUNG_ANZEIGE,
"4339": attribs.get("ANZAHL_SCANNER", "0"),
"3992": ja_nein(attribs.get("GERUEST_EINZELMODUL", "0")),
"3993": geruest_typ_anzeige(attribs.get("GERUEST_TYP", "Schoenenberger Geruest")),
}
Die reine Attribut->Merkmal-Zuordnung kommt aus cfg/sivas.cfg
[gefaellestrecke]; 4300 (Streckengruppe-Text) und 4311 (Anzahl-Parameter)
sind berechnete/parametrisierte Werte und bleiben hier Code."""
attribs = block.get("attribs", {})
details = sivas_mapping.anwenden(SIVAS_MAPPING_GEFAELLESTRECKE, attribs, TRANSFORMS)
details["4300"] = streckengruppe_objekt_text(attribs)
details["4311"] = str(anzahl)
return details
def kreisel_signature(block):
@@ -203,6 +216,9 @@ def parse_str_list(csv_str):
return [p.strip() for p in str(csv_str or "").split(",") if p.strip()]
SIVAS_MAPPING_VARIOFOERDERER = sivas_mapping.load_section_mapping("variofoerderer")
def build_variofoerderer_details(block):
"""
Merkmale-Dict (Sivas-Merkmalnummern) fuer eine VF_N-Streckengruppe
@@ -216,12 +232,14 @@ def build_variofoerderer_details(block):
ausstehenden Neuzuordnung von Separator/Scanner zum jeweiligen
Streckenabschnitt) - bis dahin steht 4337/4339 fest auf "0" und der volle
Attributwert steht auf 4381/4383.
Die von der Segmentanzahl UNABHAENGIGEN Felder (2950, GF_Bogen_*/
VF_Bogen_*-Zaehler, 4381-4383, 3992/3993) kommen aus cfg/sivas.cfg
[variofoerderer]. Die Gefaelle-/Vario-Segmentpaare (4312-4392, 4353/
4354/4386, abhaengig von len(gf_laengen)/len(vf_laengen)) bleiben Code.
"""
attribs = block.get("attribs", {})
def to_int(key):
return safe_int(attribs.get(key, "0") or "0")
gf_laengen = parse_float_list(attribs.get("L_GF_m", ""))
gf_winkel = parse_float_list(attribs.get("GF_WINKEL", ""))
vf_laengen = parse_float_list(attribs.get("L_VF_m", ""))
@@ -229,11 +247,9 @@ def build_variofoerderer_details(block):
vf_richtung = parse_str_list(attribs.get("ANTRIEBFAHRTRICHTUNG", ""))
motorseiten = parse_str_list(attribs.get("MOTORSEITE", ""))
details = {
"2950": attribs.get("MONTAGEHOEHE_m", "0"),
"4300": "mehrere Objekte",
"4311": "1",
}
details = sivas_mapping.anwenden(SIVAS_MAPPING_VARIOFOERDERER, attribs, TRANSFORMS)
details["4300"] = "mehrere Objekte"
details["4311"] = "1"
if len(gf_laengen) >= 1:
details["4312"] = "Ja"
@@ -249,85 +265,62 @@ def build_variofoerderer_details(block):
else:
details["4314"] = "Nein"
details.update({
"4329": to_int("GF_Bogen_L_90"),
"4330": to_int("GF_Bogen_L_60"),
"4331": to_int("GF_Bogen_L_30"),
"4332": to_int("GF_Bogen_R_90"),
"4333": to_int("GF_Bogen_R_60"),
"4334": to_int("GF_Bogen_R_30"),
"4337": "0",
"4338": SEPARATOR_BEARBEITUNG_ANZEIGE,
"4339": "0",
"4340": "Ja" if vf_laengen else "Nein",
"4341": sum(1 for seite in motorseiten if seite.lower() == "links"),
"4342": sum(1 for seite in motorseiten if seite.lower() == "rechts"),
})
details["4337"] = "0"
details["4338"] = SEPARATOR_BEARBEITUNG_ANZEIGE
details["4339"] = "0"
details["4340"] = "Ja" if vf_laengen else "Nein"
details["4341"] = sum(1 for seite in motorseiten if seite.lower() == "links")
details["4342"] = sum(1 for seite in motorseiten if seite.lower() == "rechts")
if vf_laengen:
details["4353"] = vf_laengen[0]
details["4354"] = vf_richtung[0].lower() if vf_richtung else ""
details["4386"] = str(int(vf_winkel[0])) if vf_winkel else ""
details.update({
"4373": to_int("VF_Bogen_A_90"),
"4374": to_int("VF_Bogen_A_60"),
"4375": to_int("VF_Bogen_A_30"),
"4376": to_int("VF_Bogen_I_90"),
"4377": to_int("VF_Bogen_I_60"),
"4378": to_int("VF_Bogen_I_30"),
"4381": attribs.get("ANZAHL_SEPARATOR", "0"),
"4382": SEPARATOR_BEARBEITUNG_ANZEIGE,
"4383": attribs.get("ANZAHL_SCANNER", "0"),
"3992": ja_nein(attribs.get("GERUEST_EINZELMODUL", "0")),
"3993": geruest_typ_anzeige(attribs.get("GERUEST_TYP", "Schoenenberger Geruest")),
})
return {k: str(v) for k, v in details.items()}
SIVAS_MAPPING_KREISEL = sivas_mapping.load_section_mapping("kreisel")
SIVAS_MAPPING_ECKRAD = sivas_mapping.load_section_mapping("eckrad")
SIVAS_MAPPING_GERUEST = sivas_mapping.load_section_mapping("geruest")
def build_kreisel_details(block):
"""Merkmale-Dict (Sivas-Merkmalnummern) fuer eine Gruppe gleichartiger
ILS Kreisel. 3511 ("Kreisel - zu fertigende Menge") steht bewusst fest
auf "1" - die reale Stueckzahl der Gruppe steht auf der Sivas-Hauptzeile
selbst (Anzahl-Spalte, siehe kreisel_groups in process_blocks)."""
selbst (Anzahl-Spalte, siehe kreisel_groups in process_blocks).
Die reine Attribut->Merkmal-Zuordnung kommt aus cfg/sivas.cfg [kreisel];
3510 (Kreiselart-Anzeigetext) und 3512 (Abstand mm->m, gerundet) sind
berechnete Werte und bleiben hier Code."""
attribs = block.get("attribs", {})
abstand_m = str(round(safe_float(attribs.get("ABSTAND", "2300"), 2300.0) / 1000.0, 2))
kreiselart = "Kreisel mit PINBAND" if attribs.get("KREISELART", "STANDARD") == "PIN" else "Kreisel"
return {
"2952": attribs.get("MONTAGEHOEHE_m", "0"),
"3510": kreiselart,
"3511": "1",
"3512": abstand_m,
"3515": attribs.get("ANZAHL_SEPARATOR", attribs.get("N_SEPARATOREN", "2")),
"3517": attribs.get("ANZAHL_SCANNER", attribs.get("N_SCANNER", "0")),
"3519": attribs.get("ANZAHL_RAMPEN", attribs.get("N_RAMPEN", "0")),
"3992": ja_nein(attribs.get("GERUEST_EINZELMODUL", "0")),
"3993": geruest_typ_anzeige(attribs.get("GERUEST_TYP", "Schoenenberger Geruest")),
}
details = sivas_mapping.anwenden(SIVAS_MAPPING_KREISEL, attribs, TRANSFORMS)
details["3510"] = kreiselart
details["3512"] = abstand_m
return details
def build_eckrad_details(block):
"""Merkmale-Dict fuer ein ILS Eckrad."""
"""Merkmale-Dict fuer ein ILS Eckrad. Vollstaendig aus cfg/sivas.cfg
[eckrad] (keine berechneten Werte)."""
attribs = block.get("attribs", {})
return {
"Geruest fuer Einzelmodul": attribs.get("GERUEST_EINZELMODUL", "0") == "1",
"Geruestoption fuer Einzelmodul": attribs.get("GERUEST_TYP", "Schoenenberger Geruest"),
}
return sivas_mapping.anwenden(SIVAS_MAPPING_ECKRAD, attribs, TRANSFORMS)
def build_geruest_details(block):
"""Merkmale-Dict fuer eine GR_-Geruest-Baugruppe (siehe pattern_geruest in
cfg/export.cfg, GeruestInsert.lsp *geruest-attrib-defs*). ARTINR/
BEZEICHNUNG/TEILEART/LAENGE wandern in feste Sivas-Spalten (sivasnr/
bezeichnung/teileart/laenge_mm), FARBE bleibt als Rest-Attribut hier.
LAYER ist reine BricsCAD-Zeichnungsinformation ohne Sivas-Bedeutung und
wird bewusst NICHT mit exportiert (anders als im CSV-Export
export_csv.py, wo sie fuer die Zuordnung in der Zeichnung nuetzlich
bleibt)."""
bezeichnung/teileart/laenge_mm), FARBE bleibt als Rest-Attribut hier
(cfg/sivas.cfg [geruest]). LAYER ist reine BricsCAD-Zeichnungsinformation
ohne Sivas-Bedeutung und wird bewusst NICHT mit exportiert (anders als im
CSV-Export export_csv.py, wo sie fuer die Zuordnung in der Zeichnung
nuetzlich bleibt)."""
attribs = block.get("attribs", {})
return {
"Farbe": attribs.get("FARBE", ""),
}
return sivas_mapping.anwenden(SIVAS_MAPPING_GERUEST, attribs, TRANSFORMS)
# --- Bekannte Bloecke ignorieren ---
@@ -890,7 +883,7 @@ def lesbarer_dateiname(output_csv):
def main():
if len(sys.argv) < 4:
print("Aufruf: python export_sivas.py <export_raw.json> <data_dir> <output.csv>")
print("Aufruf: python export_sivas.py <export_raw.json> <data_dir> <output.csv> [--mit-bezeichnung]")
sys.exit(1)
raw_json_path = sys.argv[1]
@@ -898,6 +891,7 @@ def main():
# kommen aus ARTINR, nicht mehr aus dem Katalog. Argument bleibt fuer
# den Aufrufer (export.lsp) bestehen.
output_csv = sys.argv[3]
mit_bezeichnung = "--mit-bezeichnung" in sys.argv[4:]
blocks = load_json(raw_json_path)
print(f"[export_sivas] {len(blocks)} Bloecke geladen.")
@@ -914,14 +908,16 @@ def main():
# Zweite, lesbare Kopie fuer die manuelle Pruefung (Merkmalnummer +
# Bezeichnung aus cfg/Merkmalsstamm_in_Sivas.xlsx) - wird von Sivas
# selbst NICHT eingelesen, nur output_csv ist der eigentliche Import.
merkmalstamm = lade_merkmalstamm()
output_csv_lesbar = lesbarer_dateiname(output_csv)
with open(output_csv_lesbar, "w", encoding="utf-8") as f:
f.write(header + "\n")
for item in items:
details = details_mit_bezeichnung(item["details"], merkmalstamm)
f.write(format_csv_line(item, details=details) + "\n")
print(f"[export_sivas] Lesbare Kopie geschrieben: {output_csv_lesbar}")
# Nur bei explizitem --mit-bezeichnung-Schalter (Dialog CSV_EXPORT_KONFIG).
if mit_bezeichnung:
merkmalstamm = lade_merkmalstamm()
output_csv_lesbar = lesbarer_dateiname(output_csv)
with open(output_csv_lesbar, "w", encoding="utf-8") as f:
f.write(header + "\n")
for item in items:
details = details_mit_bezeichnung(item["details"], merkmalstamm)
f.write(format_csv_line(item, details=details) + "\n")
print(f"[export_sivas] Lesbare Kopie geschrieben: {output_csv_lesbar}")
if __name__ == "__main__":
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sivas_mapping.py - Liest die Attribut->Sivas-Merkmal-Zuordnung aus
cfg/sivas.cfg und wendet sie auf ein Block-Attribut-Dict an.
Einzige Quelle fuer die "einfachen" build_*_details-Zuordnungen in
export_sivas.py (siehe cfg/sivas.cfg fuer das Zeilenformat und die
Begruendung, warum nur die einfachen Faelle hier landen).
"""
import configparser
import functools
import os
def cfg_path_from_env(default_dir=None):
"""Pfad zu sivas.cfg: DXFM_CFG env var, sonst <repo>/cfg."""
dxfm_cfg = os.environ.get("DXFM_CFG")
if dxfm_cfg:
return os.path.join(dxfm_cfg, "sivas.cfg")
base = default_dir or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "cfg", "sivas.cfg")
def _parse_wert(roh):
"""Zerlegt eine Config-Wertzeile "attribut|alt_attribut:default|transform"
in (attribut, alt_attribut_oder_None, default, transform_oder_None).
Reihenfolge beim Parsen: erst am ":" in (attributteil, defaultteil)
trennen (nur der erste ":" zaehlt, ein Default-Text soll selbst keinen
":" enthalten muessen), danach im defaultteil am LETZTEN "|" den
Transform-Namen abtrennen (ein Default-Text wie "ohne HARTCOAT..."
enthaelt kein "|"). attributteil wird am ERSTEN "|" in Attribut/
Fallback-Attribut gesplittet.
"""
roh = roh.strip()
if ":" in roh:
attribut_teil, rest = roh.split(":", 1)
else:
attribut_teil, rest = roh, ""
transform = None
default = rest.strip()
if "|" in rest:
default_teil, transform_teil = rest.rsplit("|", 1)
default = default_teil.strip()
transform = transform_teil.strip() or None
attribut_teil = attribut_teil.strip()
alt_attribut = None
if "|" in attribut_teil:
attribut, alt_attribut = (p.strip() for p in attribut_teil.split("|", 1))
else:
attribut = attribut_teil
alt_attribut = alt_attribut or None
return (attribut or None, alt_attribut, default, transform)
@functools.lru_cache(maxsize=None)
def load_sivas_cfg(cfg_path):
"""Liest sivas.cfg einmal je Pfad (Prozess-Cache)."""
parser = configparser.ConfigParser(inline_comment_prefixes=None)
parser.optionxform = str # Keys (Merkmalnummern/Klartext-Keys) case-sensitive erhalten
parser.read(cfg_path, encoding="utf-8")
return parser
def load_section_mapping(section, cfg_path=None):
"""Liest einen Abschnitt aus sivas.cfg.
Rueckgabe: dict merkmal -> (attribut, alt_attribut, default, transform),
wie von _parse_wert geliefert. Bei fehlender Datei/Sektion: leeres dict -
Aufrufer bekommt dann einfach keine Merkmale aus der Config (bisheriges
Verhalten vor der Config-Einfuehrung, kein harter Fehler).
"""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_sivas_cfg(cfg_path)
mapping = {}
if parser.has_section(section):
for merkmal, roh in parser.items(section):
mapping[merkmal] = _parse_wert(roh)
return mapping
def load_flat_mapping(section, cfg_path=None):
"""Liest einen Abschnitt als einfaches dict key -> value (z.B.
[geruest_typ_anzeige]: ASCII-Wert -> Sivas-Klartext), ohne das
attribut/default/transform-Format von load_section_mapping."""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_sivas_cfg(cfg_path)
if parser.has_section(section):
return {k: v.strip() for k, v in parser.items(section)}
return {}
def anwenden(section_mapping, attribs, transforms):
"""Wendet eine per load_section_mapping() gelesene Zuordnung auf ein
Block-Attribut-Dict an.
transforms: dict transform_name -> Funktion(wert) -> wert. Ein in der
Config genannter, aber hier unbekannter Transform-Name wird ignoriert
(Rohwert bleibt unveraendert) statt abzubrechen - ein Tippfehler in
sivas.cfg darf den Export nicht zum Stillstand bringen.
Rueckgabe: dict merkmal -> wert. Ohne Transform wird der Rohwert zu str()
gewandelt (bisheriges Verhalten der build_*_details-Funktionen: alles
Merkmalnummer-Werte sind Strings). Ein Transform liefert seinen
Rueckgabewert UNVERAENDERT (z.B. build_eckrad_details erwartet fuer
"Geruest fuer Einzelmodul" ein echtes bool, kein "True"/"False"-String -
json.dumps schreibt dann true/false statt eines Strings).
"""
ergebnis = {}
for merkmal, (attribut, alt_attribut, default, transform) in section_mapping.items():
if attribut is not None:
wert = attribs.get(attribut)
if wert is None and alt_attribut is not None:
wert = attribs.get(alt_attribut)
if wert is None:
wert = default
else:
wert = default
if transform:
fn = transforms.get(transform)
if fn is not None:
ergebnis[merkmal] = fn(wert)
continue
ergebnis[merkmal] = str(wert)
return ergebnis