diff --git a/docs/superpowers/plans/2026-09-16-strassen.md b/docs/superpowers/plans/2026-09-16-strassen.md index 27d12d9..f261e9b 100644 --- a/docs/superpowers/plans/2026-09-16-strassen.md +++ b/docs/superpowers/plans/2026-09-16-strassen.md @@ -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 - 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 + """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): + 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