Skip to content

Commit 6c8d60f

Browse files
committed
cache 24hr coverage in 5min ranges, heatmap perf
1 parent 7f7db42 commit 6c8d60f

2 files changed

Lines changed: 65 additions & 12 deletions

File tree

src/where_the_plow/routes.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,40 @@ def is_limited(self, key: str) -> bool:
4242
) # 6 searches per min per IP
4343

4444

45+
# ── In-memory coverage cache for recent (non-historical) queries ──────
46+
47+
_COVERAGE_TTL = 5 * 60 # 5 minutes — matches frontend rounding interval
48+
_COVERAGE_MAX = 20 # max entries before evicting oldest
49+
50+
# key = (since_iso, until_iso, source|None) -> (expires_at_monotonic, trails)
51+
_coverage_cache: dict[tuple, tuple[float, list[dict]]] = {}
52+
53+
54+
def _coverage_cache_get(
55+
since: datetime, until: datetime, source: str | None
56+
) -> list[dict] | None:
57+
key = (since.isoformat(), until.isoformat(), source)
58+
entry = _coverage_cache.get(key)
59+
if entry is None:
60+
return None
61+
expires_at, trails = entry
62+
if time.monotonic() > expires_at:
63+
del _coverage_cache[key]
64+
return None
65+
return trails
66+
67+
68+
def _coverage_cache_put(
69+
since: datetime, until: datetime, source: str | None, trails: list[dict]
70+
) -> None:
71+
key = (since.isoformat(), until.isoformat(), source)
72+
# Evict oldest if full
73+
if len(_coverage_cache) >= _COVERAGE_MAX and key not in _coverage_cache:
74+
oldest_key = min(_coverage_cache, key=lambda k: _coverage_cache[k][0])
75+
del _coverage_cache[oldest_key]
76+
_coverage_cache[key] = (time.monotonic() + _COVERAGE_TTL, trails)
77+
78+
4579
# ── Nominatim search proxy with in-memory cache ──────
4680

4781
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
@@ -334,16 +368,21 @@ def get_coverage(
334368
if until is None:
335369
until = now
336370

337-
# Check file cache (only hits for fully-historical queries without source filter)
338-
if source is None:
339-
cached = cache.get(since, until)
340-
if cached is not None:
341-
trails = cached
342-
else:
343-
trails = db.get_coverage_trails(since=since, until=until)
344-
cache.put(since, until, trails)
345-
else:
346-
trails = db.get_coverage_trails(since=since, until=until, source=source)
371+
# 1. In-memory cache (short TTL, works for recent/live queries)
372+
trails = _coverage_cache_get(since, until, source)
373+
if trails is None:
374+
# 2. File cache (historical queries only, no source filter)
375+
if source is None:
376+
trails = cache.get(since, until)
377+
if trails is None:
378+
trails = db.get_coverage_trails(
379+
since=since, until=until, **({"source": source} if source else {})
380+
)
381+
# Populate file cache for historical queries
382+
if source is None:
383+
cache.put(since, until, trails)
384+
# Populate in-memory cache for all queries
385+
_coverage_cache_put(since, until, source, trails)
347386

348387
features = [
349388
CoverageFeature(

src/where_the_plow/static/app.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,12 @@ plowMap.on("moveend", () => {
724724
/* ── Utilities ─────────────────────────────────────── */
725725

726726
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
727+
const FIVE_MIN_MS = 5 * 60 * 1000;
728+
729+
/** Round a Date down to the nearest 5-minute boundary. */
730+
function floorTo5Min(date) {
731+
return new Date(Math.floor(date.getTime() / FIVE_MIN_MS) * FIVE_MIN_MS);
732+
}
727733
const VEHICLE_STALE_MS = 2 * 60 * 60 * 1000; // hide vehicles not seen in 2 hours
728734
const SOURCE_STALE_MS = 30 * 60 * 1000; // warn if source has no data in 30 minutes
729735

@@ -1433,6 +1439,10 @@ class PlowApp {
14331439
this.stopPlayback();
14341440
const signal = this.map.newCoverageSignal();
14351441

1442+
// Round to 5-minute boundaries so repeat loads hit the backend cache
1443+
since = floorTo5Min(since);
1444+
until = floorTo5Min(until);
1445+
14361446
this.coverageSince = since;
14371447
this.coverageUntil = until;
14381448
this.updateRangeLabel();
@@ -1474,8 +1484,10 @@ class PlowApp {
14741484

14751485
async loadCoverageForDate(dateStr) {
14761486
const start = new Date(dateStr + "T00:00:00");
1477-
const end = new Date(dateStr + "T23:59:59");
1478-
await this.loadCoverageForRange(start, end);
1487+
// Use next day midnight so the 5-min floor still covers the full day
1488+
const nextDay = new Date(start);
1489+
nextDay.setDate(nextDay.getDate() + 1);
1490+
await this.loadCoverageForRange(start, nextDay);
14791491
}
14801492

14811493
switchCoverageView(view) {
@@ -1587,6 +1599,8 @@ class PlowApp {
15871599
[249, 115, 22],
15881600
[239, 68, 68],
15891601
],
1602+
weightsTextureSize: 512,
1603+
debounceTimeout: 100,
15901604
}),
15911605
]);
15921606
}

0 commit comments

Comments
 (0)