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
+69 -13
+69 -13
View File
@@ -532,6 +532,29 @@ def test_breite_ignoriert_die_kreuzungsenden():
assert out.edges[0].klass == "wohnstrasse" 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(): def test_klasse_ist_die_naechstliegende_regelbreite():
distance = np.full((200, 200), 8.0) # 16 m distance = np.full((200, 200), 8.0) # 16 m
graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100)), 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: An `tools/extract/leonida/roads.py` anhängen:
```python ```python
from collections import Counter
from shapely.geometry import LineString from shapely.geometry import LineString
from .coords import SLICE from .coords import SLICE
@@ -595,25 +620,55 @@ def _length(edge: Edge) -> float:
return LineString(edge.points).length 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: def _drop_spurs(graph: Graph) -> None:
changed = True """Fransen weg, in Durchgängen statt Fund für Fund.
while changed:
changed = False Der Grad wird einmal gezählt und beim Entfernen fortgeschrieben, statt ihn je Kante neu über
for k, edge in enumerate(graph.edges): alle Kanten zu suchen, und ein Durchgang räumt alle Fransen auf einmal weg, statt nach jedem
frei = graph.degree(edge.a) == 1 or graph.degree(edge.b) == 1 Fund von vorn zu beginnen. Beides zusammen macht aus einer quadratischen eine lineare
if frei and _length(edge) < SPUR_MIN: Rechnung je Durchgang — das echte Skelett hat rund 12.000 Rohäste, und die naive Fassung
del graph.edges[k] rechnete daran Stunden. Die Zahl der Durchgänge ist die Tiefe der Fransenketten, also klein.
changed = True """
break 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):
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: 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 changed = True
while changed: while changed:
changed = False changed = False
for node in range(len(graph.nodes)): for node, incident in _incident_index(graph).items():
incident = graph.incident(node) if len(incident) != 2:
if len(incident) != 2 or graph.degree(node) != 2:
continue continue
first, second = (graph.edges[i] for i in incident) first, second = (graph.edges[i] for i in incident)
points = _join(first, second, graph.nodes[node].point) points = _join(first, second, graph.nodes[node].point)
@@ -640,10 +695,11 @@ def _contract_short(graph: Graph) -> None:
changed = True changed = True
while changed: while changed:
changed = False changed = False
grade = _degrees(graph)
for k, edge in enumerate(graph.edges): for k, edge in enumerate(graph.edges):
if edge.a == edge.b or _length(edge) >= CONTRACT_MAX: if edge.a == edge.b or _length(edge) >= CONTRACT_MAX:
continue continue
if graph.degree(edge.a) < 3 or graph.degree(edge.b) < 3: if grade[edge.a] < 3 or grade[edge.b] < 3:
continue continue
ax, ay = graph.nodes[edge.a].point ax, ay = graph.nodes[edge.a].point
bx, by = graph.nodes[edge.b].point bx, by = graph.nodes[edge.b].point