"""Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen.""" from __future__ import annotations from shapely.geometry import LineString, Point, box from shapely.ops import unary_union from leonida.roads import Edge, Graph, Node from leonida.section import SECTION, carriageway_area from leonida.street import LOT_MIN, build_street, punch def strasse(klass: str = "wohnstrasse") -> Graph: return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION[klass].corridor, klass)]) def korridor(klass: str = "wohnstrasse"): halb = SECTION[klass].corridor / 2 return box(0, -halb, 100, halb) def test_gehweg_fuellt_genau_den_rest_des_korridors(): street = build_street(strasse(), korridor(), bridges=[]) section = SECTION["wohnstrasse"] # 100 m lang, zwei Streifen von 2,5 m. Die Enden sind offen, deshalb genau die Fläche. assert round(street.sidewalk.area) == round(100 * 2 * section.walk) assert street.sidewalk.intersection(street.carriageway).area < 0.01 assert street.sidewalk.geom_type == "MultiPolygon" def test_gasse_bekommt_weder_gehweg_noch_bordstein(): street = build_street(strasse("gasse"), korridor("gasse"), bridges=[]) assert street.sidewalk.area < 0.01 assert street.kerb == [] # Der Grenzfall für die MultiPolygon-Zusage: ein leeres Ergebnis käme ohne Normalisierung # als leeres Polygon zurück, nicht als leeres MultiPolygon (wie carriageway_area() es seit # Task 6 zusagt) - Task 10/12 iterieren über .geoms und würden daran brechen. assert street.sidewalk.geom_type == "MultiPolygon" def test_bordstein_laeuft_entlang_der_fahrbahnkante(): street = build_street(strasse(), korridor(), bridges=[]) gesamt = sum(line.length for line in street.kerb) # Zwei Längsseiten von 100 m; die kurzen Stirnseiten sind mit 7 m kaum der Rede wert. assert 190 < gesamt < 220 def test_bruecken_bekommen_keinen_bordstein(): bridge = box(40, -10, 60, 10) street = build_street(strasse(), korridor(), bridges=[bridge]) for line in street.kerb: assert not bridge.buffer(-0.5).intersects(line), "über der Brücke bleibt die Kante frei" def test_loch_hat_genau_die_form_der_fahrbahn(): platte = box(-50, -50, 150, 50) street = build_street(strasse(), korridor(), bridges=[]) rest = punch([platte], street.carriageway) assert len(rest) == 1 assert not rest[0].contains(Point(50, 0)), "die Fahrbahnmitte ist ausgeschnitten" assert rest[0].contains(Point(50, 20)), "daneben bleibt die Platte" assert abs(unary_union(rest).area + street.carriageway.area - platte.area) < 1.0 def test_kleine_reststuecke_fallen_weg_grosse_bleiben(): # Ein Korridor mit einer 30 x 30 m großen Ausbuchtung (ein Parkplatz) und einem Splitter. # Der Splitter braucht eine Lücke zum Korridorrand: Läge er wie die Ausbuchtung direkt an # y = 6 an, verschmölze schon unary_union() ihn mit ihr, weil beide dieselbe durchgehende # Kante berühren - dann bliebe für LOT_MIN unten gar nichts mehr zu filtern übrig. weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)]) street = build_street(strasse(), weit, bridges=[]) assert all(lot.area >= LOT_MIN for lot in street.lots) assert any(lot.area > 500 for lot in street.lots), "der Parkplatz bleibt" # Nur die Ausbuchtung (30x30 minus den 0,5 m Gehwegsaum an ihrer Unterkante = 885 m²) bleibt. # Die beiden Seitenstreifen des Basiskorridors (je 250 m²) sind jetzt korrekt als Gehweg # erkannt, nicht mehr als zusätzliche Restfläche gezählt - genau der Fehler aus Fixrunde 1, # Punkt 1 (_lots() zog vorher nur die Fahrbahn ab, nicht auch den Gehwegstreifen). assert len(street.lots) == 1, "der 2 m² große Splitter ist herausgefallen, die Seitenstreifen sind Gehweg" def test_gerade_strasse_ohne_ausbuchtung_hat_keine_restflaeche(): # Fixrunde 1, Punkt 1: "Korridor minus Fahrbahn" allein ist bei jeder Klasse wörtlich der # Gehweg (SECTION garantiert carriageway + 2*walk == corridor). Ohne den Gehwegabzug in # _lots() wären die beiden 2,5-m-Seitenstreifen (je 250 m², ab 80 m Länge über LOT_MIN) # eine zweite, asphaltfarbene Restfläche genau dort, wo schon der Gehweg liegt. street = build_street(strasse(), korridor(), bridges=[]) assert street.lots == [] def test_bordstein_frei_an_kurzer_gasseneinmuendung(): # Fixrunde 1, Punkt 2, mit einer nachgerechneten Korrektur der Gassenlänge (siehe Bericht): # Bei der vom Auftrag genannten 30 m langen Gasse liefert ribbon() *kein* None (Setback 5,5 m, # 30-5,5=24,5 >= 1,0) - ribbon() schneidet dort ganz regulär zurück, und der alte Ausschluss # deckt die Gasse bereits vollständig ab. Der Fehler braucht eine Gasse kürzer als # setback+1=6,5 m: Erst dann liefert trimmed_line() None, der alte Code fügt für diese Kante # überhaupt keinen Ausschluss ein (der Walrus-Filter "is not None" schlägt fehl), und der # Bordstein läuft quer über die Einmündung, statt an ihr abgesenkt zu sein. graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -5.0))], edges=[Edge(0, 1, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(1, 2, [(50.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(1, 3, [(50.0, 0.0), (50.0, -5.0)], SECTION["gasse"].corridor, "gasse")]) assert graph.degree(1) == 3 street = build_street(graph, box(-20, -40, 120, 20), bridges=[]) gassenkorridor = LineString([(50.0, 0.0), (50.0, -5.0)]).buffer(SECTION["gasse"].carriageway / 2) for line in street.kerb: assert not gassenkorridor.intersects(line), "die Zufahrt bleibt frei, kein Bordstein quer davor" def test_pflasterflaeche_neben_der_gasse_ist_restflaeche_kein_gehweg(): # Fixrunde 2: die Gasse hat laut SECTION überhaupt keinen Gehweg (walk=0.0). Eine echte, # 3 m tiefe, 100 m lange Pflasterfläche direkt neben ihr - z.B. ein durchgehender Hofstreifen, # den die Kartierung mit zur Straßenfläche zählt - darf nicht zu Gehweg werden, nur weil # WALK_MAX=3.0 dort zufällig genau hinreicht. Sie muss als Restfläche erscheinen. pflaster = box(0, 3, 100, 6) weit = unary_union([korridor("gasse"), pflaster]) street = build_street(strasse("gasse"), weit, bridges=[]) assert street.sidewalk.area < 0.01, "die Gasse bekommt so wenig Gehweg wie sonst: keinen" assert any(lot.area > 250 for lot in street.lots), "die Pflasterfläche bleibt als Restfläche" def test_fahrbahn_gehweg_und_rest_ergeben_wieder_den_korridor(): # Die drei Teile dürfen sich weder überlappen noch eine Lücke lassen - unabhängig vom # LOT_MIN-Filter in street.lots prüfen wir hier den *rohen* Rest (vor dem Filter), denn nur # der ergänzt Fahrbahn und Gehweg exakt wieder zum Korridor. weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)]) street = build_street(strasse(), weit, bridges=[]) roh = weit.difference(street.carriageway).difference(street.sidewalk) assert street.carriageway.intersection(street.sidewalk).area < 1e-6 assert street.carriageway.intersection(roh).area < 1e-6 assert street.sidewalk.intersection(roh).area < 1e-6 bilanz = street.carriageway.area + street.sidewalk.area + roh.area assert abs(bilanz - weit.area) < 1e-6 def test_aufteilung_geht_fuer_jede_klasse_auf(): # Fixrunde 3, Punkt 4: keine Klasse ausgenommen, auch nicht die Gasse (dort ist der Gehweg # leer, aber die Bilanz muss trotzdem exakt aufgehen: Fahrbahn plus roher Rest ergibt dann # den ganzen Korridor). for klass in SECTION: street = build_street(strasse(klass), korridor(klass), bridges=[]) roh = korridor(klass).difference(street.carriageway).difference(street.sidewalk) bilanz = street.carriageway.area + street.sidewalk.area + roh.area assert abs(bilanz - korridor(klass).area) < 1e-6, klass def test_wohnstrasse_behaelt_ihren_gehweg_trotz_naher_paralleler_gasse(): # Fixrunde 3: Der alte Ansatz ließ den Gehweg aus der *ganzen* Fahrbahn wachsen und zog # danach eine Reichweitenzone um jede bordsteinlose Klasse wieder ab. Diese Zone kennt ihre # Nachbarschaft nicht - jeder Punkt näher als WALK_MAX=3 m an einer Gassen-Mittellinie fiel # weg, auch wenn dort tatsächlich eine Wohnstraße liegt. Wohnstraße bei y=0 (Korridor bis # y=6), Gasse parallel bei y=9,5 (Korridor ab y=6,5) - 0,5 m Lücke zwischen den Korridoren, # die Gassen-Mittellinie liegt trotzdem nur 9,5 m entfernt, deutlich innerhalb der alten, # 6 m weiten Reichweitenzone (carriageway/2 + WALK_MAX = 3+3). graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 9.5)), Node((100.0, 9.5))], edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(2, 3, [(0.0, 9.5), (100.0, 9.5)], SECTION["gasse"].corridor, "gasse")]) corridor = unary_union([box(0, -6, 100, 6), box(0, 6.5, 100, 12.5)]) street = build_street(graph, corridor, bridges=[]) assert round(street.sidewalk.area) == 500, "die volle Gehwegfläche der Wohnstraße bleibt" def test_wohnstrassen_gehweg_bleibt_vollstaendig_an_gasseneinmuendung(): # Fixrunde 3: an der T-Einmündung reichte dieselbe Reichweitenzone der Gasse (jetzt um ihre # volle Mittellinie, wegen Fixrunde 1) symmetrisch in beide Wohnstraßen-Gehwege an der Ecke # hinein - unabhängig von der Gassenlänge, solange die Gasse nicht selbst in der Nähe endet # (deshalb hier 10 m lang, nicht die 5 m aus dem Bordstein-Test oben). Zwei Punkte, die genau # in den vorher fehlenden, je 3,79 m² großen Flächen an den gerundeten Kreuzungsecken liegen. graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -10.0))], edges=[Edge(0, 1, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(1, 2, [(50.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(1, 3, [(50.0, 0.0), (50.0, -10.0)], SECTION["gasse"].corridor, "gasse")]) wohn_korridor = box(0, -6, 100, 6) gassen_korridor = LineString([(50.0, 0.0), (50.0, -10.0)]).buffer(SECTION["gasse"].corridor / 2, cap_style="flat") street = build_street(graph, unary_union([wohn_korridor, gassen_korridor]), bridges=[]) assert street.sidewalk.contains(Point(45.0, -5.0)), "Wohnstraßen-Gehweg links der Einmündung" assert street.sidewalk.contains(Point(55.0, -5.0)), "Wohnstraßen-Gehweg rechts der Einmündung" def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung(): # Fixrunde 4/5: _walk_source() nahm ursprünglich die ganze junction_area() auf, sobald # irgendein Arm Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört. # Ein linearer Zuschnitt (Fixrunde 4) reichte nicht, weil die FILLET-Rundung die Nische # zwischen Gassenstummeln abseits der Gassenachse aufweitet. Seit Fixrunde 5 geht die # Kreuzung nur noch über ihre bordsteintragenden Armstummel (junction_arms(kerb_only=True), # ungerundet) in die Quelle ein - die Nische entsteht dadurch gar nicht erst. T-Kreuzung: # zwei durchgehende Gassenarme (West/Ost) plus ein Wohnstraßenarm (Süd). Ohne den Fix wächst # ein Gehwegstreifen die volle Länge beider Gassenstummel entlang (53,11 m² im Nordstreifen # y∈[3,6]), obwohl mitten auf der Gasse (Kontrollpunkt, weit von der Kreuzung) kein Gehweg # entsteht - der Fehler sitzt eindeutig am Kreuzungsanteil. graph = Graph(nodes=[Node((-50.0, 0.0)), Node((0.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0))], edges=[Edge(0, 1, [(-50.0, 0.0), (0.0, 0.0)], SECTION["gasse"].corridor, "gasse"), Edge(1, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"), Edge(1, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")]) assert graph.degree(1) == 3 # Großzügiger Korridor (voller WALK_MAX-Bereich um die Fahrbahn), damit der Bug ungehindert # zur Geltung kommt - ein knapper Korridor würde ihn wegschneiden, bevor er sichtbar wird. corridor = carriageway_area(graph).buffer(3.0, join_style="round") street = build_street(graph, corridor, bridges=[]) gassenstreifen_nord = box(-15, 3, 15, 6) assert street.sidewalk.intersection(gassenstreifen_nord).area < 0.01, \ "kein Gehweg entlang der Gassenstummel, nur der Wohnstraßen-Gehweg im Süden bleibt" def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus(): # Fixrunde 5, Abnahmepunkt 4 - jetzt Zielverhalten statt Zwischenstand: Kreuzung mit drei # Gassenarmen (West/Ost/Süd) und einem Boulevard-Arm (Nord). setback = max(9,3,3,3)+2 = 11 m # - der Boulevard zieht den Kreuzungsstummel aller vier Arme, auch der schmalen Gassen, auf # 12 m Länge hoch. Ein linearer Zuschnitt (Fixrunde 4, _kerbless_zone entlang der # Gassen-Mittellinie) erreichte die durch die FILLET-Rundung aufgeweitete Nische zwischen # zwei benachbarten Gassenstummeln nicht (Rest blieb bei 27,14 m², Ausdehnung bis 11,14 m). # Seit Fixrunde 5 geht die Kreuzung nur noch über ihren bordsteintragenden Arm (den # Boulevard) in die Gehwegquelle ein, ungerundet - die drei Gassenarme tragen gar nichts # mehr bei, die Nische zwischen ihnen kann also gar nicht erst entstehen. graph = Graph(nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0)), Node((0.0, 50.0))], edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["gasse"].corridor, "gasse"), Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"), Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["gasse"].corridor, "gasse"), Edge(0, 4, [(0.0, 0.0), (0.0, 50.0)], SECTION["boulevard"].corridor, "boulevard")]) assert graph.degree(0) == 4 corridor = carriageway_area(graph).buffer(3.0, join_style="round") street = build_street(graph, corridor, bridges=[]) nische = box(-15, -15, -3, -3) rest = street.sidewalk.intersection(nische) # Gemessen: 0,0 m² exakt - deutlich strenger als die von der Abnahme geforderte Obergrenze # von rund WALK_MAX + halbe Gassenbreite (~6 m Ausdehnung); 1,0 m² lässt Spielraum für # künftige, geometrisch bedingte Rundungsreste, ohne die Kernaussage zu verwässern. assert rest.area < 1.0, "die Nische zwischen zwei Gassenstummeln bleibt frei von Gehweg" def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke(): """Befund 7 der Fixwelle: An einer gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine Gasse beteiligt) klaffte zwischen der gerundeten Fahrbahnecke (carriageway_area() rundet mit FILLET=6,0) und der ungerundeten Gehwegquelle eine kleine Lücke - eine reine Dilatation der Quelle um WALK_MAX=3,0 zeichnet eine Reflexecke nicht nach. Am echten Netz 0,26-2,2 m² je Ecke bei 690 Kreuzungen; hier reproduziert an einer T-Kreuzung aus drei Wohnstraßen-Armen, mit demselben großzügigen Korridor wie in den Gassen-Tests oben (carriageway_area() + 3 m). Vor der Korrektur maß jede der beiden Ecken exakt 0,256 m² Lücke (52 gerundete Doppelpunkte davon zusammen 0,5114 m²) - innerhalb des berichteten Bereichs. _walk_source() rundet den Kreuzungsanteil jetzt mit derselben Schließung wie carriageway_area() (junction_area(kerb_only=True)), sodass Fahrbahn- und Gehwegquelle an der Ecke wieder zueinander passen. """ graph = Graph( nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0))], edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")]) corridor = carriageway_area(graph).buffer(3.0, join_style="round") street = build_street(graph, corridor, bridges=[]) # Der rohe Rest (vor dem LOT_MIN-Filter aus _lots()): An einer gewöhnlichen Ecke darf davon # nichts übrig bleiben, sonst wird die Lücke später als zu kleines Reststück verworfen und # bleibt ein unsichtbares Loch im Boden. roh_rest = corridor.difference(street.carriageway).difference(street.sidewalk) ecken = Point(0, 0).buffer(15.0) luecke = sum(g.area for g in getattr(roh_rest, "geoms", [roh_rest]) if not g.is_empty and g.intersects(ecken) and g.area < LOT_MIN) assert luecke < 0.05, f"{luecke:.4f} m² Lücke an den Kreuzungsecken, sollte geschlossen sein"