You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The warm run is now dominated by graph deserialization (~1 s) and A* routing (~0.04 s). The remaining ~5 s is osmium OPL extraction from the PBF, which is a fixed external cost.
214
+
*Cold run went from 16 s → 37 s because we moved from a tile-level msgpack cache to a bbox-level cache. The first cold run for a new bbox still needs osmium extraction (~29 s). This is expected — the win is on warm runs.*
215
+
216
+
The warm run is now dominated by graph deserialization (~1 s) and weight preparation (~2.5 s). A* routing itself is ~40 ms — negligible.
215
217
216
218
### Test Route Details
217
219
@@ -224,45 +226,101 @@ All benchmarks use the same real-world test case:
*Weighted graph cache skips `PrepareWeights` entirely on repeat requests. The 1.3 s msgpack deserialization is now the bottleneck.*
237
242
238
243
The A* router explores ~130k nodes (26.6% of the 490k-node graph) to find the 62 km adventurous path. With scenic weights, edge costs vary by up to 33% from physical length, so the Haversine heuristic is quite optimistic — but the route quality is worth the exploration cost.
239
244
245
+
### Optimization history
246
+
247
+
**Round 1 — correctness + quick wins:**
248
+
- Dense node index (`[]float64` gScore instead of `map[int64]float64`)
249
+
- Parallel `PrepareWeights` (8 goroutines, 23 s → 4.5 s)
1.**Grid-based scenic precomputation.** Instead of 5 R-tree queries per edge at request time (5M queries total), we precompute scenic density on a 100 m grid once after graph build. `PrepareWeights` becomes a single bilinear lookup per edge. Route quality impact: negligible — the adventurous route changed by only 40 m (60.16 km vs 60.20 km).
256
+
257
+
2.**Skip scenic weights for base route.** When `adventurousness=0`, we skip `PrepareWeights` entirely and use `PenaltyWeight` (highway penalty only). In compare mode this saves the full weight-prep time for the base route.
258
+
259
+
3.**Nearest-node R-tree.**`nearestNode()` was a linear scan over all 490k nodes. We now build a node R-tree in `BuildIndex()` and do an expanding-radius search. Cuts nearest-node time from ~0.2 s to ~1 ms.
260
+
261
+
**Round 3 — caching layers + visualization:**
262
+
263
+
4.**Route result cache.** Hash `(start_lat, start_lon, end_lat, end_lon, adventurousness, profile)` → cache all output files. Repeat queries: **<100 ms**. Invalidated when PBF or code changes.
264
+
265
+
5.**Weighted graph cache.** Cache `edge.Weight` values after `PrepareWeights` (~9 MB msgpack). On hit, skip the entire 1.5 s scenic scoring step. Key includes graph hash + scoring params.
266
+
267
+
6.**Bidirectional A*.** Search from both ends simultaneously. On a 490k-node graph: **2.2× faster** (66 ms → 30 ms per route). Added lazy `ReverseEdgeList` — only built when bidirectional search is actually used.
268
+
269
+
7.**PNG candidate maps.** Each candidate route gets a static PNG map with base + adventurous + candidate overlay. Viewer shows "🗺️ View map" links per candidate.
270
+
271
+
### The Native PBF Parser Experiment (What We Learned)
272
+
273
+
We tried replacing the osmium subprocess with a native Go PBF parser using `github.com/paulmach/osm`. The parser worked for basic routing (~14 s, 2× faster than osmium) but **failed to extract scenic data**:
**Why:** Osmium has a C++ spatial index that can look up any node by ID in O(1) when resolving way geometries. Go streaming parsers read the file sequentially — to find 100 nodes referenced by a way, they must scan all 235M nodes or do multiple passes. Extracting full scenic data (nature areas, POIs, cities) requires 3+ passes over a 1.9 GB file, which exceeds 5 minutes.
284
+
285
+
**Decision:** Osmium stays as default. Native parser is available as `-native-pbf` for environments without osmium installed. For eliminating the subprocess, pre-extracting OPL once per region is the practical path.
286
+
287
+
### The Profiling Lesson
288
+
289
+
We added graph pruning (removing disconnected components) and `ReverseEdgeList` building to `BuildIndex()`, thinking they were "free" optimizations. Profiling revealed they added **2–3 seconds to every single run**:
290
+
291
+
- Pruning BFS on 490k nodes: ~2 s per run
292
+
- ReverseEdgeList scan over 1M edges: ~1 s per run
293
+
- Combined: warm run went from 2.9 s → 5.5 s
294
+
295
+
**Rule:** Never add O(n) or O(m) work to the hot path without measuring. Both were reverted. Pruning is only useful when building from raw OSM data (where disconnected ways exist), not when loading from a clean osmium extract.
296
+
240
297
### What the Go rewrite replaced
241
298
242
299
| Component | Python | Go |
243
300
|---|---|---|
244
301
| Parser | Custom OPL parser | Same, rewritten |
245
302
| Graph | NetworkX MultiDiGraph | Custom adjacency list + dense index |
246
303
| Spatial index | Shapely STRtree |`tidwall/rtree`|
The remaining warm-run time is dominated by `PrepareWeights` (~4.5 s). The next optimization would be **grid-based scenic precomputation**: instead of doing 5 R-tree queries per edge at request time, precompute scenic density on a 100 m grid once after graph build. Edge weights become simple grid lookups. This would cut warm runs from ~6 s to ~2 s.
256
-
257
-
Other possibilities:
258
-
- Native Go PBF parser (eliminate 8 s osmium subprocess on cold runs)
259
-
- Skip scenic weight prep entirely for base/shortest routes
260
-
- k-d tree for nearest-node lookup (eliminates 490k-node linear scans)
314
+
-**Bidirectional A* as default** — 2.2× routing speedup, zero overhead. Wire into CLI.
315
+
-**Skip non-road ways during parse** — filter buildings/barriers during OPL build. Estimated 60% graph shrink.
-**WebSocket/SSE progress** — replace polling with server-sent events for real-time job progress.
318
+
-**Native PBF parser** — available as `-native-pbf` for environments without osmium. Not default.
261
319
262
320
## Summary
263
321
264
322
The core idea is simple: **interesting cycling happens at the boundaries between ecosystems**. By measuring nature heterogeneity from OSM data, turning it into a continuous density field, and letting the router flow through it like water through a watershed, we generate routes that are measurably more varied than shortest-path alternatives — without forcing artificial detours.
265
323
266
-
The algorithm is fully offline (no APIs), runs in **~6 seconds on a warm cache** (was ~60 s in Python), and produces routes that cyclists actually want to ride.
324
+
The algorithm is fully offline (no APIs), runs in **~2.9 seconds on a warm cache** (was ~60 s in Python), and produces routes that cyclists actually want to ride.
267
325
268
326
<!-- TODO: screenshot — the "Plan a route" panel in viewer.html. Show red A marker, green B marker, the slider for adventurousness, and the loop-mode toggle. Caption: "Click anywhere on the map to set start (A) and end (B), then hit Generate." -->
0 commit comments