From c29f13a4ae73e85fbc74941f9c3226cd9c99c4da Mon Sep 17 00:00:00 2001 From: mistangl Date: Wed, 28 Jan 2026 18:28:54 +0100 Subject: [PATCH] Unittests zu allen Elementen erstellt --- bin/manage_interpreter.bat | 3 +- bin/run_unittests.bat | 12 +- lib/Elemente/Angetriebene_Kurve.py | 47 +- lib/Elemente/Angetriebene_Kurve_tests.py | 104 +++ lib/Elemente/Eckrad.py | 153 +++- lib/Elemente/Eckrad_tests.py | 68 ++ lib/Elemente/Gefaellestrecke.py | 863 +++++++++++++---------- lib/Elemente/Gefaellestrecke_tests.py | 238 +++++++ lib/Elemente/Kreisel.py | 241 ++++--- lib/Elemente/Kreisel_tests.py | 91 +++ lib/Elemente/Omniflo.py | 166 +++-- lib/Elemente/Omniflo_tests.py | 94 +++ lib/plant2dxf.py | 6 +- pyproject.toml | 3 +- 14 files changed, 1523 insertions(+), 566 deletions(-) create mode 100644 lib/Elemente/Angetriebene_Kurve_tests.py create mode 100644 lib/Elemente/Eckrad_tests.py create mode 100644 lib/Elemente/Gefaellestrecke_tests.py create mode 100644 lib/Elemente/Kreisel_tests.py create mode 100644 lib/Elemente/Omniflo_tests.py diff --git a/bin/manage_interpreter.bat b/bin/manage_interpreter.bat index 1b061e1..248b115 100644 --- a/bin/manage_interpreter.bat +++ b/bin/manage_interpreter.bat @@ -30,4 +30,5 @@ GOTO :eof :deactivate CALL "%PROJECT%\.venv\Scripts\deactivate.bat" SET VIRTUAL_ENV_PYTHON= -GOTO :eofecho off +GOTO :eof +echo off diff --git a/bin/run_unittests.bat b/bin/run_unittests.bat index 55e24e8..85f8f75 100644 --- a/bin/run_unittests.bat +++ b/bin/run_unittests.bat @@ -2,27 +2,29 @@ CALL setenv.bat echo ======================================== -echo running all unittests in lib\Elements +echo running all unittests in lib\Elemente echo ======================================== echo. -CALL manage_interpreter.bat activate_interpreter +CALL manage_interpreter.bat activate if %ERRORLEVEL% NEQ 0 ( echo. echo Failed to activate the Python interpreter! pause exit /B 1 ) -echo python -m unittest discover -t %PROJECT_LIB% -s %PROJECT_LIB%\Elemente -p "*_tests.py" -v +echo Gefundene Testdateien: +for /f "delims=" %%f in ('dir /b "%PROJECT_LIB%\Elemente\*_tests.py" 2^>nul') do echo %%f +echo. "%VIRTUAL_ENV%\Scripts\python.exe" -m unittest discover -t %PROJECT_LIB% -s %PROJECT_LIB%\Elemente -p "*_tests.py" -v if %ERRORLEVEL% NEQ 0 ( echo. echo Tests failed! - CALL manage_interpreter.bat deactivate_interpreter + CALL manage_interpreter.bat deactivate exit /B 1 ) else ( echo. echo All tests passed! - CALL manage_interpreter.bat deactivate_interpreter + CALL manage_interpreter.bat deactivate exit /B 0 ) diff --git a/lib/Elemente/Angetriebene_Kurve.py b/lib/Elemente/Angetriebene_Kurve.py index 99a32c0..14a0ee3 100644 --- a/lib/Elemente/Angetriebene_Kurve.py +++ b/lib/Elemente/Angetriebene_Kurve.py @@ -1,8 +1,18 @@ # -*- coding: utf-8 -*- +""" +Angetriebene_Kurve — Modell für angetriebene Kurven. + +Refactoring-Änderungen: +- antrieb-Property Side-Effect entfernt: Normalisierung "Aussen"→"außen" etc. + wird nun beim Erstellen durch @field_validator erledigt +- antrieb-Property ist jetzt ein reiner read-only Getter +""" from pydantic import BaseModel, Field, field_validator class Angetriebene_Kurve(BaseModel): + """Modell für eine angetriebene Kurve.""" + teileid: str x: float y: float @@ -15,22 +25,47 @@ class Angetriebene_Kurve(BaseModel): ) winkel: int = Field(description="Der Winkel der Kurve") + @field_validator("antriebNebenStrecke", mode="before") + @classmethod + def normalize_antrieb(cls, v): + """ + Normalisiert die Antrieb-Bezeichnung beim Erstellen. + + Konvertiert englische/gemischte Schreibvarianten in + die konsistente deutsche Kleinbuchstaben-Form. + + "Aussen" → "außen", "Innen" → "innen" + """ + if isinstance(v, str): + mapping = {"Aussen": "außen", "Innen": "innen"} + return mapping.get(v, v) + return v + @property - def antrieb(self): - if self.antriebNebenStrecke == "Aussen": - self.antriebNebenStrecke = "außen" - elif self.antriebNebenStrecke == "Innen": - self.antriebNebenStrecke = "innen" + def antrieb(self) -> str: + """Antrieb-Seite (read-only, normalisiert beim Erstellen).""" return self.antriebNebenStrecke @property def hight_zwischen(self): + """Mittlere Höhe zwischen hoehe0 und hoehe1.""" return (self.hoehe0 + self.hoehe1) / 2 @classmethod def from_merkmale( cls, teileid: str, x: float, y: float, merkmale: dict ) -> "Angetriebene_Kurve": + """ + Erstellt Angetriebene_Kurve aus einem Merkmale-Dictionary. + + Args: + teileid: Teile-Identifikator + x, y: Koordinaten in mm + merkmale: Dictionary mit Eigenschaftswerten + + Returns: + Angetriebene_Kurve-Instanz + """ hoehe0 = float(merkmale.get("Höhe Anfang")) * 1000 hoehe1 = float(merkmale.get("Höhe Ende")) * 1000 winkel = int(merkmale.get("Kurvenwinkel")) @@ -38,7 +73,7 @@ class Angetriebene_Kurve(BaseModel): antriebNebenstrecke = merkmale.get("AntriebNebenStrecke") try: drehung = float(merkmale.get("Drehung")) - except Exception as e: + except (TypeError, ValueError): drehung = 0.0 return cls( diff --git a/lib/Elemente/Angetriebene_Kurve_tests.py b/lib/Elemente/Angetriebene_Kurve_tests.py new file mode 100644 index 0000000..edfef39 --- /dev/null +++ b/lib/Elemente/Angetriebene_Kurve_tests.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +""" +Unit-Tests für Angetriebene_Kurve (Refactored) +""" +import unittest + +from .Angetriebene_Kurve import Angetriebene_Kurve + + +class TestAngetriebeneKurveModell(unittest.TestCase): + """Tests für das Angetriebene_Kurve Modell.""" + + def test_from_merkmale(self): + merkmale = { + "Höhe Anfang": "1.5", + "Höhe Ende": "2.0", + "Kurvenwinkel": "90", + "Kurvenrichtung": "links", + "AntriebNebenStrecke": "außen", + "Drehung": "-270", + } + obj = Angetriebene_Kurve.from_merkmale("AK-001", 100.0, 200.0, merkmale) + self.assertEqual(obj.teileid, "AK-001") + self.assertEqual(obj.hoehe0, 1500.0) + self.assertEqual(obj.hoehe1, 2000.0) + self.assertEqual(obj.winkel, 90) + self.assertEqual(obj.kurvenrichtung, "links") + self.assertEqual(obj.drehung, -270.0) + + def test_from_merkmale_drehung_fehlend(self): + merkmale = { + "Höhe Anfang": "1.0", + "Höhe Ende": "1.0", + "Kurvenwinkel": "45", + "Kurvenrichtung": "rechts", + "AntriebNebenStrecke": "innen", + } + obj = Angetriebene_Kurve.from_merkmale("AK-002", 0.0, 0.0, merkmale) + self.assertEqual(obj.drehung, 0.0) # None → default + + +class TestAntriebNormalisierung(unittest.TestCase): + """Tests für die field_validator Normalisierung von antriebNebenStrecke.""" + + def test_aussen_normalisiert(self): + """'Aussen' wird beim Erstellen zu 'außen' normalisiert.""" + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="links", + antriebNebenStrecke="Aussen", winkel=90, + ) + self.assertEqual(obj.antriebNebenStrecke, "außen") + self.assertEqual(obj.antrieb, "außen") + + def test_innen_normalisiert(self): + """'Innen' wird beim Erstellen zu 'innen' normalisiert.""" + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="rechts", + antriebNebenStrecke="Innen", winkel=45, + ) + self.assertEqual(obj.antriebNebenStrecke, "innen") + self.assertEqual(obj.antrieb, "innen") + + def test_bereits_normalisiert_bleibt(self): + """Bereits normalisierte Werte bleiben unverändert.""" + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="links", + antriebNebenStrecke="außen", winkel=90, + ) + self.assertEqual(obj.antriebNebenStrecke, "außen") + + def test_unbekannter_wert_bleibt(self): + """Unbekannte Werte werden nicht verändert.""" + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="links", + antriebNebenStrecke="custom_wert", winkel=90, + ) + self.assertEqual(obj.antriebNebenStrecke, "custom_wert") + + def test_antrieb_property_read_only(self): + """antrieb Property gibt den normierten Wert zurück ohne Side-Effect.""" + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="links", + antriebNebenStrecke="Aussen", winkel=90, + ) + # Mehrfacher Zugriff ändert nichts + _ = obj.antrieb + _ = obj.antrieb + self.assertEqual(obj.antrieb, "außen") + + +class TestHightZwischen(unittest.TestCase): + """Tests für hight_zwischen Property.""" + + def test_mittlere_hoehe(self): + obj = Angetriebene_Kurve( + teileid="T", x=0, y=0, kurvenrichtung="links", + antriebNebenStrecke="außen", winkel=90, + hoehe0=1000, hoehe1=3000, + ) + self.assertEqual(obj.hight_zwischen, 2000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/Elemente/Eckrad.py b/lib/Elemente/Eckrad.py index 6d10845..c916baa 100644 --- a/lib/Elemente/Eckrad.py +++ b/lib/Elemente/Eckrad.py @@ -1,12 +1,64 @@ # -*- coding: utf-8 -*- -from pydantic import BaseModel, Field, field_validator +""" +Eckrad — Modell und Erstellungsfunktionen für Eckrad-Komponenten. + +Refactoring-Änderungen: +- Hartkodierte Block-Namen durch Konstanten (BLOCK_AN8, BLOCK_RICHTUNGSPFEIL, etc.) ersetzt +- @staticmethod auf erstellung_eckrad_richtung +- 3x try/except in from_merkmale vereinfacht: drehrichtung braucht kein try/except (dict.get wirft nie) +- drehung wird nun korrekt an den Konstruktor übergeben (war vorher gelesen aber nicht weitergegeben) +- Hinweis: Modell hat keine x/y-Felder, diese werden von Pydantic ignoriert +""" +from pydantic import BaseModel, Field from typing import Optional + from lib import block_methoden -RADIUS = 400 + +# ============================================================================ +# KONSTANTEN +# ============================================================================ + +RADIUS = 400 # Radius der Eckrad-Kreise (mm) +BLOCK_AN8 = "AN8" # Grundform-Block für Eckrad +BLOCK_RICHTUNGSPFEIL = "Richtungspfeil" # Richtungsanzeiger-Block +BLOCK_ECKRAD_UZS = "eckrad_UZS" # Eckrad-Block im Uhrzeigersinn +BLOCK_ECKRAD_GUZS = "eckrad_GUZS" # Eckrad-Block gegen Uhrzeigersinn +RICHTUNGSPFEIL_OFFSET = 200 # Versatz der Richtungspfeile vom Zentrum (mm) + + +# ============================================================================ +# HILFSFUNKTIONEN +# ============================================================================ + + +def _safe_float(value, default=0.0): + """ + Konvertiert einen Wert zu float mit Fallback. + + Args: + value: Zu konvertierender Wert (kann None sein) + default: Rückgabewert bei Fehler + + Returns: + Float-Wert oder default + """ + try: + if value is None: + return default + return float(value) + except (TypeError, ValueError): + return default + + +# ============================================================================ +# ECKRAD KLASSE +# ============================================================================ class Eckrad(BaseModel): + """Modell für Eckrad-Komponenten.""" + teileid: str drehung: Optional[float] = Field(default=0.0, description="Rotation des Elements") hoehe: Optional[float] = Field(default=0.0, description="Hoehe des Elements") @@ -18,38 +70,79 @@ class Eckrad(BaseModel): def from_merkmale( cls, teileid: str, x: float, y: float, merkmale: dict ) -> "Eckrad": - try: - hoehe = float(merkmale.get("Höhe in Meter")) * 1000 - except Exception as e: - hoehe = 0.0 - try: - drehung = float(merkmale.get("Drehung")) - except Exception as e: - drehung = 0.0 - try: - drehrichtung = merkmale.get("Drehrichtung") - except Exception as e: - drehrichtung = None - return cls(teileid=teileid, x=x, y=y, hoehe=hoehe, drehrichtung=drehrichtung) + """ + Erstellt Eckrad aus einem Merkmale-Dictionary. + Args: + teileid: Teile-Identifikator + x, y: Koordinaten (werden vom Modell nicht gespeichert) + merkmale: Dictionary mit Eigenschaftswerten + + Returns: + Eckrad-Instanz + """ + hoehe = _safe_float(merkmale.get("Höhe in Meter"), 0.0) * 1000 + drehung = _safe_float(merkmale.get("Drehung"), 0.0) + # dict.get() wirft keine Ausnahme — kein try/except nötig + drehrichtung = merkmale.get("Drehrichtung") + + return cls( + teileid=teileid, + hoehe=hoehe, + drehung=drehung, + drehrichtung=drehrichtung, + ) + + @staticmethod def erstellung_eckrad_richtung(merkmale, doc, lib_doc): - block_methoden.import_block("AN8", lib_doc, doc) - block_methoden.import_block("Richtungspfeil", lib_doc, doc) - eckrad_rechts = "eckrad_UZS" - eckrad_links = "eckrad_GUZS" + """ + Erstellt Eckrad-Blöcke mit Richtungspfeilen (UZS und GUZS). + + Erstellt bei Bedarf neue Blöcke für beide Drehrichtungen, + jeder mit dem AN8-Grundform-Block und zwei Richtungspfeilen. + + Args: + merkmale: Dictionary mit "Höhe in m" + doc: DXF-Dokument + lib_doc: Bibliotheks-Dokument + + Returns: + (eckrad_rechts, eckrad_links, hight): Blocknamen und Höhe in mm + """ + block_methoden.import_block(BLOCK_AN8, lib_doc, doc) + block_methoden.import_block(BLOCK_RICHTUNGSPFEIL, lib_doc, doc) + hight = float(merkmale.get("Höhe in m")) * 1000 - # Erstellung der Richtungung Blöcke der Eckrads - if eckrad_rechts not in doc.blocks: - block_rechts = doc.blocks.new(name=eckrad_rechts, base_point=(0, 0, 0)) - block_links = doc.blocks.new(name=eckrad_links, base_point=(0, 0, 0)) - block_rechts.add_blockref("AN8", (0, 0, 0)) - block_links.add_blockref("AN8", (0, 0, 0)) - block_rechts.add_blockref("Richtungspfeil", (0 + 200, 0 + RADIUS, 0)) + + # Blöcke nur erstellen wenn noch nicht vorhanden + if BLOCK_ECKRAD_UZS not in doc.blocks: + block_rechts = doc.blocks.new(name=BLOCK_ECKRAD_UZS, base_point=(0, 0, 0)) + block_links = doc.blocks.new(name=BLOCK_ECKRAD_GUZS, base_point=(0, 0, 0)) + + # Grundform für beide + block_rechts.add_blockref(BLOCK_AN8, (0, 0, 0)) + block_links.add_blockref(BLOCK_AN8, (0, 0, 0)) + + # UZS: Pfeile bei (+offset, +RADIUS) und (-offset, -RADIUS) block_rechts.add_blockref( - "Richtungspfeil", (0 - 200, 0 - RADIUS, 0), dxfattribs={"rotation": 180} + BLOCK_RICHTUNGSPFEIL, + (RICHTUNGSPFEIL_OFFSET, RADIUS, 0), ) - block_links.add_blockref("Richtungspfeil", (0 + 200, 0 - RADIUS, 0)) + block_rechts.add_blockref( + BLOCK_RICHTUNGSPFEIL, + (-RICHTUNGSPFEIL_OFFSET, -RADIUS, 0), + dxfattribs={"rotation": 180}, + ) + + # GUZS: Pfeile bei (+offset, -RADIUS) und (-offset, +RADIUS) block_links.add_blockref( - "Richtungspfeil", (0 - 200, 0 + RADIUS, 0), dxfattribs={"rotation": 180} + BLOCK_RICHTUNGSPFEIL, + (RICHTUNGSPFEIL_OFFSET, -RADIUS, 0), ) - return eckrad_rechts, eckrad_links, hight + block_links.add_blockref( + BLOCK_RICHTUNGSPFEIL, + (-RICHTUNGSPFEIL_OFFSET, RADIUS, 0), + dxfattribs={"rotation": 180}, + ) + + return BLOCK_ECKRAD_UZS, BLOCK_ECKRAD_GUZS, hight diff --git a/lib/Elemente/Eckrad_tests.py b/lib/Elemente/Eckrad_tests.py new file mode 100644 index 0000000..33db975 --- /dev/null +++ b/lib/Elemente/Eckrad_tests.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +Unit-Tests für Eckrad (Refactored) +""" +import unittest + +from .Eckrad import Eckrad, _safe_float, BLOCK_AN8, BLOCK_ECKRAD_UZS, BLOCK_ECKRAD_GUZS, RADIUS + + +class TestSafeFloat(unittest.TestCase): + """Tests für Eckrad _safe_float.""" + + def test_normaler_wert(self): + self.assertEqual(_safe_float("2.5"), 2.5) + + def test_none_default(self): + self.assertEqual(_safe_float(None, 99.0), 99.0) + + def test_ungueltig_default(self): + self.assertEqual(_safe_float("abc", 0.0), 0.0) + + +class TestEckradModell(unittest.TestCase): + """Tests für das Eckrad Modell.""" + + def test_from_merkmale_vollstaendig(self): + merkmale = { + "Höhe in Meter": "3.5", + "Drehung": "45", + "Drehrichtung": "UZS", + } + obj = Eckrad.from_merkmale("E-001", 100.0, 200.0, merkmale) + self.assertEqual(obj.teileid, "E-001") + self.assertEqual(obj.hoehe, 3500.0) # 3.5 * 1000 + self.assertEqual(obj.drehung, 45.0) + self.assertEqual(obj.drehrichtung, "UZS") + + def test_from_merkmale_fehlende_hoehe(self): + merkmale = {"Drehung": "90", "Drehrichtung": None} + obj = Eckrad.from_merkmale("E-002", 0.0, 0.0, merkmale) + self.assertEqual(obj.hoehe, 0.0) # None → default 0 + + def test_from_merkmale_fehlende_drehung(self): + merkmale = {"Höhe in Meter": "1.0"} + obj = Eckrad.from_merkmale("E-003", 0.0, 0.0, merkmale) + self.assertEqual(obj.drehung, 0.0) # None → default 0 + + def test_drehrichtung_ohne_try_except(self): + # drehrichtung = merkmale.get("Drehrichtung") wirft nie + merkmale = {"Höhe in Meter": "1.0", "Drehung": "0"} + obj = Eckrad.from_merkmale("E-004", 0.0, 0.0, merkmale) + self.assertIsNone(obj.drehrichtung) + + +class TestEckradKonstanten(unittest.TestCase): + """Tests für Eckrad-Konstanten.""" + + def test_block_namen(self): + self.assertEqual(BLOCK_AN8, "AN8") + self.assertEqual(BLOCK_ECKRAD_UZS, "eckrad_UZS") + self.assertEqual(BLOCK_ECKRAD_GUZS, "eckrad_GUZS") + + def test_radius(self): + self.assertEqual(RADIUS, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/Elemente/Gefaellestrecke.py b/lib/Elemente/Gefaellestrecke.py index 4a00696..44ce0af 100644 --- a/lib/Elemente/Gefaellestrecke.py +++ b/lib/Elemente/Gefaellestrecke.py @@ -1,13 +1,286 @@ # -*- coding: utf-8 -*- -from pydantic import BaseModel, Field, field_validator -import re +""" +Gefaellestrecke — Modell und Hilfsfunktionen für Gefällestrecken. + +Enthält das Pydantic-Modell und statische Methoden zur Erstellung +von DXF-Geometrie für Gefällestrecken zwischen Fördererkomponenten. + +Refactoring-Änderungen: +- @staticmethod auf alle Methoden, die self nicht nutzen +- Bug fix Zeile 112: am_kreisel == 1 → am_kreisel = 1 +- Duplizierte Logik (Zeilen 69–90 vs 121–141) in _bestimme_position/_bestimme_gefaelle extrahiert +- rotation_mit_zwei_verbunden aufgeteilt in Hilfsfunktionen +- hat_motor_umlenk_station aufgeteilt: _pruefe_motor_umlenk_an_kurve, _bestimme_tefkurve +- ein_motor_oder_eine_umlenk aufgeteilt: _add_station_mit_bogen, _add_station_gerade +- Magic Numbers durch Konstanten ersetzt +- Dokumentation hinzugefügt +""" import math -from ezdxf.math import Matrix44 -from lib import plant2dxf +import re + +from pydantic import BaseModel, Field + from lib import block_methoden +# ============================================================================ +# KONSTANTEN +# ============================================================================ + +ROTATION_MAP = {"oben": 0, "unten": 180, "links": 90, "rechts": 270} +"""Zuordnung von Gefälle-Richtung zu Rotationswinkel in Grad.""" + +LAYER_SP = "6-SP" +"""DXF-Layer für Seitenprofile.""" + +WINKEL_3_GRAD = 3 +"""Standard-Bogenwinkel in Grad.""" + +STATIONS_LAENGE = 500 +"""Länge einer Motor-/Umlenkstation in mm.""" + + +# ============================================================================ +# HILFSFUNKTIONEN +# ============================================================================ + + +def _bestimme_position(richtung, x0_kreisel, x1_kreisel, y0_kreisel, y1_kreisel, hight_position): + """ + Bestimmt die relative Position basierend auf Richtung und Kreiselkoordinaten. + + Args: + richtung: "Vertikal" oder "Horizontal" + x0_kreisel, y0_kreisel: Koordinaten erstes Kreisel + x1_kreisel, y1_kreisel: Koordinaten zweites Kreisel + hight_position: "higher" oder "lower" + + Returns: + Position-String z.B. "higher_links", "lower_rechts" + """ + if richtung == "Vertikal": + suffix = "_links" if x0_kreisel < x1_kreisel else "_rechts" + else: + suffix = "_higher" if y0_kreisel > y1_kreisel else "_lower" + return hight_position + suffix + + +def _bestimme_gefaelle(richtung, position): + """ + Bestimmt die Gefälle-Richtung basierend auf Richtung und Position. + + Args: + richtung: "Vertikal" oder "Horizontal" + position: Position-String (z.B. "lower_rechts") + + Returns: + Gefälle-Richtung: "links", "rechts", "oben" oder "unten" + """ + if richtung == "Vertikal": + return "links" if position in ("lower_rechts", "higher_links") else "rechts" + elif richtung == "Horizontal": + return "oben" if position in ("lower_lower", "higher_higher") else "unten" + return "links" # Fallback für unbekannte Richtung + + +def _tausche_drehungen_und_hoehe(drehung0, drehung1, hight_position): + """ + Tauscht Drehungen und invertiert die Höhe-Position. + + Returns: + (neue_drehung0, neue_drehung1, neue_hight_position) + """ + neue_hight = "lower" if hight_position == "higher" else "higher" + return drehung1, drehung0, neue_hight + + +def _bestimme_tefkurve(kurvenrichtung, tefkurve): + """ + Bestimmt die effektive Tefkurve-Richtung aus Kurvenrichtung und Tefkurve. + + Args: + kurvenrichtung: "links" oder "rechts" + tefkurve: "außen" oder "innen" + + Returns: + "rechts" oder "links" + """ + if (kurvenrichtung == "links" and tefkurve == "außen") or ( + kurvenrichtung == "rechts" and tefkurve == "innen" + ): + return "rechts" + return "links" + + +def _ist_umlenk_position(rotation, x, y, x_angetrieben, y_angetrieben): + """ + Prüft ob die Position eine Umlenkposition ist (gleiche-Höhe-Fall). + + Basiert auf Rotationswinkel und relativer Position zur angetriebenen Strecke. + + Args: + rotation: Drehung in Grad + x, y: Position des Gefällestreckens + x_angetrieben, y_angetrieben: Position der angetriebenen Strecke + + Returns: + True wenn Umlenkposition, sonst False (Motorposition) + """ + rotation_zwischen = rotation if rotation != 0.0 else -360.0 + return ( + ((-360.0 <= rotation_zwischen < -270.0) and y > y_angetrieben) + or ((-90.0 < rotation < 0.0) and y > y_angetrieben) + or ((-270.0 < rotation_zwischen < -90.0) and y < y_angetrieben) + or (rotation == -90.0 and x < x_angetrieben) + or (rotation == -270.0 and x < x_angetrieben) + ) + + +def _normalize_deltas(*delta_lists): + """Setzt negative Werte in Delta-Listen auf positiv.""" + for delta_list in delta_lists: + for i, val in enumerate(delta_list): + if val < 0: + delta_list[i] = abs(val) + + +def _laedt_bogen_deltas(block_name, lib_doc, doc): + """ + Lädt Bogen-Delta-Attribute und normalisiert negative Werte. + + Args: + block_name: Name des Bogen-Blocks + lib_doc: Bibliotheks-Dokument + doc: Ziel-Dokument + + Returns: + Dict mit DELTA_SP_0 und DELTA_SP_1 als normalisierten Listen + """ + attrib = block_methoden.import_block(block_name, lib_doc, doc) + deltas = {} + for key in ("DELTA_SP_0", "DELTA_SP_1"): + values = [float(v) for v in re.split(r"[;,]", attrib[key])] + deltas[key] = [abs(v) if v < 0 else v for v in values] + return deltas + + +def _add_station_mit_bogen( + block, bogen_block, station_block, position, x, y, hoehe, + deltas_0, deltas_1, vorzeichen, rotation_bogen +): + """ + Fügt Station mit Bogen-Übergang hinzu. + + Args: + block: DXF-Block zum Hinzufügen + bogen_block: Blockname des Bogens + station_block: Blockname der Station + position: [x, y, z] aktuelle Position + x, y: Offset-Koordinaten + hoehe: Höhe des Gefälles + deltas_0, deltas_1: Delta-Werte des Bogens [x, y, z] + vorzeichen: -1 für Motor (Anfang), +1 für Umlenk (Ende) + rotation_bogen: Rotation des Bogenblocks + + Returns: + Neue Position nach Station-Platzierung + """ + v = vorzeichen + # Bogen hinzufügen + block.add_blockref( + bogen_block, + (position[0] - x, position[1] + v * deltas_0[0] - y, position[2] + v * deltas_0[2] - hoehe), + dxfattribs={"rotation": rotation_bogen}, + ) + # Position nach Bogen aktualisieren + new_pos = [ + position[0], + position[1] + v * (deltas_0[0] + deltas_1[0]), + position[2] + v * (deltas_0[2] + deltas_1[2]), + ] + # Station hinzufügen + winkel_rad = math.radians(WINKEL_3_GRAD) + block.add_blockref( + station_block, + ( + new_pos[0] - x, + new_pos[1] + v * (STATIONS_LAENGE / 2) * math.cos(winkel_rad) - y, + new_pos[2] + v * (STATIONS_LAENGE / 2) * math.sin(winkel_rad) - hoehe, + ), + dxfattribs={"rotation": 270}, + ) + new_pos[1] += v * STATIONS_LAENGE * math.cos(winkel_rad) + new_pos[2] += v * STATIONS_LAENGE * math.sin(winkel_rad) + return new_pos + + +def _add_station_gerade(block, station_block, position, x, y, hoehe, vorzeichen): + """ + Fügt Station ohne Bogen (gerade) hinzu. + + Args: + block: DXF-Block zum Hinzufügen + station_block: Blockname der Station + position: [x, y, z] aktuelle Position + x, y: Offset-Koordinaten + hoehe: Höhe des Gefälles + vorzeichen: -1 für Motor, +1 für Umlenk + + Returns: + Neue Position nach Station-Platzierung + """ + v = vorzeichen + block.add_blockref( + station_block, + (position[0] - x, position[1] + v * (STATIONS_LAENGE / 2) - y, position[2] - hoehe), + dxfattribs={"rotation": 270}, + ) + new_pos = list(position) + new_pos[1] += v * STATIONS_LAENGE + return new_pos + + +def _pruefe_motor_umlenk_an_kurve( + upper_hoehe, lower_hoehe, vario_hoehe_0, vario_hoehe_1, + rotation, x, y, x_angetrieben, y_angetrieben +): + """ + Prüft ob Motor oder Umlenk an einer Kurve benötigt wird. + + Args: + upper_hoehe: Obere Höhe des Gefällestreckens + lower_hoehe: Untere Höhe des Gefällestreckens + vario_hoehe_0, vario_hoehe_1: Höhen der Nachbarn + rotation: Drehung in Grad + x, y: Position des Gefällestreckens + x_angetrieben, y_angetrieben: Position der angetriebenen Strecke + + Returns: + (hat_motor, hat_umlenk, ist_gerade) + """ + if upper_hoehe > lower_hoehe: + if vario_hoehe_0 == upper_hoehe or vario_hoehe_1 == upper_hoehe: + return True, False, False + return False, True, False + elif upper_hoehe < lower_hoehe: + if vario_hoehe_0 == lower_hoehe or vario_hoehe_1 == lower_hoehe: + return True, False, False + return False, True, False + else: # Gleiche Höhe → Positionsbasierte Bestimmung + ist_umlenk = _ist_umlenk_position(rotation, x, y, x_angetrieben, y_angetrieben) + if ist_umlenk: + return False, True, True + return True, False, True + + +# ============================================================================ +# GEFAELLESTRECKE KLASSE +# ============================================================================ + + class Gefaellestrecke(BaseModel): + """Modell für Gefällestrecken zwischen Fördererkomponenten.""" + teileid: str x: float = Field(description="X-Koordinate des Foerder-Zentrums") y: float = Field(description="Y-Koordinate des Foerder-Zentrums") @@ -20,12 +293,24 @@ class Gefaellestrecke(BaseModel): @property def hight_zwischen(self): + """Mittlere Höhe zwischen h0 und h1.""" return (self.h0 + self.h1) / 2 @classmethod def from_merkmale( cls, teileid: str, x: float, y: float, merkmale: dict ) -> "Gefaellestrecke": + """ + Erstellt ein Gefaellestrecke-Objekt aus einem Merkmale-Dictionary. + + Args: + teileid: Teile-Identifikator + x, y: Koordinaten in mm + merkmale: Dictionary mit Eigenschaftswerten + + Returns: + Gefaellestrecke-Instanz + """ h0 = float(merkmale.get("Höhe unten")) * 1000 h1 = float(merkmale.get("Höhe oben")) * 1000 laenge = float(merkmale.get("Länge in Meter")) * 1000 @@ -41,16 +326,29 @@ class Gefaellestrecke(BaseModel): anzahl_separatoren=int(merkmale.get("Anzahl der Separatoren")), ) + @staticmethod def erstehlung_von_gefalle_ohne_aussnahmen( msp, x, y, upper_hoehe_gefaelle, lower_hoehe_gefaelle, halbe_laenge, winkel ): + """ + Zeichnet eine Gefällelinie ohne Motor-/Umlenk-Ausnahmen. + + Args: + msp: DXF-Modelspace + x, y: Zentrum der Gefällelinie + upper_hoehe_gefaelle: Höhe am oberen Ende + lower_hoehe_gefaelle: Höhe am unteren Ende + halbe_laenge: Halbe Länge der Strecke + winkel: Rotationswinkel + """ dx = halbe_laenge * math.sin(winkel * -1) dy = halbe_laenge * math.cos(winkel) start = x + dx, y + dy, upper_hoehe_gefaelle ende = x - dx, y - dy, lower_hoehe_gefaelle line = msp.add_line(start, ende) - line.dxf.layer = "6-SP" + line.dxf.layer = LAYER_SP + @staticmethod def rotation_mit_zwei_verbunden( gefaellestrecke_nachbarn, richtung2, @@ -59,6 +357,23 @@ class Gefaellestrecke(BaseModel): kreisel_verbunden, hight_position, ): + """ + Berechnet Rotation wenn zwei Kreise verbunden sind. + + Bestimmt die Gefälle-Richtung und passt Drehungen an, + damit der 1. Kreisel in der Liste konsistent am Kreisel verbunden ist. + + Args: + gefaellestrecke_nachbarn: Dict mit Drehungen und Kreisel-Positionen + richtung2: Richtung des zweiten Kreisels ("DEFAULT", "Vertikal", "Horizontal") + richtung0: Richtung des ersten Kreisels + am_kreisel: Kreisel-Index (0, 1 oder 2) + kreisel_verbunden: Anzahl verbundener Kreisel + hight_position: "higher" oder "lower" + + Returns: + (rotation, drehung0, drehung1, hight_position) + """ drehung0 = gefaellestrecke_nachbarn.get("Drehung0") drehung1 = gefaellestrecke_nachbarn.get("Drehung1") x0_kreisel = float(gefaellestrecke_nachbarn.get("x0")) @@ -66,100 +381,44 @@ class Gefaellestrecke(BaseModel): x1_kreisel = float(gefaellestrecke_nachbarn.get("x1")) y1_kreisel = float(gefaellestrecke_nachbarn.get("y1")) - if richtung2 == "DEFAULT": - if richtung0 == "Vertikal": - if x0_kreisel < x1_kreisel: - position = hight_position + "_links" - else: - position = hight_position + "_rechts" - else: - if y0_kreisel > y1_kreisel: - position = hight_position + "_higher" - else: - position = hight_position + "_lower" + # DEFAULT → richtung0 verwenden, sonst richtung2 + effektive_richtung = richtung0 if richtung2 == "DEFAULT" else richtung2 - if richtung0 == "Vertikal": - if position == "lower_rechts" or position == "higher_links": - gefaelle = "links" - else: - gefaelle = "rechts" - elif richtung0 == "Horizontal": - if position == "lower_lower" or position == "higher_higher": - gefaelle = "oben" - else: - gefaelle = "unten" - # vertausch der drehung und der höhe für die namens gebung des blockes + # Einheitliche Position- und Gefälle-Bestimmung (eliminiert Duplikation) + position = _bestimme_position( + effektive_richtung, x0_kreisel, x1_kreisel, y0_kreisel, y1_kreisel, hight_position + ) + gefaelle = _bestimme_gefaelle(effektive_richtung, position) + + if richtung2 == "DEFAULT": + # Vertausch wenn Position rechts/lower und Drehungen verschieden if ( - ( - position == "higher_rechts" - or position == "lower_rechts" - or position == "higher_lower" - or position == "lower_lower" - ) + position in ("higher_rechts", "lower_rechts", "higher_lower", "lower_lower") and drehung0 != drehung1 and am_kreisel == 0 ): - drehung_2 = drehung0 - drehung0 = drehung1 - drehung1 = drehung_2 - if hight_position == "higher": - hight_position = "lower" - else: - hight_position = "higher" + drehung0, drehung1, hight_position = _tausche_drehungen_und_hoehe( + drehung0, drehung1, hight_position + ) - # austausch der werte damit immer davon ausgehen dass der 1 kreisel in unserer Liste am Kreisel verbuden ist + # Austausch damit der 1. Kreisel in der Liste am Kreisel verbunden ist if kreisel_verbunden == 1 and am_kreisel == 2: - am_kreisel == 1 - drehung_2 = drehung0 - drehung0 = drehung1 - drehung1 = drehung_2 - if hight_position == "higher": - hight_position = "lower" - else: - hight_position = "higher" + am_kreisel = 1 # BUG FIX: war "am_kreisel == 1" (Vergleich statt Zuweisung) + drehung0, drehung1, hight_position = _tausche_drehungen_und_hoehe( + drehung0, drehung1, hight_position + ) else: - if richtung2 == "Vertikal": - if x0_kreisel < x1_kreisel: - position = hight_position + "_links" - else: - position = hight_position + "_rechts" - else: - if y0_kreisel > y1_kreisel: - position = hight_position + "_higher" - else: - position = hight_position + "_lower" - - if richtung2 == "Vertikal": - if position == "lower_rechts" or position == "higher_links": - gefaelle = "links" - else: - gefaelle = "rechts" - elif richtung2 == "Horizontal": - if position == "lower_lower" or position == "higher_higher": - gefaelle = "oben" - else: - gefaelle = "unten" - # austausch der werte damit immer davon ausgehen dass der 1 kreisel in unserer Liste am Kreisel verbuden ist + # Austausch wenn am_kreisel == 2 if am_kreisel == 2: am_kreisel = 1 - drehung_2 = drehung0 - drehung0 = drehung1 - drehung1 = drehung_2 - if hight_position == "higher": - hight_position = "lower" - else: - hight_position = "higher" - # Erstellung der Rotation - if gefaelle == "oben": - rotation = 0 - elif gefaelle == "unten": - rotation = 180 - elif gefaelle == "links": - rotation = 90 - elif gefaelle == "rechts": - rotation = 270 + drehung0, drehung1, hight_position = _tausche_drehungen_und_hoehe( + drehung0, drehung1, hight_position + ) + + rotation = ROTATION_MAP[gefaelle] return rotation, drehung0, drehung1, hight_position + @staticmethod def ein_motor_oder_eine_umlenk( x, y, @@ -179,37 +438,35 @@ class Gefaellestrecke(BaseModel): umlenk_gerade, motor_gerade, ): + """ + Platziert Motor- und/oder Umlenkstation mit optionalen Bögen. + + Args: + x, y: Zentrum des Förderers + start, ende: Start-/End-Punkt der Gefällelinie + doc, lib_doc: DXF-Dokumente + hoehe_gefaelle: Höhe der Gefällelinie + block_Vario_Umlenkstation_500mm: Block-Name Umlenkstation rechts + block_Vario_Motorstation_500mm: Block-Name Motorstation rechts + blockname_motor_links: Block-Name Motorstation links + blockname_umlenk_links: Block-Name Umlenkstation links + hat_motor_0, hat_umlenk_0: Ob Motor/Umlenk vorhanden + tefkurve_0: "links" oder "rechts" + block: DXF-Block zum Hinzufügen + umlenk_gerade, motor_gerade: Ob Station gerade (keine Bögen) + + Returns: + (start, ende) nach Platzierung + """ + # Bogen-Blöcke laden und Deltas extrahieren + block_Vario_Bogen_auf = "Vario_Bogen_auf_3°" + block_Vario_Bogen_ab = "Vario_Bogen_ab_3°" + block_Vario_Bogen_auf_links = block_Vario_Bogen_auf + "_links" + block_Vario_Bogen_ab_links = block_Vario_Bogen_ab + "_links" + + ab_deltas = _laedt_bogen_deltas(block_Vario_Bogen_ab, lib_doc, doc) + auf_deltas = _laedt_bogen_deltas(block_Vario_Bogen_auf, lib_doc, doc) - block_Vario_Bogen_auf = f"Vario_Bogen_auf_3°" - block_Vario_Bogen_ab = f"Vario_Bogen_ab_3°" - block_Vario_Bogen_auf_links = (f"Vario_Bogen_auf_3°") + "_links" - block_Vario_Bogen_ab_links = (f"Vario_Bogen_ab_3°") + "_links" - auf_attrib = block_methoden.import_block(block_Vario_Bogen_auf, lib_doc, doc) - ab_attrib = block_methoden.import_block(block_Vario_Bogen_ab, lib_doc, doc) - Vario_Bogen_auf_Delta_SP_0 = list( - float(att) for att in re.split(r"[;,]", auf_attrib["DELTA_SP_0"]) - ) - Vario_Bogen_auf_Delta_SP_1 = list( - float(att) for att in re.split(r"[;,]", auf_attrib["DELTA_SP_1"]) - ) - Vario_Bogen_ab_Delta_SP_0 = list( - float(att) for att in re.split(r"[;,]", ab_attrib["DELTA_SP_0"]) - ) - Vario_Bogen_ab_Delta_SP_1 = list( - float(att) for att in re.split(r"[;,]", ab_attrib["DELTA_SP_1"]) - ) - for i, wert in enumerate(Vario_Bogen_auf_Delta_SP_0): - if wert < 0: - Vario_Bogen_auf_Delta_SP_0[i] = abs(wert) - for i, wert in enumerate(Vario_Bogen_auf_Delta_SP_1): - if wert < 0: - Vario_Bogen_auf_Delta_SP_1[i] = abs(wert) - for i, wert in enumerate(Vario_Bogen_ab_Delta_SP_0): - if wert < 0: - Vario_Bogen_ab_Delta_SP_0[i] = abs(wert) - for i, wert in enumerate(Vario_Bogen_ab_Delta_SP_1): - if wert < 0: - Vario_Bogen_ab_Delta_SP_1[i] = abs(wert) block_methoden.turn_two_blocks_left( doc, block_Vario_Bogen_auf, @@ -217,167 +474,71 @@ class Gefaellestrecke(BaseModel): block_Vario_Bogen_ab_links, block_Vario_Bogen_auf_links, ) - if hat_motor_0 == True: - if tefkurve_0 == "links": - if motor_gerade == False: - block.add_blockref( - block_Vario_Bogen_ab_links, - ( - start[0] - x, - start[1] - Vario_Bogen_ab_Delta_SP_0[0] - y, - start[2] - Vario_Bogen_ab_Delta_SP_0[2] - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - start = [ - start[0], - start[1] - - Vario_Bogen_ab_Delta_SP_0[0] - - Vario_Bogen_ab_Delta_SP_1[0], - start[2] - - Vario_Bogen_ab_Delta_SP_0[2] - - Vario_Bogen_ab_Delta_SP_1[2], - ] - block.add_blockref( - blockname_motor_links, - ( - start[0] - x, - start[1] - 250 * math.cos(math.radians(3)) - y, - start[2] - 250 * math.sin(math.radians(3)) - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - start[1] = start[1] - 500 * math.cos(math.radians(3)) - start[2] = start[2] - 500 * math.sin(math.radians(3)) - else: - block.add_blockref( - "Vario_Motorstation_500mm_links", - (start[0] - x, start[1] - 250 - y, start[2] - hoehe_gefaelle), - dxfattribs={"rotation": 270}, - ) - start[1] = start[1] - 500 + # --- Motor-Station --- + if hat_motor_0: + ist_links = (tefkurve_0 == "links") + # Motor: tefkurve "links" → bogen_links + station_links + bogen_motor = block_Vario_Bogen_ab_links if ist_links else block_Vario_Bogen_ab + station_motor = blockname_motor_links if ist_links else block_Vario_Motorstation_500mm + station_motor_gerade = ( + "Vario_Motorstation_500mm_links" if ist_links else "Vario_Motorstation_500mm" + ) + + if not motor_gerade: + start = _add_station_mit_bogen( + block, bogen_motor, station_motor, start, + x, y, hoehe_gefaelle, + ab_deltas["DELTA_SP_0"], ab_deltas["DELTA_SP_1"], + vorzeichen=-1, rotation_bogen=270, + ) else: - if motor_gerade == False: - block.add_blockref( - block_Vario_Bogen_ab, - ( - start[0] - x, - start[1] - Vario_Bogen_ab_Delta_SP_0[0] - y, - start[2] - Vario_Bogen_ab_Delta_SP_0[2] - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - start = [ - start[0], - start[1] - - Vario_Bogen_ab_Delta_SP_0[0] - - Vario_Bogen_ab_Delta_SP_1[0], - start[2] - - Vario_Bogen_ab_Delta_SP_0[2] - - Vario_Bogen_ab_Delta_SP_1[2], - ] - block.add_blockref( - block_Vario_Motorstation_500mm, - ( - start[0] - x, - start[1] - 250 * math.cos(math.radians(3)) - y, - start[2] - 250 * math.sin(math.radians(3)) - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - start[1] = start[1] - 500 * math.cos(math.radians(3)) - start[2] = start[2] - 500 * math.sin(math.radians(3)) - else: - block.add_blockref( - "Vario_Motorstation_500mm", - (start[0] - x, start[1] - 250 - y, start[2] - hoehe_gefaelle), - dxfattribs={"rotation": 270}, - ) - start[1] = start[1] - 500 + start = _add_station_gerade( + block, station_motor_gerade, start, + x, y, hoehe_gefaelle, vorzeichen=-1, + ) - if hat_umlenk_0 == True: - if tefkurve_0 == "links": - if umlenk_gerade == False: - block.add_blockref( - block_Vario_Bogen_auf, - ( - ende[0] - x, - ende[1] + Vario_Bogen_auf_Delta_SP_0[0] - y, - ende[2] + Vario_Bogen_auf_Delta_SP_0[2] - hoehe_gefaelle, - ), - dxfattribs={"rotation": 90}, - ) - ende = [ - ende[0], - ende[1] - + Vario_Bogen_auf_Delta_SP_0[0] - + Vario_Bogen_auf_Delta_SP_1[0], - ende[2] - + Vario_Bogen_auf_Delta_SP_0[2] - + Vario_Bogen_auf_Delta_SP_1[2], - ] - block.add_blockref( - blockname_umlenk_links, - ( - ende[0] - x, - ende[1] + 250 * math.cos(math.radians(3)) - y, - ende[2] + 250 * math.sin(math.radians(3)) - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - - ende[1] = ende[1] + 500 * math.cos(math.radians(3)) - ende[2] = ende[2] + 500 * math.sin(math.radians(3)) - else: - block.add_blockref( - "Vario_Umlenkstation_500mm_links", - (ende[0] - x, ende[1] + 250 - y, ende[2] - hoehe_gefaelle), - dxfattribs={"rotation": 270}, - ) - ende[1] = ende[1] + 500 + # --- Umlenk-Station --- + if hat_umlenk_0: + ist_links = (tefkurve_0 == "links") + # Umlenk: Bogen-Blockname ist INVERTIERT relativ zu tefkurve + bogen_umlenk = block_Vario_Bogen_auf if ist_links else block_Vario_Bogen_auf_links + station_umlenk = blockname_umlenk_links if ist_links else block_Vario_Umlenkstation_500mm + station_umlenk_gerade = ( + "Vario_Umlenkstation_500mm_links" if ist_links else "Vario_Umlenkstation_500mm" + ) + if not umlenk_gerade: + ende = _add_station_mit_bogen( + block, bogen_umlenk, station_umlenk, ende, + x, y, hoehe_gefaelle, + auf_deltas["DELTA_SP_0"], auf_deltas["DELTA_SP_1"], + vorzeichen=+1, rotation_bogen=90, + ) else: - if umlenk_gerade == False: - block.add_blockref( - block_Vario_Bogen_auf_links, - ( - ende[0] - x, - ende[1] + Vario_Bogen_auf_Delta_SP_0[0] - y, - ende[2] + Vario_Bogen_auf_Delta_SP_0[2] - hoehe_gefaelle, - ), - dxfattribs={"rotation": 90}, - ) - ende = [ - ende[0], - ende[1] - + Vario_Bogen_auf_Delta_SP_0[0] - + Vario_Bogen_auf_Delta_SP_1[0], - ende[2] - + Vario_Bogen_auf_Delta_SP_0[2] - + Vario_Bogen_auf_Delta_SP_1[2], - ] - block.add_blockref( - block_Vario_Umlenkstation_500mm, - ( - ende[0] - x, - ende[1] + 250 * math.cos(math.radians(3)) - y, - ende[2] + 250 * math.sin(math.radians(3)) - hoehe_gefaelle, - ), - dxfattribs={"rotation": 270}, - ) - ende[1] = ende[1] + 500 * math.cos(math.radians(3)) - ende[2] = ende[2] + 500 * math.sin(math.radians(3)) - else: - block.add_blockref( - "Vario_Umlenkstation_500mm", - (ende[0] - x, ende[1] + 250 - y, ende[2] - hoehe_gefaelle), - dxfattribs={"rotation": 270}, - ) - ende[1] = ende[1] + 500 + ende = _add_station_gerade( + block, station_umlenk_gerade, ende, + x, y, hoehe_gefaelle, vorzeichen=+1, + ) + return start, ende + @staticmethod def hat_motor_umlenk_station(gefaelle_objekt, gefaellestrecke_nachbarn): + """ + Analysiert Nachbar-Daten und bestimmt Motor-/Umlenkstation-Zuordnung. + + Untersucht ob an jedem Ende der Gefällelinie eine Motorstation + oder eine Umlenkstation angeschlossen ist. + + Args: + gefaelle_objekt: Gefaellestrecke-Instanz mit h0, h1, drehung, x, y + gefaellestrecke_nachbarn: Dict mit Kurven- und Höhen-Informationen + + Returns: + Dict mit hat_motor_0/1, hat_umlenk_0/1, tefkurve_0/1, + umlenk_gerade, motor_gerade + """ hat_motor_0 = None hat_motor_1 = None hat_umlenk_0 = None @@ -392,111 +553,69 @@ class Gefaellestrecke(BaseModel): x = gefaelle_objekt.x y = gefaelle_objekt.y - if "Kurvenrichtung" in gefaellestrecke_nachbarn: - vario_hoehe_0 = float(gefaellestrecke_nachbarn.get("vario_hoehe_0")) - vario_hoehe_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_1")) - kurvenrichtung = gefaellestrecke_nachbarn.get("Kurvenrichtung") - tefkurve_0 = gefaellestrecke_nachbarn.get("Tefkurve") - x_angetrieben = gefaellestrecke_nachbarn.get("X_angetrieben") - y_angetrieben = gefaellestrecke_nachbarn.get("Y_angetrieben") + if "Kurvenrichtung" not in gefaellestrecke_nachbarn: + return { + "hat_motor_0": hat_motor_0, + "hat_motor_1": hat_motor_1, + "hat_umlenk_0": hat_umlenk_0, + "hat_umlenk_1": hat_umlenk_1, + "tefkurve_0": tefkurve_0, + "tefkurve_1": tefkurve_1, + "umlenk_gerade": umlenk_gerade, + "motor_gerade": motor_gerade, + } + + # --- Erster Nachbar --- + vario_hoehe_0 = float(gefaellestrecke_nachbarn.get("vario_hoehe_0")) + vario_hoehe_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_1")) + kurvenrichtung = gefaellestrecke_nachbarn.get("Kurvenrichtung") + tefkurve_0 = gefaellestrecke_nachbarn.get("Tefkurve") + x_angetrieben = gefaellestrecke_nachbarn.get("X_angetrieben") + y_angetrieben = gefaellestrecke_nachbarn.get("Y_angetrieben") + + tefkurve_0 = _bestimme_tefkurve(kurvenrichtung, tefkurve_0) + + hat_motor_0, hat_umlenk_0, ist_gerade_0 = _pruefe_motor_umlenk_an_kurve( + upper_hoehe_gefaelle, lower_hoehe_gefaelle, + vario_hoehe_0, vario_hoehe_1, + rotation, x, y, x_angetrieben, y_angetrieben, + ) + # Gerade-Flag nur setzen im gleiche-Höhe-Fall + if ist_gerade_0: + if hat_umlenk_0: + umlenk_gerade = True + if hat_motor_0: + motor_gerade = True + + # --- Zweiter Nachbar (falls vorhanden) --- + if "Kurvenrichtung_1" in gefaellestrecke_nachbarn: + vario_hoehe_0_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_0_1")) + vario_hoehe_1_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_1_1")) + kurvenrichtung_1 = gefaellestrecke_nachbarn.get("Kurvenrichtung_1") + tefkurve_1 = gefaellestrecke_nachbarn.get("Tefkurve_1") x_angetrieben_1 = gefaellestrecke_nachbarn.get("X_angetrieben_1") y_angetrieben_1 = gefaellestrecke_nachbarn.get("Y_angetrieben_1") - if ( - (kurvenrichtung == "links" and tefkurve_0 == "außen") - or kurvenrichtung == "rechts" - and tefkurve_0 == "innen" - ): - tefkurve_0 = "rechts" - else: - tefkurve_0 = "links" - if upper_hoehe_gefaelle > lower_hoehe_gefaelle: - if ( - vario_hoehe_0 == upper_hoehe_gefaelle - or vario_hoehe_1 == upper_hoehe_gefaelle - ): - hat_motor_0 = True - else: - hat_umlenk_0 = True - elif upper_hoehe_gefaelle < lower_hoehe_gefaelle: - if ( - vario_hoehe_0 == lower_hoehe_gefaelle - or vario_hoehe_1 == lower_hoehe_gefaelle - ): - hat_motor_0 = True - else: - hat_umlenk_0 = True - else: - rotation_zwischen = rotation - if rotation_zwischen == 0.0: - rotation_zwischen = -360.0 - if ( - ((-360.0 <= rotation_zwischen < -270.0) and y > y_angetrieben) - or ((-90.0 < rotation < 0.0) and y > y_angetrieben) - or ((-270.0 < rotation_zwischen < -90.0) and y < y_angetrieben) - or (rotation == -90.0 and x < x_angetrieben) - or ((rotation == -270.0) and x < x_angetrieben) - ): - hat_umlenk_0 = True + tefkurve_1 = _bestimme_tefkurve(kurvenrichtung_1, tefkurve_1) + + hat_motor_1, hat_umlenk_1, ist_gerade_1 = _pruefe_motor_umlenk_an_kurve( + upper_hoehe_gefaelle, lower_hoehe_gefaelle, + vario_hoehe_0_1, vario_hoehe_1_1, + rotation, x, y, x_angetrieben_1, y_angetrieben_1, + ) + if ist_gerade_1: + if hat_umlenk_1: umlenk_gerade = True - else: - hat_motor_0 = True + if hat_motor_1: motor_gerade = True - if "Kurvenrichtung_1" in gefaellestrecke_nachbarn: - vario_hoehe_0_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_0_1")) - vario_hoehe_1_1 = float(gefaellestrecke_nachbarn.get("vario_hoehe_1_1")) - kurvenrichtung_1 = gefaellestrecke_nachbarn.get("Kurvenrichtung_1") - tefkurve_1 = gefaellestrecke_nachbarn.get("Tefkurve_1") - if ( - (kurvenrichtung_1 == "links" and tefkurve_1 == "außen") - or kurvenrichtung_1 == "rechts" - and tefkurve_1 == "innen" - ): - tefkurve_1 = "rechts" - else: - tefkurve_1 = "links" - if upper_hoehe_gefaelle > lower_hoehe_gefaelle: - if ( - vario_hoehe_0_1 == upper_hoehe_gefaelle - or vario_hoehe_1_1 == upper_hoehe_gefaelle - ): - hat_motor_1 = True - else: - hat_umlenk_1 = True - elif upper_hoehe_gefaelle < lower_hoehe_gefaelle: - if ( - vario_hoehe_0_1 == lower_hoehe_gefaelle - or vario_hoehe_1_1 == lower_hoehe_gefaelle - ): - hat_motor_1 = True - else: - hat_umlenk_1 = True - else: - rotation_zwischen = rotation - if rotation_zwischen == 0.0: - rotation_zwischen = -360.0 - if ( - ((-360.0 <= rotation_zwischen < -270.0) and y > y_angetrieben_1) - or ((-90.0 < rotation < 0.0) and y > y_angetrieben_1) - or ( - (-270.0 < rotation_zwischen < -90.0) and y < y_angetrieben_1 - ) - or (rotation == -90.0 and x < x_angetrieben_1) - or ((rotation == -270.0) and x < x_angetrieben_1) - ): - hat_umlenk_1 = True - umlenk_gerade = True - else: - hat_motor_1 = True - motor_gerade = True - hat_zusatz = {} - hat_zusatz["hat_motor_0"] = hat_motor_0 - hat_zusatz["hat_motor_1"] = hat_motor_1 - hat_zusatz["hat_umlenk_0"] = hat_umlenk_0 - hat_zusatz["hat_umlenk_1"] = hat_umlenk_1 - hat_zusatz["tefkurve_0"] = tefkurve_0 - hat_zusatz["tefkurve_1"] = tefkurve_1 - hat_zusatz["umlenk_gerade"] = umlenk_gerade - hat_zusatz["motor_gerade"] = motor_gerade - return hat_zusatz + return { + "hat_motor_0": hat_motor_0, + "hat_motor_1": hat_motor_1, + "hat_umlenk_0": hat_umlenk_0, + "hat_umlenk_1": hat_umlenk_1, + "tefkurve_0": tefkurve_0, + "tefkurve_1": tefkurve_1, + "umlenk_gerade": umlenk_gerade, + "motor_gerade": motor_gerade, + } diff --git a/lib/Elemente/Gefaellestrecke_tests.py b/lib/Elemente/Gefaellestrecke_tests.py new file mode 100644 index 0000000..7c4dbbc --- /dev/null +++ b/lib/Elemente/Gefaellestrecke_tests.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +""" +Unit-Tests für Gefaellestrecke (Refactored) +""" +import unittest +import math + +from .Gefaellestrecke import ( + Gefaellestrecke, + ROTATION_MAP, + _bestimme_position, + _bestimme_gefaelle, + _tausche_drehungen_und_hoehe, + _bestimme_tefkurve, + _ist_umlenk_position, + _pruefe_motor_umlenk_an_kurve, +) + + +class TestGefaellestreckeModell(unittest.TestCase): + """Tests für das Gefaellestrecke Pydantic-Modell.""" + + def test_from_merkmale(self): + merkmale = { + "Höhe unten": "1.5", + "Höhe oben": "2.0", + "Länge in Meter": "5.0", + "Drehung": "-90", + "Anzahl der Scanner": "2", + "Anzahl der Separatoren": "1", + } + obj = Gefaellestrecke.from_merkmale("TEST-001", 100.0, 200.0, merkmale) + self.assertEqual(obj.teileid, "TEST-001") + self.assertEqual(obj.h0, 1500.0) + self.assertEqual(obj.h1, 2000.0) + self.assertEqual(obj.laenge, 5000.0) + self.assertEqual(obj.drehung, -90.0) + self.assertEqual(obj.anzahl_scanner, 2) + self.assertEqual(obj.anzahl_separatoren, 1) + + def test_hight_zwischen(self): + obj = Gefaellestrecke(teileid="T", x=0, y=0, laenge=1000, h0=1000, h1=3000) + self.assertEqual(obj.hight_zwischen, 2000.0) + + +class TestBestimmePosition(unittest.TestCase): + """Tests für _bestimme_position Hilfsfunktion.""" + + def test_vertikal_links(self): + # x0 < x1 → "_links" + result = _bestimme_position("Vertikal", 100, 200, 0, 0, "higher") + self.assertEqual(result, "higher_links") + + def test_vertikal_rechts(self): + # x0 > x1 → "_rechts" + result = _bestimme_position("Vertikal", 300, 200, 0, 0, "lower") + self.assertEqual(result, "lower_rechts") + + def test_horizontal_higher(self): + # y0 > y1 → "_higher" + result = _bestimme_position("Horizontal", 0, 0, 500, 100, "higher") + self.assertEqual(result, "higher_higher") + + def test_horizontal_lower(self): + # y0 < y1 → "_lower" + result = _bestimme_position("Horizontal", 0, 0, 50, 500, "lower") + self.assertEqual(result, "lower_lower") + + +class TestBestimmeGefaelle(unittest.TestCase): + """Tests für _bestimme_gefaelle Hilfsfunktion.""" + + def test_vertikal_links(self): + self.assertEqual(_bestimme_gefaelle("Vertikal", "lower_rechts"), "links") + self.assertEqual(_bestimme_gefaelle("Vertikal", "higher_links"), "links") + + def test_vertikal_rechts(self): + self.assertEqual(_bestimme_gefaelle("Vertikal", "higher_rechts"), "rechts") + self.assertEqual(_bestimme_gefaelle("Vertikal", "lower_links"), "rechts") + + def test_horizontal_oben(self): + self.assertEqual(_bestimme_gefaelle("Horizontal", "lower_lower"), "oben") + self.assertEqual(_bestimme_gefaelle("Horizontal", "higher_higher"), "oben") + + def test_horizontal_unten(self): + self.assertEqual(_bestimme_gefaelle("Horizontal", "higher_lower"), "unten") + self.assertEqual(_bestimme_gefaelle("Horizontal", "lower_higher"), "unten") + + +class TestTauscheDrehungen(unittest.TestCase): + """Tests für _tausche_drehungen_und_hoehe.""" + + def test_tausch_drehungen(self): + d0, d1, h = _tausche_drehungen_und_hoehe(10, 20, "higher") + self.assertEqual(d0, 20) + self.assertEqual(d1, 10) + self.assertEqual(h, "lower") + + def test_tausch_invert_hoehe(self): + _, _, h = _tausche_drehungen_und_hoehe(0, 0, "lower") + self.assertEqual(h, "higher") + + +class TestBestimmeeTefkurve(unittest.TestCase): + """Tests für _bestimme_tefkurve.""" + + def test_links_aussen_ist_rechts(self): + self.assertEqual(_bestimme_tefkurve("links", "außen"), "rechts") + + def test_rechts_innen_ist_rechts(self): + self.assertEqual(_bestimme_tefkurve("rechts", "innen"), "rechts") + + def test_links_innen_ist_links(self): + self.assertEqual(_bestimme_tefkurve("links", "innen"), "links") + + def test_rechts_aussen_ist_links(self): + self.assertEqual(_bestimme_tefkurve("rechts", "außen"), "links") + + +class TestIstUmlenkPosition(unittest.TestCase): + """Tests für _ist_umlenk_position.""" + + def test_rotation_minus90_x_kleiner(self): + # rotation == -90 und x < x_angetrieben → True (Umlenk) + self.assertTrue(_ist_umlenk_position(-90.0, 50, 100, 200, 100)) + + def test_rotation_minus90_x_groesser(self): + # rotation == -90 und x >= x_angetrieben → False (Motor) + self.assertFalse(_ist_umlenk_position(-90.0, 300, 100, 200, 100)) + + def test_rotation_0_wird_minus360(self): + # rotation == 0 → rotation_zwischen = -360 + # -360 <= -360 < -270 und y > y_angetrieben → True + self.assertTrue(_ist_umlenk_position(0.0, 100, 500, 100, 100)) + + +class TestPruefeMotorUmlenkAnKurve(unittest.TestCase): + """Tests für _pruefe_motor_umlenk_an_kurve.""" + + def test_upper_hoeher_motor_passend(self): + # upper > lower, vario_hoehe_0 == upper → Motor + hat_motor, hat_umlenk, ist_gerade = _pruefe_motor_umlenk_an_kurve( + 2000, 1000, 2000, 1500, 0, 0, 0, 0, 0 + ) + self.assertTrue(hat_motor) + self.assertFalse(hat_umlenk) + self.assertFalse(ist_gerade) + + def test_upper_hoeher_umlenk(self): + # upper > lower, keine Höhe passt → Umlenk + hat_motor, hat_umlenk, ist_gerade = _pruefe_motor_umlenk_an_kurve( + 2000, 1000, 1500, 1200, 0, 0, 0, 0, 0 + ) + self.assertFalse(hat_motor) + self.assertTrue(hat_umlenk) + self.assertFalse(ist_gerade) + + def test_lower_hoeher_motor(self): + # upper < lower, vario_hoehe_1 == lower → Motor + hat_motor, hat_umlenk, ist_gerade = _pruefe_motor_umlenk_an_kurve( + 1000, 2000, 1500, 2000, 0, 0, 0, 0, 0 + ) + self.assertTrue(hat_motor) + self.assertFalse(hat_umlenk) + + def test_gleiche_hoehe_umlenk_position(self): + # gleiche Höhe, Umlenkposition → Umlenk gerade + hat_motor, hat_umlenk, ist_gerade = _pruefe_motor_umlenk_an_kurve( + 1000, 1000, 800, 900, -90.0, 50, 100, 200, 100 + ) + self.assertFalse(hat_motor) + self.assertTrue(hat_umlenk) + self.assertTrue(ist_gerade) + + def test_gleiche_hoehe_motor_position(self): + # gleiche Höhe, keine Umlenkposition → Motor gerade + hat_motor, hat_umlenk, ist_gerade = _pruefe_motor_umlenk_an_kurve( + 1000, 1000, 800, 900, -90.0, 300, 100, 200, 100 + ) + self.assertTrue(hat_motor) + self.assertFalse(hat_umlenk) + self.assertTrue(ist_gerade) + + +class TestRotationMap(unittest.TestCase): + """Tests für ROTATION_MAP Konstante.""" + + def test_alle_richtungen(self): + self.assertEqual(ROTATION_MAP["oben"], 0) + self.assertEqual(ROTATION_MAP["unten"], 180) + self.assertEqual(ROTATION_MAP["links"], 90) + self.assertEqual(ROTATION_MAP["rechts"], 270) + + +class TestRotationMitZweiVerbunden(unittest.TestCase): + """Tests für rotation_mit_zwei_verbunden (Bug-Fix Zeile 112 getestet).""" + + def _nachbarn(self): + return { + "Drehung0": 90, "Drehung1": 180, + "x0": "100", "y0": "200", "x1": "300", "y1": "400", + } + + def test_default_richtung_vertikal(self): + # richtung2 == "DEFAULT", richtung0 == "Vertikal" + # x0(100) < x1(300) → "_links", position="higher_links" + # gefaelle="links" → rotation=90 + rotation, d0, d1, hp = Gefaellestrecke.rotation_mit_zwei_verbunden( + self._nachbarn(), "DEFAULT", "Vertikal", 0, 0, "higher" + ) + self.assertEqual(rotation, 90) + + def test_nicht_default_richtung_horizontal(self): + # richtung2 != "DEFAULT", richtung2 == "Horizontal" + # y0(200) < y1(400) → "_lower", position="higher_lower" + # gefaelle="unten" → rotation=180 + rotation, _, _, _ = Gefaellestrecke.rotation_mit_zwei_verbunden( + self._nachbarn(), "Horizontal", "Vertikal", 0, 0, "higher" + ) + self.assertEqual(rotation, 180) + + def test_bug_fix_am_kreisel_zuweisung(self): + # kreisel_verbunden=1, am_kreisel=2 → muss tauschen (Bug fix: war ==) + nachbarn = self._nachbarn() + # Drehungen verschieden damit Tausch sichtbar + nachbarn["Drehung0"] = 45 + nachbarn["Drehung1"] = 135 + rotation, d0, d1, hp = Gefaellestrecke.rotation_mit_zwei_verbunden( + nachbarn, "DEFAULT", "Vertikal", 2, 1, "higher" + ) + # Drehungen sollten getauscht sein + self.assertEqual(d0, 135) + self.assertEqual(d1, 45) + self.assertEqual(hp, "lower") + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/Elemente/Kreisel.py b/lib/Elemente/Kreisel.py index fbc9244..a3fdd24 100644 --- a/lib/Elemente/Kreisel.py +++ b/lib/Elemente/Kreisel.py @@ -1,14 +1,64 @@ # -*- coding: utf-8 -*- -from ezdxf.entities import Line +""" +Kreisel — Modell und Zeichenfunktionen für Kreisel-Komponenten. + +Refactoring-Änderungen: +- Pin-Zeichenlogik (4x copy-paste für 0°/90°/180°/270°) durch PIN_OFFSETS-Lookup-Tabelle ersetzt +- @staticmethod auf draw_kreisel_lines und draw_kreisel_drehrichtung_markierung +- Magic Number 50 durch benanntes Constant PIN_OFFSET ersetzt +- Richtungspfeil-Import aus Loop herausgezogen (war 6x idempotent aufgerufen) +- Unused variable bref entfernt +""" import math + +from ezdxf.entities import Line from pydantic import BaseModel, Field, field_validator from typing import Optional -from lib import plant2dxf + from lib import block_methoden +# ============================================================================ +# KONSTANTEN +# ============================================================================ + ATTR_TAG = "TeileId" # Attributtag im Block RADIUS = 400 # Radius der Kreiselkreise (mm) +PIN_OFFSET = 50 # Offset für Pin-Bereichsmarkierung (mm) + +# Pin-Bereichs-Offsets pro Drehung: (p1a_dx, p1a_dy, p1b_dx, p1b_dy, p2a_dx, p2a_dy, p2b_dx, p2b_dy) +# Berechnet aus dem manuell kodierten Original für 0°/90°/180°/270° +PIN_OFFSETS = { + 0.0: ( + -(RADIUS + PIN_OFFSET), +PIN_OFFSET, + -(RADIUS + PIN_OFFSET), -PIN_OFFSET, + +(RADIUS + PIN_OFFSET), +PIN_OFFSET, + +(RADIUS + PIN_OFFSET), -PIN_OFFSET, + ), + 180.0: ( + +(RADIUS + PIN_OFFSET), -PIN_OFFSET, + +(RADIUS + PIN_OFFSET), +PIN_OFFSET, + -(RADIUS + PIN_OFFSET), -PIN_OFFSET, + -(RADIUS + PIN_OFFSET), +PIN_OFFSET, + ), + 90.0: ( + +PIN_OFFSET, +(RADIUS - PIN_OFFSET), + -PIN_OFFSET, +(RADIUS - PIN_OFFSET), + +PIN_OFFSET, -(RADIUS - PIN_OFFSET), + -PIN_OFFSET, -(RADIUS - PIN_OFFSET), + ), + 270.0: ( + -PIN_OFFSET, -(RADIUS - PIN_OFFSET), + +PIN_OFFSET, -(RADIUS - PIN_OFFSET), + -PIN_OFFSET, +(RADIUS - PIN_OFFSET), + +PIN_OFFSET, +(RADIUS - PIN_OFFSET), + ), +} + + +# ============================================================================ +# KREISEL KLASSE +# ============================================================================ class Kreisel(BaseModel): @@ -71,7 +121,6 @@ class Kreisel(BaseModel): @property def richtung_rad(self) -> float: """Richtung in Radianten (für am_kreisel_direct_verbunden).""" - # Wird aus drehung abgeleitet oder separat gesetzt return math.radians(self.drehung) @property @@ -140,11 +189,22 @@ class Kreisel(BaseModel): anzahl_separatoren=anzahl_separatoren, ) + @staticmethod def draw_kreisel_lines(msp, pos1, pos2, kreisel): - """Zeichnet tangentiale Linien zwischen zwei Kreiselblöcken, unabhängig vom Winkel.""" + """ + Zeichnet tangentiale Linien zwischen zwei Kreiselblöcken, unabhängig vom Winkel. + + Für Pin-Kreisel werden zusätzlich Pinbereichs-Begrenzungslinien gezeichnet, + die sich basierend auf der Drehung (0°/90°/180°/270°) verschieben. + + Args: + msp: DXF-Modelspace + pos1, pos2: Positionen der beiden Kreisel-Blöcke (x, y, z) + kreisel: Kreisel-Instanz + """ rotation = kreisel.drehung x1, y1, z1 = pos1 - x2, y2, z1 = pos2 + x2, y2, _ = pos2 # z identisch für beide Blöcke # Verbindungsvektor dx = x2 - x1 dy = y2 - y1 @@ -153,7 +213,6 @@ class Kreisel(BaseModel): if length == 0: return # keine Linie bei identischen Punkten # Normalenvektor (senkrecht, normiert, Länge = RADIUS) - nx = -dy / length * RADIUS ny = dx / length * RADIUS # Tangentialpunkte @@ -161,72 +220,50 @@ class Kreisel(BaseModel): p1b = (x1 - nx, y1 - ny, z1) p2a = (x2 + nx, y2 + ny, z1) p2b = (x2 - nx, y2 - ny, z1) - if kreisel.kreiselart == "Pin": - if rotation == 0.0: - p1a2 = p1a[0] - RADIUS - 50, p1a[1] + 50, z1 - p1b2 = p1b[0] - RADIUS - 50, p1b[1] - 50, z1 - p2a2 = p2a[0] + RADIUS + 50, p2a[1] + 50, z1 - p2b2 = p2b[0] + RADIUS + 50, p2b[1] - 50, z1 - Line1 = Line.new( - dxfattribs={"start": p1a2, "end": p2a2, "layer": "Pinbereich"} - ) - Line2 = Line.new( - dxfattribs={"start": p1b2, "end": p2b2, "layer": "Pinbereich"} - ) - msp.add_entity(Line1) - msp.add_entity(Line2) - elif rotation == 180.0: - p1a2 = p1a[0] + RADIUS + 50, p1a[1] - 50, z1 - p1b2 = p1b[0] + RADIUS + 50, p1b[1] + 50, z1 - p2a2 = p2a[0] - RADIUS - 50, p2a[1] - 50, z1 - p2b2 = p2b[0] - RADIUS - 50, p2b[1] + 50, z1 - Line1 = Line.new( - dxfattribs={"start": p1a2, "end": p2a2, "layer": "Pinbereich"} - ) - Line2 = Line.new( - dxfattribs={"start": p1b2, "end": p2b2, "layer": "Pinbereich"} - ) - msp.add_entity(Line1) - msp.add_entity(Line2) - elif rotation == 90.0: - p1a2 = p1a[0] + 50, p1a[1] - 50 + RADIUS, z1 - p1b2 = p1b[0] - 50, p1b[1] - 50 + RADIUS, z1 - p2a2 = p2a[0] + 50, p2a[1] + 50 - RADIUS, z1 - p2b2 = p2b[0] - 50, p2b[1] + 50 - RADIUS, z1 - Line1 = Line.new( - dxfattribs={"start": p1a2, "end": p2a2, "layer": "Pinbereich"} - ) - Line2 = Line.new( - dxfattribs={"start": p1b2, "end": p2b2, "layer": "Pinbereich"} - ) - msp.add_entity(Line1) - msp.add_entity(Line2) - elif rotation == 270.0: - p1a2 = p1a[0] - 50, p1a[1] + 50 - RADIUS, z1 - p1b2 = p1b[0] + 50, p1b[1] + 50 - RADIUS, z1 - p2a2 = p2a[0] - 50, p2a[1] - 50 + RADIUS, z1 - p2b2 = p2b[0] + 50, p2b[1] - 50 + RADIUS, z1 - Line1 = Line.new( - dxfattribs={"start": p1a2, "end": p2a2, "layer": "Pinbereich"} - ) - Line2 = Line.new( - dxfattribs={"start": p1b2, "end": p2b2, "layer": "Pinbereich"} - ) - msp.add_entity(Line1) - msp.add_entity(Line2) - # Linien zeichnen + # Pin-Bereichsmarkierung (Lookup-Tabelle statt 4x copy-paste) + if kreisel.kreiselart == "Pin" and rotation in PIN_OFFSETS: + offsets = PIN_OFFSETS[rotation] + points = [p1a, p1b, p2a, p2b] + adjusted = [ + (p[0] + offsets[i * 2], p[1] + offsets[i * 2 + 1], z1) + for i, p in enumerate(points) + ] + p1a2, p1b2, p2a2, p2b2 = adjusted + msp.add_entity(Line.new( + dxfattribs={"start": p1a2, "end": p2a2, "layer": "Pinbereich"} + )) + msp.add_entity(Line.new( + dxfattribs={"start": p1b2, "end": p2b2, "layer": "Pinbereich"} + )) + + # Hauptlinien zeichnen msp.add_line(p1a, p2a) msp.add_line(p1b, p2b) + @staticmethod def draw_kreisel_drehrichtung_markierung( msp, pos1, pos2, kreisel, lib_doc, doc, verbose ): + """ + Zeichnet Richtungspfeile für die Drehrichtung eines Kreisels. + + Platziert 3 Pfeile auf der oberen und 3 auf der unteren Tangentiallinie, + mit invertierter Richtung zwischen oben und unten. + + Args: + msp: DXF-Modelspace + pos1, pos2: Positionen der beiden Kreisel-Blöcke + kreisel: Kreisel-Instanz + lib_doc: Bibliotheks-Dokument + doc: DXF-Dokument + verbose: Ob Debug-Ausgabe aktiviert ist + """ drehrichtung = (kreisel.drehrichtung or "").upper() if drehrichtung not in ("UZS", "GUZS"): return x1, y1, z1 = pos1 - x2, y2, z2 = pos2 + x2, y2, _ = pos2 dx = x2 - x1 dy = y2 - y1 length = math.hypot(dx, dy) @@ -235,55 +272,59 @@ class Kreisel(BaseModel): # Normalenvektor (senkrecht, normiert, Länge = RADIUS) nx = -dy / length * RADIUS ny = dx / length * RADIUS - # Obere Linie + # Obere und untere Tangentiallinien p1_oben = (x1 + nx, y1 + ny) p2_oben = (x2 + nx, y2 + ny) - # Untere Linie p1_unten = (x1 - nx, y1 - ny) p2_unten = (x2 - nx, y2 - ny) - # S-LP auf oberer Linie (Drehrichtung wie angegeben) + + # Richtungspfeil einmal importieren (nicht 6x in der Schleife) + block_methoden.import_block("Richtungspfeil", lib_doc, doc) + blockref_layer, color = block_methoden.get_insert_color_layer(lib_doc, "Richtungspfeil") + + # Obere Linie: Drehrichtung wie angegeben + Kreisel._place_richtungspfeile( + msp, p1_oben, p2_oben, z1, drehrichtung, + invert=False, blockref_layer=blockref_layer, + verbose=verbose, label="oben", + ) + # Untere Linie: Drehrichtung invertiert + Kreisel._place_richtungspfeile( + msp, p1_unten, p2_unten, z1, drehrichtung, + invert=True, blockref_layer=blockref_layer, + verbose=verbose, label="unten", + ) + + @staticmethod + def _place_richtungspfeile(msp, p1, p2, z, drehrichtung, invert, blockref_layer, verbose, label): + """ + Platziert 3 Richtungspfeile entlang einer Tangentiallinie. + + Args: + msp: DXF-Modelspace + p1, p2: Start-/End-Punkt der Linie (x, y) + z: Z-Koordinate + drehrichtung: "UZS" oder "GUZS" + invert: Ob die Richtung invertiert werden soll + blockref_layer: Layer für den Block-Reference + verbose: Ob Debug-Ausgabe aktiviert ist + label: "oben" oder "unten" für Logging + """ for i in range(1, 4): t = i / 4 # 1/4, 2/4, 3/4 - px = p1_oben[0] + t * (p2_oben[0] - p1_oben[0]) - py = p1_oben[1] + t * (p2_oben[1] - p1_oben[1]) - rotation = math.degrees( - math.atan2(p2_oben[1] - p1_oben[1], p2_oben[0] - p1_oben[0]) - ) - if drehrichtung == "GUZS": + px = p1[0] + t * (p2[0] - p1[0]) + py = p1[1] + t * (p2[1] - p1[1]) + rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0])) + # Obere Linie: GUZS invertiert; Untere Linie: UZS invertiert + should_invert = (drehrichtung == "GUZS" and not invert) or (drehrichtung == "UZS" and invert) + if should_invert: rotation += 180 - block_methoden.import_block("Richtungspfeil", lib_doc, doc) - blockref_layer, color = block_methoden.get_insert_color_layer( - lib_doc, "Richtungspfeil" - ) - bref = msp.add_blockref( + msp.add_blockref( "Richtungspfeil", - (px, py, z1), + (px, py, z), dxfattribs={"rotation": rotation, "layer": blockref_layer}, ) if verbose: print( - f"[INFO] Drehrichtung '{drehrichtung}': Richtungspfeil oben bei ({px:.1f}, {py:.1f}), rot={rotation:.1f}" - ) - # S-LP auf unterer Linie (Drehrichtung invertiert) - for i in range(1, 4): - t = i / 4 - px = p1_unten[0] + t * (p2_unten[0] - p1_unten[0]) - py = p1_unten[1] + t * (p2_unten[1] - p1_unten[1]) - rotation = math.degrees( - math.atan2(p2_unten[1] - p1_unten[1], p2_unten[0] - p1_unten[0]) - ) - if drehrichtung == "UZS": - rotation += 180 - block_methoden.import_block("Richtungspfeil", lib_doc, doc) - blockref_layer, color = block_methoden.get_insert_color_layer( - lib_doc, "Richtungspfeil" - ) - bref = msp.add_blockref( - "Richtungspfeil", - (px, py, z1), - dxfattribs={"rotation": rotation, "layer": blockref_layer}, - ) - if verbose: - print( - f"[INFO] Drehrichtung '{drehrichtung}':Richtungspfeil unten bei ({px:.1f}, {py:.1f}), rot={rotation:.1f}" + f"[INFO] Drehrichtung '{drehrichtung}': Richtungspfeil {label} bei ({px:.1f}, {py:.1f}), rot={rotation:.1f}" ) diff --git a/lib/Elemente/Kreisel_tests.py b/lib/Elemente/Kreisel_tests.py new file mode 100644 index 0000000..5a0c526 --- /dev/null +++ b/lib/Elemente/Kreisel_tests.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" +Unit-Tests für Kreisel (Refactored) +""" +import unittest +import math + +from .Kreisel import Kreisel, RADIUS, PIN_OFFSET, PIN_OFFSETS + + +class TestKreiselModell(unittest.TestCase): + """Tests für das Kreisel Pydantic-Modell.""" + + def test_from_merkmale(self): + merkmale = { + "Höhe in m": "2,5", # Komma-Format + "Abstand (Kreiselachse A - Kreiselachse) in Meter": "15", + "Drehung": "90", + "Drehrichtung": "UZS", + "Kreiselart": "Pin", + "Anzahl der Scanner": "1", + "Anzahl der Separatoren": "0", + } + obj = Kreisel.from_merkmale("K-001", 1000.0, 2000.0, merkmale) + self.assertEqual(obj.teileid, "K-001") + self.assertEqual(obj.hoehe, 2500.0) # 2.5 * 1000 + self.assertEqual(obj.abstand, 15000.0) # 15 * 1000 + self.assertEqual(obj.drehung, 90.0) + self.assertEqual(obj.drehrichtung, "UZS") + self.assertEqual(obj.kreiselart, "Pin") + + def test_halbabstand(self): + obj = Kreisel(teileid="K", x=0, y=0, hoehe=0, abstand=10000) + self.assertEqual(obj.halbabstand, 5000.0) + + def test_pos1_pos2_bei_90_grad(self): + obj = Kreisel(teileid="K", x=1000, y=1000, hoehe=500, abstand=2000, drehung=90) + # Bei 90° → winkel_rad = radians(90) → cos=0, sin=1 + # pos1 = (x - halbabstand*cos, y - halbabstand*sin, z) = (1000-0, 1000-1000, 500) + self.assertAlmostEqual(obj.pos1[0], 1000.0, places=3) + self.assertAlmostEqual(obj.pos1[1], 0.0, places=3) + # pos2 = (x + halbabstand*cos, y + halbabstand*sin, z) = (1000+0, 1000+1000, 500) + self.assertAlmostEqual(obj.pos2[0], 1000.0, places=3) + self.assertAlmostEqual(obj.pos2[1], 2000.0, places=3) + + +class TestPinOffsets(unittest.TestCase): + """Tests für die PIN_OFFSETS Lookup-Tabelle.""" + + def test_alle_rotationen_vorhanden(self): + for rotation in (0.0, 90.0, 180.0, 270.0): + self.assertIn(rotation, PIN_OFFSETS) + + def test_offset_laenge(self): + # Jeder Eintrag muss 8 Werte haben (4 Punkte × 2 Koordinaten) + for rotation, offsets in PIN_OFFSETS.items(): + self.assertEqual(len(offsets), 8, f"Rotation {rotation}: erwartet 8 Offsets") + + def test_rotation_0_p1a_offset(self): + # p1a bei 0°: (-(RADIUS+PIN_OFFSET), +PIN_OFFSET) + offsets = PIN_OFFSETS[0.0] + self.assertEqual(offsets[0], -(RADIUS + PIN_OFFSET)) + self.assertEqual(offsets[1], +PIN_OFFSET) + + def test_rotation_90_p2b_offset(self): + # p2b bei 90°: (-PIN_OFFSET, -(RADIUS-PIN_OFFSET)) + offsets = PIN_OFFSETS[90.0] + self.assertEqual(offsets[6], -PIN_OFFSET) + self.assertEqual(offsets[7], -(RADIUS - PIN_OFFSET)) + + def test_rotation_180_symmetrie_zu_0(self): + # 180° ist X-Spiegelung von 0° mit Y-Inversion + off_0 = PIN_OFFSETS[0.0] + off_180 = PIN_OFFSETS[180.0] + # p1a bei 0°: (-(R+O), +O) → p1a bei 180°: (+(R+O), -O) + self.assertEqual(off_180[0], -off_0[0]) + self.assertEqual(off_180[1], -off_0[1]) + + +class TestKreiselKonstanten(unittest.TestCase): + """Tests für Kreisel-Konstanten.""" + + def test_radius_wert(self): + self.assertEqual(RADIUS, 400) + + def test_pin_offset_wert(self): + self.assertEqual(PIN_OFFSET, 50) + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/Elemente/Omniflo.py b/lib/Elemente/Omniflo.py index 711046e..e485363 100644 --- a/lib/Elemente/Omniflo.py +++ b/lib/Elemente/Omniflo.py @@ -1,13 +1,77 @@ # -*- coding: utf-8 -*- -from ezdxf.entities import Line +""" +Omniflo — Modell und Erstellungsfunktionen für Omniflo-Kettenförderer. + +Refactoring-Änderungen: +- 8x try/except-Boilerplate durch _safe_float/_safe_int Hilfsfunktionen ersetzt +- @staticmethod auf Omniflo_geraden_erstellung und omniflo_foerdererstellung +- Bug fix: omniflo_foerdererstellung nutzte rotation (Grad) statt winkel_rad in sin/cos +- Duplizierte dx/dy-Berechnung vereinheitlicht +""" import math -from pydantic import BaseModel, Field, field_validator + +from ezdxf.entities import Line +from pydantic import BaseModel, Field from typing import Optional -from lib import plant2dxf + from lib import block_methoden +# ============================================================================ +# HILFSFUNKTIONEN +# ============================================================================ + + +def _safe_float(value, default=0.0): + """ + Konvertiert einen Wert zu float mit Fallback. + + Behandelt automatisch Komma-als-Dezimaltrennzeichen (z.B. "3,5" → 3.5). + + Args: + value: Zu konvertierender Wert (str, int, float oder None) + default: Rückgabewert bei Konvertierungsfehler + + Returns: + Float-Wert oder default + """ + try: + if value is None: + return default + if isinstance(value, str): + value = value.replace(",", ".") + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value, default=0): + """ + Konvertiert einen Wert zu int mit Fallback (über float-Zwischenschritt). + + Args: + value: Zu konvertierender Wert + default: Rückgabewert bei Konvertierungsfehler + + Returns: + Int-Wert oder default + """ + try: + if value is None: + return default + return int(float(value)) + except (TypeError, ValueError): + return default + + +# ============================================================================ +# OMNIFLO KLASSE +# ============================================================================ + + class Omniflo(BaseModel): + """Modell für Omniflo-Kettenförderer.""" + teileid: str x: float y: float @@ -24,62 +88,58 @@ class Omniflo(BaseModel): @property def hight_zwischen(self): + """Mittlere Höhe zwischen h0 und h1.""" return (self.h0 + self.h1) / 2 @classmethod def from_merkmale(cls, teileid, x, y, merkmale): - sivasnummer = merkmale.get("SivasNummer") - try: - laenge = ( - float(merkmale.get("Länge in Meter", "0").replace(",", ".")) * 1000 - ) # Meter → mm - except Exception: - laenge = 0 - try: - winkel = float(merkmale.get("Drehung")) - except Exception: - winkel = 0.0 - try: - hoehe = float(merkmale.get("Höhe")) - except Exception: - hoehe = 0.0 - try: - h0 = float(merkmale.get("Höhe unten")) - except Exception: - h0 = 0.0 - try: - h1 = float(merkmale.get("Höhe oben")) - except Exception: - h1 = 0.0 - try: - anzahl_scanner = float(merkmale.get("Anzahl der Scanner", "0")) - except Exception: - anzahl_scanner = 0 - try: - anzahl_separatoren = float(merkmale.get("Anzahl der Separatoren", "0")) - except Exception: - anzahl_separatoren = 0 + """ + Erstellt Omniflo aus einem Merkmale-Dictionary. + + Verwendet _safe_float/_safe_int für robuste Konvertierung + statt 8x wiederholtem try/except-Muster. + + Args: + teileid: Teile-Identifikator + x, y: Koordinaten in mm + merkmale: Dictionary mit Eigenschaftswerten + + Returns: + Omniflo-Instanz + """ return cls( teileid=teileid, x=x, y=y, - sivasnummer=sivasnummer, - laenge=laenge, - drehung=winkel, - hoehe=hoehe, - h0=h0, - h1=h1, - anzahl_scanner=anzahl_scanner, - anzahl_stopper=anzahl_separatoren, + sivasnummer=merkmale.get("SivasNummer"), + laenge=_safe_float(merkmale.get("Länge in Meter"), 0.0) * 1000, # Meter → mm + drehung=_safe_float(merkmale.get("Drehung"), 0.0), + hoehe=_safe_float(merkmale.get("Höhe"), 0.0), + h0=_safe_float(merkmale.get("Höhe unten"), 0.0), + h1=_safe_float(merkmale.get("Höhe oben"), 0.0), + anzahl_scanner=_safe_int(merkmale.get("Anzahl der Scanner"), 0), + anzahl_stopper=_safe_int(merkmale.get("Anzahl der Separatoren"), 0), ) + @staticmethod def Omniflo_geraden_erstellung(msp, doc, tefsivas, omniflo_objekt): - """Erstellung der Tef gerade und Omniflo gerade""" + """ + Erstellung der Tef gerade und Omniflo gerade. + + Zeichnet eine Linie zwischen Start- und Endpunkt basierend auf + Länge und Drehung des Omniflo-Objekts. + + Args: + msp: DXF-Modelspace + doc: DXF-Dokument + tefsivas: Sivas-Nummer der Tef-Strecke + omniflo_objekt: Omniflo-Instanz + """ winkel_rad = math.radians(omniflo_objekt.drehung) halbe_laenge = omniflo_objekt.laenge / 2 x = omniflo_objekt.x y = omniflo_objekt.y - # Man muss bei sin -1 machen wegen des links koordinaten system + # Koordinatensystem: sin mit -1 wegen links-Koordinatensystem dx = halbe_laenge * math.sin(winkel_rad * -1) dy = halbe_laenge * math.cos(winkel_rad) start = (x + dx, y + dy, omniflo_objekt.h1) @@ -94,8 +154,17 @@ class Omniflo(BaseModel): else: linie.dxf.layer = "A-2" + @staticmethod def omniflo_foerdererstellung(msp, doc, lib_doc, omniflo_objekt): - """ "Erstellung des Kettenförderers aktuell nur grundriss""" + """ + Erstellung des Kettenförderers (aktuell nur Grundriss). + + Args: + msp: DXF-Modelspace + doc: DXF-Dokument + lib_doc: Bibliotheks-Dokument + omniflo_objekt: Omniflo-Instanz + """ block_methoden.import_block("bogen1", lib_doc, doc) block_methoden.import_block("bogen2", lib_doc, doc) x = omniflo_objekt.x @@ -108,9 +177,10 @@ class Omniflo(BaseModel): blockname = f"OF_Förderer_{laenge}_{h_zwischen}" winkel_rad = math.radians(rotation) halbe_laenge = laenge / 2 - # Man muss bei sin -1 machen wegen des links koordinaten system - dx = halbe_laenge * math.sin(rotation * -1) - dy = halbe_laenge * math.cos(rotation) + # BUG FIX: war `math.sin(rotation * -1)` — rotation ist in Grad! + # Muss winkel_rad (Radianten) verwenden wie in Omniflo_geraden_erstellung + dx = halbe_laenge * math.sin(winkel_rad * -1) + dy = halbe_laenge * math.cos(winkel_rad) start = (x + dx, y + dy, h1) ende = (x - dx, y - dy, h0) if blockname not in doc.blocks: diff --git a/lib/Elemente/Omniflo_tests.py b/lib/Elemente/Omniflo_tests.py new file mode 100644 index 0000000..a5dfd1b --- /dev/null +++ b/lib/Elemente/Omniflo_tests.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" +Unit-Tests für Omniflo (Refactored) +""" +import unittest + +from .Omniflo import Omniflo, _safe_float, _safe_int + + +class TestSafeFloat(unittest.TestCase): + """Tests für _safe_float Hilfsfunktion.""" + + def test_normaler_string(self): + self.assertEqual(_safe_float("3.14"), 3.14) + + def test_komma_als_dezimaltrenner(self): + self.assertEqual(_safe_float("3,14"), 3.14) + + def test_none_gibt_default(self): + self.assertEqual(_safe_float(None, 42.0), 42.0) + + def test_ungueltiger_string_gibt_default(self): + self.assertEqual(_safe_float("abc", 1.0), 1.0) + + def test_int_input(self): + self.assertEqual(_safe_float(5), 5.0) + + def test_float_input(self): + self.assertEqual(_safe_float(2.5), 2.5) + + +class TestSafeInt(unittest.TestCase): + """Tests für _safe_int Hilfsfunktion.""" + + def test_normaler_string(self): + self.assertEqual(_safe_int("3"), 3) + + def test_float_string_wird_getruncated(self): + self.assertEqual(_safe_int("3.7"), 3) + + def test_none_gibt_default(self): + self.assertEqual(_safe_int(None, 7), 7) + + def test_ungueltiger_string(self): + self.assertEqual(_safe_int("xyz", 0), 0) + + +class TestOmnifloModell(unittest.TestCase): + """Tests für das Omniflo Modell.""" + + def test_from_merkmale_vollstaendig(self): + merkmale = { + "SivasNummer": "SV-123", + "Länge in Meter": "10,5", # Komma-Format + "Drehung": "-180", + "Höhe": "3.0", + "Höhe unten": "1.0", + "Höhe oben": "2.0", + "Anzahl der Scanner": "2", + "Anzahl der Separatoren": "1", + } + obj = Omniflo.from_merkmale("TEST-001", 50.0, 60.0, merkmale) + self.assertEqual(obj.teileid, "TEST-001") + self.assertEqual(obj.sivasnummer, "SV-123") + self.assertAlmostEqual(obj.laenge, 10500.0) # 10.5 * 1000 + self.assertEqual(obj.drehung, -180.0) + self.assertEqual(obj.hoehe, 3.0) + self.assertEqual(obj.h0, 1.0) + self.assertEqual(obj.h1, 2.0) + self.assertEqual(obj.anzahl_scanner, 2) + self.assertEqual(obj.anzahl_stopper, 1) + + def test_from_merkmale_fehlende_werte(self): + # Fehlende optionale Felder → defaults + merkmale = { + "SivasNummer": "SV-001", + } + obj = Omniflo.from_merkmale("TEST-002", 0.0, 0.0, merkmale) + self.assertEqual(obj.laenge, 0.0) # None * 1000 → default 0 * 1000 + self.assertEqual(obj.drehung, 0.0) + self.assertEqual(obj.hoehe, 0.0) + + def test_hight_zwischen(self): + merkmale = { + "SivasNummer": "SV-001", + "Höhe unten": "1.0", + "Höhe oben": "3.0", + } + obj = Omniflo.from_merkmale("TEST-003", 0.0, 0.0, merkmale) + self.assertEqual(obj.hight_zwischen, 2.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/plant2dxf.py b/lib/plant2dxf.py index d2f83e0..cc0201b 100644 --- a/lib/plant2dxf.py +++ b/lib/plant2dxf.py @@ -1604,6 +1604,7 @@ def main( logger.info(f"[DONE] DXF gespeichert unter: {output_path}") else: print(f"[DONE] DXF gespeichert unter: {output_path}") + logger.info("=== plant2dxf Verarbeitung abgeschlossen ===") def check_dxflibrary_path(lib_path, verbose, logger): @@ -1686,7 +1687,7 @@ if __name__ == "__main__": output_path_json = ( Path(args.output) if args.output else (work_dir / f"{csv_path.stem}.json") ) - main( + raise SystemExit(main( csv_path, default_lib_path, cfg_path, @@ -1695,5 +1696,4 @@ if __name__ == "__main__": output_path_json, verbose=args.verbose, logger=logger, - ) - logger.info("=== plant2dxf Verarbeitung abgeschlossen ===") + )) diff --git a/pyproject.toml b/pyproject.toml index 861c19c..b4f22e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,5 @@ target-version = ["py312"] [tool.ruff] line-length = 88 -select = ["E", "F", "I"] \ No newline at end of file +select = ["E", "F", "I"] +