_contract_short() ließ Kanten, die schon vorher am überlebenden Knoten hingen, auf ihrer alten Koordinate stehen - nur die vom eingezogenen Knoten umgehängten folgten dem neuen Mittelpunkt. Da Kontraktion beidseitig Grad >= 3 voraussetzt, hat der überlebende Knoten danach immer mindestens Grad 4, und _merge_chains() fasst nur Grad-2-Knoten an: Der Fehler konnte sich nie von selbst heilen. Am heutigen Mosaik wirkungslos (die Funktion greift dort kein einziges Mal), aber real für jeden Ausschnitt mit zwei nahen Kreuzungen - genau der Fall, den die Funktion behandeln soll. Dazu zwei Kleinigkeiten: _median_width() deckte den Kurzkanten-Fallback (unter 2 * WIDTH_MARGIN) bisher nicht ab, und ein Kommentar in _merge_chains() war ein Zeichen zu weit eingerückt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
9.5 KiB
Python
178 lines
9.5 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, CONTRACT_MAX, 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
|
|
|
|
|
|
def test_kurze_kante_ohne_randregel_misst_sich_ganz():
|
|
# Kante unter 2 * WIDTH_MARGIN (16 m): "end <= start" bei der Randregel greift ins Leere -
|
|
# _median_width() muss dann auf die ganze Kante zurückfallen statt auf ein leeres Stück.
|
|
# Alle anderen Breitentests laufen mit Kanten ab 160 m und erreichen diesen Zweig nie.
|
|
distance = np.full((50, 50), 3.0) # halbe Breite 3 m -> 6 m
|
|
graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 5, SLICE["y1"] - 20)),
|
|
Node(punkt(SLICE["x0"] + 15, SLICE["y1"] - 20))],
|
|
edges=[Edge(0, 1, gerade(punkt(SLICE["x0"] + 5, SLICE["y1"] - 20),
|
|
punkt(SLICE["x0"] + 15, SLICE["y1"] - 20)))])
|
|
out = measure_widths(graph, distance)
|
|
assert out.edges[0].width == 6.0
|
|
assert out.edges[0].klass == "gasse"
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
# Diesen Fall hat nicht das Mosaik gezeigt, sondern die Codeprüfung von Fixrunde 1: Die
|
|
# Kontraktion griff bisher an keiner Stelle im heutigen Netz, ihr Fehler blieb dort wirkungslos.
|
|
|
|
|
|
def test_kontraktion_zieht_alle_anliegenden_kanten_zum_neuen_knoten():
|
|
# Zwei Kreuzungen mit je Grad 3, 3 m auseinander (unter CONTRACT_MAX) - _contract_short()
|
|
# zieht sie zu einem Knoten in der Mitte zusammen. Nicht nur die zwei Kanten, die vorher am
|
|
# eingezogenen Knoten hingen, müssen dem neuen Punkt folgen, sondern auch die zwei, die schon
|
|
# vorher am überlebenden Knoten hingen: Der ist gerade selbst auf den Mittelpunkt gewandert.
|
|
assert 3 < CONTRACT_MAX
|
|
graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(3, 0)),
|
|
Node(punkt(-50, 0)), Node(punkt(0, 50)),
|
|
Node(punkt(53, 0)), Node(punkt(3, 50))],
|
|
edges=[Edge(0, 1, gerade(punkt(0, 0), punkt(3, 0))),
|
|
Edge(0, 2, gerade(punkt(0, 0), punkt(-50, 0))),
|
|
Edge(0, 3, gerade(punkt(0, 0), punkt(0, 50))),
|
|
Edge(1, 4, gerade(punkt(3, 0), punkt(53, 0))),
|
|
Edge(1, 5, gerade(punkt(3, 0), punkt(3, 50)))])
|
|
out = clean(graph)
|
|
ziel = out.nodes[0].point
|
|
assert ziel == punkt(1.5, 0.0), "der überlebende Knoten steht auf dem Mittelpunkt beider Kreuzungen"
|
|
fehler = [(edge.a, edge.b, edge.points) for edge in out.edges
|
|
if (edge.a == 0 and edge.points[0] != ziel) or (edge.b == 0 and edge.points[-1] != ziel)]
|
|
assert not fehler, f"Kanten mit veralteter Koordinate am zusammengezogenen Knoten: {fehler}"
|