docs: Säuberung des Graphen gegen quadratische Laufzeit absichern

Die Fassung im Plan suchte je Kante den Grad über alle Kanten und begann
nach jedem Fund von vorn. Am echten Skelett mit rund 12.000 Rohästen wäre
das nicht langsam, sondern praktisch stehengeblieben. Jetzt wird der Grad
einmal gezählt und fortgeschrieben, ein Durchgang räumt alle Fransen auf
einmal weg, und ein Test mit über 1.500 Kanten nagelt es fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 00:48:58 +02:00
1 parent 3fe3cd5c45
commit 1300dc9a2a
1 file changed
+68 -12
+68 -12
View File
@@ -532,6 +532,29 @@ def test_breite_ignoriert_die_kreuzungsenden():
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
nodes = [Node(punkt(x, 0)) for x in range(0, 2001, 2)]
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)
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)),
@@ -553,6 +576,8 @@ Erwartet: `ImportError: cannot import name 'clean'`
An `tools/extract/leonida/roads.py` anhängen:
```python
from collections import Counter
from shapely.geometry import LineString
from .coords import SLICE
@@ -595,25 +620,55 @@ def _length(edge: Edge) -> float:
return LineString(edge.points).length
def _degrees(graph: Graph) -> Counter:
grade = Counter()
for edge in graph.edges:
grade[edge.a] += 1
grade[edge.b] += 1
return grade
def _drop_spurs(graph: Graph) -> None:
changed = True
while changed:
changed = False
"""Fransen weg, in Durchgängen statt Fund für Fund.
Der Grad wird einmal gezählt und beim Entfernen fortgeschrieben, statt ihn je Kante neu über
alle Kanten zu suchen, und ein Durchgang räumt alle Fransen auf einmal weg, statt nach jedem
Fund von vorn zu beginnen. Beides zusammen macht aus einer quadratischen eine lineare
Rechnung je Durchgang — das echte Skelett hat rund 12.000 Rohäste, und die naive Fassung
rechnete daran Stunden. Die Zahl der Durchgänge ist die Tiefe der Fransenketten, also klein.
"""
grade = _degrees(graph)
while True:
bleibt, entfernt = [], 0
for edge in graph.edges:
if (grade[edge.a] == 1 or grade[edge.b] == 1) and _length(edge) < SPUR_MIN:
grade[edge.a] -= 1
grade[edge.b] -= 1
entfernt += 1
else:
bleibt.append(edge)
graph.edges = bleibt
if not entfernt:
return
def _incident_index(graph: Graph) -> dict[int, list[int]]:
index: dict[int, list[int]] = {}
for k, edge in enumerate(graph.edges):
frei = graph.degree(edge.a) == 1 or graph.degree(edge.b) == 1
if frei and _length(edge) < SPUR_MIN:
del graph.edges[k]
changed = True
break
index.setdefault(edge.a, []).append(k)
if edge.b != edge.a:
index.setdefault(edge.b, []).append(k)
return index
def _merge_chains(graph: Graph) -> None:
"""Knoten mit genau zwei Kanten auflösen. Der Index entsteht je Verschmelzung einmal neu —
das ist vertretbar, weil es nach dem Fransenschnitt nur noch wenige solcher Knoten gibt."""
changed = True
while changed:
changed = False
for node in range(len(graph.nodes)):
incident = graph.incident(node)
if len(incident) != 2 or graph.degree(node) != 2:
for node, incident in _incident_index(graph).items():
if len(incident) != 2:
continue
first, second = (graph.edges[i] for i in incident)
points = _join(first, second, graph.nodes[node].point)
@@ -640,10 +695,11 @@ def _contract_short(graph: Graph) -> None:
changed = True
while changed:
changed = False
grade = _degrees(graph)
for k, edge in enumerate(graph.edges):
if edge.a == edge.b or _length(edge) >= CONTRACT_MAX:
continue
if graph.degree(edge.a) < 3 or graph.degree(edge.b) < 3:
if grade[edge.a] < 3 or grade[edge.b] < 3:
continue
ax, ay = graph.nodes[edge.a].point
bx, by = graph.nodes[edge.b].point