Zuschneiden (Fixrunde 4) war der falsche Hebel: Egal wie breit die abgezogene Zone, die Nische zwischen zwei Gassenstummeln entsteht erst durch die FILLET-Rundung in junction_area() und liegt abseits der Gassenachse - ein linearer Abzug entlang dieser Achse kann sie grundsaetzlich nicht erreichen. Die Stummelbildung aus junction_area() in einen eigenen Helfer gezogen, junction_arms(graph, node, kerb_only=False), mit einem Filter fuer Kanten ohne Bordstein. junction_area() nutzt ihn ungefiltert und rundet wie bisher - verhaelt sich dadurch identisch (913.566,7 m² in 213 Teilen am echten Netz, bit-genau nachgemessen). _walk_source() in street.py nimmt jetzt nur noch die rohen, ungerundeten Armstummel mit Bordstein (kerb_only=True) als Quelle, ohne den kerbless_zone-Abzug aus Fixrunde 4 - die Nische entsteht dadurch gar nicht erst, weil die Kreuzung ohne Rundung nur noch ueber ihre eigenen, bordsteintragenden Arme in die Quelle eingeht. Gemessen: an der Kreuzung mit drei Gassen und einem Boulevard verschwindet der Rest in der Nische vollstaendig (0,0 statt der 27,14 m² aus Fixrunde 4) - weiter als die fuer diese Runde erwartete Obergrenze von rund 6 m Ausdehnung. Kriterium 4 damit erfuellt, keine weitere Variante noetig. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
15 KiB
Python
230 lines
15 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"
|