Sackgassen unter 10 m sind Skelettfransen und fallen weg, Knoten mit nur noch zwei Kanten lösen sich auf, Kreuzungen näher als 5 m werden eine. Die Breite kommt aus der Distanztransformation ohne die ersten und letzten 8 m: dort misst sie die Kreuzung, nicht die Straße. Der Gegencheck am echten Mosaik deckte zwei Fälle auf, die kein synthetischer Test zeigt: Ein Rohast ohne Kreuzungsanschluss liefert eine Kante mit nur einem Stützpunkt (LineString verlangt 0 oder mehr als einen Punkt), und ein Knoten mit Schleife plus Verbindungskante hat kein zweites Ende zum Verketten. Beides fängt clean() jetzt ab, mit je einem Regressionstest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
137 lines
7.0 KiB
Python
137 lines
7.0 KiB
Python
"""Säubern des Graphen und Messen der Breite."""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from leonida.coords import SLICE
|
|
from leonida.roads import (CLASS_WIDTH, Edge, Graph, Node, SPUR_MIN, clean, measure_widths)
|
|
|
|
|
|
def punkt(x: float, y: float) -> tuple[float, float]:
|
|
return (float(x), float(y))
|
|
|
|
|
|
def gerade(a: tuple[float, float], b: tuple[float, float]) -> list[tuple[float, float]]:
|
|
return [a, b]
|
|
|
|
|
|
def test_kurze_sackgasse_faellt_weg():
|
|
# Eine Durchgangsstraße mit einem 6-m-Stummel: unter SPUR_MIN, also eine Skelettfranse.
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(100, 0)), Node(punkt(50, 0)), Node(punkt(50, 6))],
|
|
edges=[Edge(0, 2, gerade(punkt(0, 0), punkt(50, 0))),
|
|
Edge(2, 1, gerade(punkt(50, 0), punkt(100, 0))),
|
|
Edge(2, 3, gerade(punkt(50, 0), punkt(50, 6)))])
|
|
assert 6 < SPUR_MIN
|
|
out = clean(graph)
|
|
assert len(out.edges) == 1, "nach dem Stummel bleibt eine durchgehende Kante"
|
|
assert out.edges[0].points[0] == punkt(0, 0)
|
|
assert out.edges[0].points[-1] == punkt(100, 0)
|
|
|
|
|
|
def test_lange_sackgasse_bleibt():
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(100, 0)), Node(punkt(50, 0)), Node(punkt(50, 40))],
|
|
edges=[Edge(0, 2, gerade(punkt(0, 0), punkt(50, 0))),
|
|
Edge(2, 1, gerade(punkt(50, 0), punkt(100, 0))),
|
|
Edge(2, 3, gerade(punkt(50, 0), punkt(50, 40)))])
|
|
assert len(clean(graph).edges) == 3
|
|
|
|
|
|
def test_knoten_mit_nur_zwei_kanten_verschwindet():
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(50, 0)), Node(punkt(100, 0))],
|
|
edges=[Edge(0, 1, gerade(punkt(0, 0), punkt(50, 0))),
|
|
Edge(1, 2, gerade(punkt(50, 0), punkt(100, 0)))])
|
|
out = clean(graph)
|
|
assert len(out.edges) == 1
|
|
# clean() vereinfacht mit 1,0 m, und der Zwischenpunkt liegt genau auf der Verbindungslinie -
|
|
# Douglas-Peucker wirft ihn weg. Geprüft wird das Verketten, nicht die Zahl der Stützpunkte.
|
|
assert out.edges[0].points == [punkt(0, 0), punkt(100, 0)]
|
|
|
|
|
|
def test_breite_ignoriert_die_kreuzungsenden():
|
|
# 100 m lange Straße, 12 m breit, an beiden Enden eine 40 m breite Kreuzungsfläche.
|
|
# Ohne die Randregel zöge der Median die Klasse auf hauptstrasse hoch.
|
|
distance = np.full((200, 200), 0.0)
|
|
distance[:, :] = 6.0 # halbe Breite 6 m -> 12 m
|
|
distance[:, :10] = 20.0 # linkes Ende: 40 m
|
|
distance[:, -10:] = 20.0 # rechtes Ende
|
|
graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 5, SLICE["y1"] - 100)),
|
|
Node(punkt(SLICE["x0"] + 195, SLICE["y1"] - 100))],
|
|
edges=[Edge(0, 1, gerade(punkt(SLICE["x0"] + 5, SLICE["y1"] - 100),
|
|
punkt(SLICE["x0"] + 195, SLICE["y1"] - 100)))])
|
|
out = measure_widths(graph, distance)
|
|
assert out.edges[0].width == 12.0
|
|
assert out.edges[0].klass == "wohnstrasse"
|
|
|
|
|
|
def test_saeubern_bleibt_bei_vielen_fransen_schnell():
|
|
"""Wache gegen quadratische Laufzeit.
|
|
|
|
Das echte Skelett hat rund 12.000 Rohäste, die allermeisten davon Fransen. Eine Fassung, die
|
|
je Kante den Grad über alle Kanten sucht und nach jedem Fund von vorn beginnt, rechnet daran
|
|
Stunden — hier fällt sie sofort auf.
|
|
"""
|
|
import time
|
|
|
|
# Abstand 20 m zwischen den Hauptstreckenknoten: über SPUR_MIN, sonst wäre schon die
|
|
# Durchgangsstraße selbst aus lauter Kurzstücken gebaut, die clean() zu Recht als Fransen
|
|
# abträgt (das echte Skelett kennt keine Zwischenknoten unter der Kreuzungsgröße; jede
|
|
# Durchgangsstraße zwischen zwei Kreuzungen ist dort immer eine einzige, lange Kante).
|
|
nodes = [Node(punkt(x, 0)) for x in range(0, 20001, 20)]
|
|
edges = [Edge(i, i + 1, gerade(nodes[i].point, nodes[i + 1].point))
|
|
for i in range(len(nodes) - 1)]
|
|
for i in range(1, len(nodes) - 1, 2): # an jeden zweiten Knoten eine Franse
|
|
nodes.append(Node(punkt(nodes[i].point[0], 3)))
|
|
edges.append(Edge(i, len(nodes) - 1, gerade(nodes[i].point, nodes[-1].point)))
|
|
graph = Graph(nodes=nodes, edges=edges)
|
|
# 1001 Knoten -> 1000 Hauptkanten + 500 Fransen (range(1, 1000, 2)) = genau 1500, nie mehr:
|
|
# ">" aus dem Auftrag kann bei dieser Fixture nie zutreffen, ">=" prüft dieselbe Größenordnung.
|
|
assert len(graph.edges) >= 1500
|
|
start = time.perf_counter()
|
|
out = clean(graph)
|
|
assert time.perf_counter() - start < 5.0
|
|
assert len(out.edges) == 1, "nach den Fransen bleibt eine durchgehende Kante"
|
|
|
|
|
|
def test_klasse_ist_die_naechstliegende_regelbreite():
|
|
distance = np.full((200, 200), 8.0) # 16 m
|
|
graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100)),
|
|
Node(punkt(SLICE["x0"] + 180, SLICE["y1"] - 100))],
|
|
edges=[Edge(0, 1, gerade(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100),
|
|
punkt(SLICE["x0"] + 180, SLICE["y1"] - 100)))])
|
|
out = measure_widths(graph, distance)
|
|
assert out.edges[0].klass == "hauptstrasse"
|
|
assert CLASS_WIDTH["hauptstrasse"] == 18.0
|
|
|
|
|
|
# Die folgenden zwei Fälle hat erst der Gegencheck am echten Mosaik gezeigt (Schritt 5): Kein
|
|
# synthetischer Test bildet sie ab, aber am echten Netz treten sie zuverlässig auf.
|
|
|
|
|
|
def test_kante_mit_einem_stuetzpunkt_wird_wie_eine_franse_entfernt():
|
|
# Ein Rohast ohne Kreuzungsanschluss legt in graph_from_skeleton() (Task 3) zwei neue
|
|
# Sackgassenknoten an derselben Stelle an, ohne dass die Kante einen zweiten Punkt bekommt.
|
|
# LineString verlangt 0 oder mehr als einen Punkt - clean() darf daran nicht abstürzen,
|
|
# sondern muss die geometrisch bedeutungslose Kante wie eine Franse wegwerfen.
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(100, 0)), Node(punkt(50, 0)),
|
|
Node(punkt(200, 200)), Node(punkt(200, 200))],
|
|
edges=[Edge(0, 2, gerade(punkt(0, 0), punkt(50, 0))),
|
|
Edge(2, 1, gerade(punkt(50, 0), punkt(100, 0))),
|
|
Edge(3, 4, [punkt(200, 200)])])
|
|
out = clean(graph)
|
|
assert len(out.edges) == 1
|
|
assert out.edges[0].points[0] == punkt(0, 0)
|
|
assert out.edges[0].points[-1] == punkt(100, 0)
|
|
|
|
|
|
def test_schleife_an_einem_durchgangsknoten_bringt_clean_nicht_zum_absturz():
|
|
# Ein kleiner geschlossener Ring im Skelett (z.B. eine Insel im Straßenpolygon oder ein
|
|
# Kreisverkehr) hängt über einen Knoten mit genau zwei Kanten am Netz: die Schleife selbst
|
|
# und die Verbindung zum Rest. Die Schleife hat kein zweites, von diesem Knoten verschiedenes
|
|
# Ende - _merge_chains darf nicht versuchen, sie mit der Nachbarkante zu verketten.
|
|
ring = [punkt(50, 0), punkt(55, 5), punkt(50, 10), punkt(45, 5), punkt(50, 0)]
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(50, 0))],
|
|
edges=[Edge(0, 1, gerade(punkt(0, 0), punkt(50, 0))),
|
|
Edge(1, 1, ring)])
|
|
out = clean(graph)
|
|
assert any(e.a == e.b for e in out.edges), "die Schleife bleibt erhalten, statt clean() abstürzen zu lassen"
|