Die Reichweitenzone aus der letzten Runde (Gehweg aus der ganzen Fahrbahn wachsen lassen, dann eine Zone um jede bordsteinlose Klasse wieder abziehen) fragte die falsche Richtung: "liegt dieser Punkt nahe einer Gasse?" statt "gehoert die naechste Fahrbahn zu einer Klasse mit Bordstein?". Die Zone kennt ihre Nachbarschaft nicht und frass dabei echten Wohnstrassen-Gehweg mit, sobald der zufaellig nah an einer Gasse lag - 250 von 500 m2 bei einer parallelen Gasse mit nur 0,5 m Korridorabstand, 7,58 m2 systematisch an jeder Gasse-Wohnstrasse- Einmuendung, unabhaengig von der Gassenlaenge. _walk_source(graph) baut die Fahrbahnquelle jetzt nur aus Kanten und Kreuzungen mit mindestens einem bordsteintragenden Arm (ribbon()/junction_area(), gefiltert auf SECTION[...].kerb). _sidewalk() waechst aus dieser Quelle statt aus der ganzen Fahrbahn und braucht keinen nachtraeglichen Abzug mehr. _kerbless_zone() bleibt nur noch fuer den Bordstein-Ausschluss in _kerb_lines(), mit dem urspruenglichen festen Zuschlag von 0,2 m. Rechenzeit am echten Netz gemessen: _walk_source() kostet zusaetzlich 1,8 s (carriageway_area() braucht 2,45 s), build_street() insgesamt rund 5,1-5,2 s - weit unter der 30-s-Schranke, kein Anlass, die Kreuzungsflaechen nur einmal statt zweimal zu berechnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
11 KiB
Python
178 lines
11 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
|
|
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"
|