Files
gta6/tools/extract/tests/test_street.py
T
knsoandClaude Sonnet 5 35d69c3e6c test(extract): toothless Gassen-Gehweg-Probe entfernt, Flaechenvaliditaet abgesichert
Nachpruefung der Fixwelle, drei Punkte:

test_gehweg_waechst_auch_bei_engem_winkel_nicht_in_die_gasse
(test_street.py) besteht identisch (0,0000 m²) unter dem aktuellen Code,
unter der verworfenen Fassung aus Fixrunde 4 und sogar unter einem
komplett ungefilterten walk_source = carriageway_area(graph) - nachgerechnet
mit einer Wegwerf-Kopie aller drei Varianten. Ursache: SECTION["gasse"].corridor
== SECTION["gasse"].carriageway, weshalb .difference(carriage) am Ende von
_sidewalk() den geprueften Streifen (die eigene Gassen-Fahrbahn) immer
loescht, unabhaengig davon, was _walk_source() liefert. Die Eigenschaft
selbst bleibt durch die drei benachbarten Tests
(test_gasse_bekommt_weder_gehweg_noch_bordstein,
test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung,
test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus) abgesichert,
die gegen dieselbe verworfene Fassung mit 53,11 bzw. 53,30 m² hart
durchfallen. Ein Test, der nicht fehlschlagen kann, taeuscht Deckung vor,
die es nicht gibt - entfernt statt umdimensioniert, weil eine Geometrie
mit nur einem Gassenarm die eigentliche Fixrunde-4-Nische (zwischen ZWEI
benachbarten Gassenstummeln) strukturell nie nachstellen kann.

test_schleife_die_auf_sich_selbst_zurueckfuehrt_bekommt_keine_markierung_daneben
(test_marking_junction.py) galt in der Nachpruefung ebenfalls als
unterscheidungslos (100% Fahrbahnanteil auch mit altem Code). Mit den
byte-genauen historischen Fassungen von junction_marks() (git show
4bf670e / ca01141) direkt in die bestehende Testfunktion eingesetzt
(Monkeypatch) zeigt sich das Gegenteil: Beide Vorstufen schlagen rot an
(schlechtester Anteil 64,9% bzw. 85,3%, an Haltelinie und aeusserstem
Zebrabalken), nur der aktuelle Code besteht. Der Test bleibt unveraendert,
mit einem Kommentar zur Gegenpruefung.

Neu: test_alle_flaechenschichten_sind_gueltige_polygone
(test_committed_roads.py) - Waechter aus Befund 5 (die alte
Koordinatenrundung erzeugte acht Selbstschnitte, ohne dass ein Test es
merkte). Prueft ueber alle 144 Chunks und alle sieben Flaechenschichten
(carriageway, land, sidewalk, surfaces, lots, bridges, paths), dass jeder
Ring ein gueltiges Polygon ergibt. Heute gruen (4.190 Flaechen, 0
ungueltige); rot nachgewiesen gegen den Datenstand vor Commit a397892
(9 ungueltige Polygone in 6 Chunks, per git show ohne Codeaenderung
nachgestellt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:07:27 +02:00

261 lines
17 KiB
Python

"""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"