Files
gta6/tools/extract/tests/test_roads_clean.py
T
knsoandClaude Sonnet 5 2d8f4e730c fix(extract): Kontraktion zieht jetzt alle anliegenden Kanten mit
_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>
2026-09-17 01:52:19 +02:00

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