"""Haltelinien und Zebrastreifen.""" from __future__ import annotations from collections import Counter import pytest from shapely.geometry import LineString from leonida.roads import Edge, Graph, Node from leonida.marking import BAR, CROSSWALK_DEPTH, junction_marks, markings from leonida.section import SECTION, setback def kreuzung(klassen=("hauptstrasse",) * 4, arm: float = 80.0) -> Graph: punkte = [(arm, 0.0), (-arm, 0.0), (0.0, arm), (0.0, -arm)] return Graph(nodes=[Node((0.0, 0.0))] + [Node(p) for p in punkte], edges=[Edge(0, i + 1, [(0.0, 0.0), p], SECTION[k].corridor, k) for i, (p, k) in enumerate(zip(punkte, klassen))]) def test_jeder_arm_bekommt_zebra_und_haltelinie(): zahl = Counter(art for _, art in junction_marks(kreuzung(), 0)) assert zahl["haltelinie"] == 4, "eine je Zufahrt" assert zahl["zebra"] > 4 * 5, "je Arm mehrere Balken" def test_zebra_liegt_am_kreuzungsrand(): rand = setback(kreuzung(), 0) zebra = [p for p, art in junction_marks(kreuzung(), 0) if art == "zebra"] # Der Arm nach Osten: Balken zwischen Kreuzungsrand und Kreuzungsrand plus Tiefe. Die # Querbedingung muss enger sein als die halbe Fahrbahnbreite (6 m): Bei |y| < 20 träfe sie # auch Nord- und Südarm-Balken nahe der Ecke (deren eigene Querachse dort ohnehin liegt), die # aber bei x um 8-11 m liegen wie der Ostarm selbst - falsches Grün durch Verwechslung, keine # echte Prüfung (Fixrunde zu Task 9). halb = SECTION["hauptstrasse"].carriageway / 2 ost = [p for p in zebra if p.centroid.x > 0 and abs(p.centroid.y) < halb] assert ost, "der Ostarm hat Balken" for poly in ost: assert rand - 0.1 <= poly.bounds[0] <= rand + CROSSWALK_DEPTH def test_haltelinie_liegt_hinter_dem_zebrastreifen(): rand = setback(kreuzung(), 0) halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] # Wie bei test_zebra_liegt_am_kreuzungsrand: |y| < 20 wäre zu locker. Die Haltelinie des # Südarms erfüllt sie ebenfalls (centroid bei x=3, |y|=12,5 < 20) - next() traf bisher nur # zufällig zuerst auf den Ostarm, weil er in "echte" zuerst kommt, nicht weil der Filter ihn # tatsächlich isoliert (Fixrunde 1 des Koordinators zu Task 9, vom Prüfer nachgerechnet). halb = SECTION["hauptstrasse"].carriageway / 2 ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < halb) assert ost.bounds[0] > rand + CROSSWALK_DEPTH, "erst Zebrastreifen, dann Haltelinie" def test_haltelinie_deckt_nur_die_rechte_haelfte(): halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] # Derselbe zu lockere Filter wie oben - siehe dortigen Kommentar. halb = SECTION["hauptstrasse"].carriageway / 2 ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < halb) # Zufahrt von Osten nach Westen, also Fahrtrichtung (-1, 0): Rechts davon liegt y > 0 - per # offset_curve()-Konvention nachgerechnet (negativ = rechts der Linienrichtung), nicht nur # behauptet. Der Auftrag ging von y < 0 aus; das war die Gegenspur (Fixrunde zu Task 9). assert ost.centroid.y > 0 assert round(ost.bounds[3] - ost.bounds[1], 1) == round(SECTION["hauptstrasse"].carriageway / 2, 1) def test_zebrabalken_liegen_symmetrisch_zur_fahrbahnmitte(): """Befund 8 der Fixwelle: Der alte Startversatz (-halb + BAR/2, Takt 2*BAR) liess die Balkenreihe an einem Fahrbahnrand bündig sitzen und am gegenüberliegenden bis zu eine Taktbreite frei - 0,6 m bei Hauptstraße/Boulevard, 0,4 m bei Wohnstraße, statt der laut Entwurf verlangten Aufteilung "Balken 60 cm, Lücke 60 cm" über die ganze Breite. n Balken, symmetrisch um die Fahrbahnmitte gestartet, lassen an beiden Rändern denselben Rest frei. """ halb = SECTION["hauptstrasse"].carriageway / 2 zebra = [p for p, art in junction_marks(kreuzung(), 0) if art == "zebra"] # Wie in test_zebra_liegt_am_kreuzungsrand: nur der Ostarm, klar von den Nachbararmen isoliert. ost = sorted((p.centroid.y for p in zebra if p.centroid.x > 0 and abs(p.centroid.y) < halb)) rand_unten = (ost[0] - BAR / 2) - (-halb) rand_oben = halb - (ost[-1] + BAR / 2) assert rand_unten == pytest.approx(rand_oben, abs=1e-6), ( f"Ränder ungleich: unten {rand_unten:.3f} m, oben {rand_oben:.3f} m" ) assert rand_unten == pytest.approx(0.3, abs=1e-6), "bei 12 m Fahrbahn bleiben 0,3 m an jedem Rand" def test_arm_der_eine_gasse_ist_bekommt_keinen_zebrastreifen(): graph = kreuzung(("hauptstrasse", "hauptstrasse", "hauptstrasse", "gasse")) marks = junction_marks(graph, 0) # Wie bei test_zebra_liegt_am_kreuzungsrand: |x| < 20 wäre zu locker und träfe auch Ost- und # Westarm-Balken nahe der Ecke, obwohl deren Querachse (|x| bis 9,5 m für Zebra, 12,5 m für # die Haltelinie) klar außerhalb der Südarm-Fahrbahnhälfte liegt (Fixrunde zu Task 9). halb = SECTION["hauptstrasse"].carriageway / 2 süd = [art for p, art in marks if p.centroid.y < -1 and abs(p.centroid.x) < halb] assert süd == [], "der Gassenarm bleibt blank" def test_kreuzung_aus_lauter_gassen_bekommt_nichts(): assert junction_marks(kreuzung(("gasse",) * 4), 0) == [] def test_markings_sammelt_laengs_und_kreuzung(): arten = {art for _, art in markings(kreuzung())} assert "gelb_doppelt" in arten and "zebra" in arten and "haltelinie" in arten def test_schleife_bekommt_haltelinie_fuer_beide_richtungen(): # Wie test_schleife_bekommt_zwei_stummel_kein_loch_im_belag in test_junction.py: Eine # Schleife hängt mit beiden Enden am selben Knoten. graph.incident() liefert sie nur einmal, # aber sie stellt zwei getrennte Abgangsrichtungen dar (Verkehr kann von beiden Seiten in die # Kreuzung einfahren) - am echten Netz betrifft das 28 von 690 Kreuzungen mit einer # bordsteintragenden Schleife. Ohne die zweite Richtung fehlten Haltelinie und Zebrastreifen # auf der einen Seite der Schleife komplett. graph = Graph( nodes=[Node((0.0, 0.0)), Node((-100.0, 0.0)), Node((0.0, -100.0))], edges=[Edge(0, 0, [(0.0, 0.0), (0.0, 20.0), (20.0, 20.0), (20.0, 0.0), (0.0, 0.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse"), Edge(0, 1, [(0.0, 0.0), (-100.0, 0.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse"), Edge(0, 2, [(0.0, 0.0), (0.0, -100.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse")]) zahl = Counter(art for _, art in junction_marks(graph, 0)) assert zahl["haltelinie"] == 4, "zwei gerade Arme plus zwei Richtungen der Schleife" def test_markierung_folgt_dem_geknickten_arm_nicht_der_ersten_richtung(): """Befund 3 der Fixwelle: "along" kam aus der Richtung origin -> points[1], also allein aus dem ersten Segment der Zufahrt. Knickt die Kante innerhalb von reach + STOP_SETBACK + STOP_DEPTH ab (bis 16 m), blieben Zebrastreifen und Haltelinie auf der Verlängerung des ersten Segments stehen, statt der tatsächlichen Kante zu folgen. Am echten Netz lagen dadurch 452,65 m² Markierungsfläche (1,03 %) neben der Fahrbahn, darunter ganze Haltelinien. Nachgebaut mit einer Zufahrt, die 1 m nach dem Knoten um 90° abknickt: reach (5,5 m bei Wohnstraße) und erst recht die Haltelinie bei 9,7-10,3 m liegen weit hinter dem Knick. Der zweite Arm zeigt nach Westen, weit weg von jeder möglichen Richtung des ersten - er dient nur dazu, die Mindestzahl von zwei Zufahrten mit Bordstein zu erfüllen (`echte`). """ arm = [(0.0, 0.0), (0.0, -1.0), (20.0, -1.0)] graph = Graph( nodes=[Node((0.0, 0.0)), Node((-80.0, 0.0)), Node((20.0, -1.0))], edges=[Edge(0, 1, [(0.0, 0.0), (-80.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(0, 2, arm, SECTION["wohnstrasse"].corridor, "wohnstrasse")], ) halb = SECTION["wohnstrasse"].carriageway / 2 fahrbahn = LineString(arm).buffer(halb, cap_style="flat") # Der Westarm liegt bei x <= -80 * 0,15 (Kreuzungsradius) .. deutlich unter -4; alles mit # centroid.x > -4 gehört zur geknickten Zufahrt, gleich in welche Richtung sie zeigt. ziel = [(p, a) for p, a in junction_marks(graph, 0) if p.centroid.x > -4] haltelinie = next(p for p, a in ziel if a == "haltelinie") anteil_halt = haltelinie.intersection(fahrbahn).area / haltelinie.area assert anteil_halt > 0.9, ( f"Haltelinie liegt nur zu {anteil_halt:.0%} auf der (geknickten) Fahrbahn - sie folgt " "noch der Richtung des ersten Segments statt der tatsächlichen Kante" ) zebra = [p for p, a in ziel if a == "zebra"] anteile = [p.intersection(fahrbahn).area / p.area for p in zebra] mittel = sum(anteile) / len(anteile) assert mittel > 0.7, f"Zebrastreifen im Mittel nur zu {mittel:.0%} auf der Fahrbahn" assert sum(1 for a in anteile if a > 0.9) >= len(anteile) - 1, "fast jeder Balken muss klar auf der Fahrbahn liegen" def test_schleife_die_auf_sich_selbst_zurueckfuehrt_bekommt_keine_markierung_daneben(): """Zweite Korrektur zu Befund 3, am echten Netz entdeckt: Eine Schleife kann geradewegs von ihrem Knoten weg- und auf demselben Weg wieder zurücklaufen (am echten Netz eine 21,63 m lange Boulevard-Schleife, deren Punktliste den Hinweg exakt rückwärts wiederholt). Landet reach nahe dieser Kehre, zeigt die Sehne vom Ursprung zu interpolate(reach) weiter nach außen, obwohl die Kante dort längst umkehrt - mit dieser Zwischenstufe der Korrektur lag die gesamte Markierung (Zebra und Haltelinie) an genau dieser Schleife vollständig neben der Fahrbahn (0 % statt 100 %, nachgerechnet gegen die echten Koordinaten). Anker am tatsächlichen Punkt plus Richtung aus dem letzten Teilstück von substring() behebt das. Nachgebaut mit denselben Maßen wie am echten Netz (9 m hin, 6 m quer, macht 21,63 m Umweg; reach=11 m, wie es der zweite Boulevard-Arm hier erzwingt). Gegengeprüft in der Gehwegtextur-Korrekturrunde: Die Nachprüfung der Fixwelle vermutete, der Test unterscheide nicht (halb=9 m deckt jede kleine Markierung nahe am Knoten ab, gleich wohin sie zeigt) und schlage auch mit dem alten Code an dieser Schleife grün an. Nachgestellt mit den byte-genauen historischen Fassungen von junction_marks() (Commits 4bf670e "vor Befund 3" und ca01141 "nach Befund 3, vor dieser Korrektur hier") und exakt dieser Testfunktion (nur junction_marks per Monkeypatch getauscht): Beide Fassungen schlagen rot an (schlechtester Anteil 64,9 % bzw. 85,3 %, an der Haltelinie und am äußersten Zebrabalken - die runde Fuge am Schleifenscheitel deckt zwar die meisten Balken vollständig ab, aber nicht alle). Nur der aktuelle Code besteht (100 %). Der Test bleibt deshalb unverändert. """ spitze = [(0.0, 0.0), (9.0, -6.0), (0.0, 0.0)] graph = Graph( nodes=[Node((0.0, 0.0)), Node((-80.0, 0.0)), Node((9.0, -6.0))], edges=[Edge(0, 1, [(0.0, 0.0), (-80.0, 0.0)], SECTION["boulevard"].corridor, "boulevard"), Edge(0, 0, spitze, SECTION["boulevard"].corridor, "boulevard")], ) halb = SECTION["boulevard"].carriageway / 2 fahrbahn = LineString(spitze).buffer(halb, cap_style="flat") ziel = [(p, a) for p, a in junction_marks(graph, 0) if p.centroid.x > 1] # nur die Schleife assert ziel, "die Schleife muss Markierungen bekommen (zwei bordsteintragende Arme)" for poly, art in ziel: anteil = poly.intersection(fahrbahn).area / poly.area assert anteil > 0.99, f"{art} liegt nur zu {anteil:.0%} auf der Schleifen-Fahrbahn" def test_kurzer_arm_an_breiter_kreuzung_bleibt_ohne_markierung(): # reach = setback() kommt vom breitesten anliegenden Arm (hier Boulevard, halbe Fahrbahn 9 m) # und gilt für alle Zufahrten gleich - ein kurzer Arm reicht dann nicht mehr bis dorthin. Am # echten Netz sind 38 von 1.508 geprüften Armstummeln zu kurz für die Haltelinie (44 von 1.536 # tatsächlichen Zufahrten, die Schleifen aus der vorigen Fixrunde zählen doppelt), im # schlimmsten Fall um 10,6 m (Fixrunde 1 des Koordinators zu Task 9). Der Nordarm hier ist nur # 10 m lang; reach allein liegt schon bei 11 m, also klar dahinter - jede Markierung, die # trotzdem entstünde, läge neben der Straße. graph = Graph( nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 10.0))], edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION["boulevard"].corridor, "boulevard"), Edge(0, 2, [(0.0, 0.0), (0.0, 10.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse")]) marks = junction_marks(graph, 0) jenseits_des_kurzen_arms = [p for p, _ in marks if p.centroid.y > 10.0] assert jenseits_des_kurzen_arms == [], "der 10 m lange Nordarm bekommt nichts, reach ist 11 m" arten = {art for _, art in marks} assert {"zebra", "haltelinie"} <= arten, "der breite, lange Ostarm behält beides"