Skip to content

Commit ae41e5f

Browse files
committed
a bunch of thing
1 parent 52d9350 commit ae41e5f

15 files changed

Lines changed: 1952 additions & 1267 deletions

File tree

docs/plans/2026-02-21-plowmap-plowapp-classes.md

Lines changed: 431 additions & 0 deletions
Large diffs are not rendered by default.

src/where_the_plow/cache.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# src/where_the_plow/cache.py
2+
"""Simple file-based cache for coverage trail responses.
3+
4+
Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of the
5+
(since, until) time range. Only caches queries whose `until` is
6+
before today (i.e. fully historical, immutable data). Uses LRU
7+
eviction by file access time when total cache size exceeds a budget.
8+
"""
9+
10+
import hashlib
11+
import json
12+
import logging
13+
import os
14+
import tempfile
15+
from datetime import datetime, timezone
16+
from pathlib import Path
17+
18+
logger = logging.getLogger(__name__)
19+
20+
CACHE_DIR = Path(tempfile.gettempdir()) / "where-the-plow-cache"
21+
MAX_CACHE_BYTES = 200 * 1024 * 1024 # 200 MB
22+
23+
24+
def _cache_key(since: datetime, until: datetime) -> str:
25+
raw = f"{since.isoformat()}|{until.isoformat()}"
26+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
27+
28+
29+
def _is_cacheable(until: datetime) -> bool:
30+
"""Only cache if the entire window is in the past (before today UTC)."""
31+
today_start = datetime.now(timezone.utc).replace(
32+
hour=0, minute=0, second=0, microsecond=0
33+
)
34+
until_utc = until if until.tzinfo else until.replace(tzinfo=timezone.utc)
35+
return until_utc < today_start
36+
37+
38+
def _ensure_dir():
39+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
40+
41+
42+
def _evict_if_needed():
43+
"""Delete oldest-accessed files until total size is under budget."""
44+
try:
45+
files = list(CACHE_DIR.glob("*.json"))
46+
if not files:
47+
return
48+
total = sum(f.stat().st_size for f in files)
49+
if total <= MAX_CACHE_BYTES:
50+
return
51+
# Sort by access time, oldest first
52+
files.sort(key=lambda f: f.stat().st_atime)
53+
for f in files:
54+
if total <= MAX_CACHE_BYTES:
55+
break
56+
size = f.stat().st_size
57+
f.unlink(missing_ok=True)
58+
total -= size
59+
logger.debug("cache evict: %s (%d bytes)", f.name, size)
60+
except OSError:
61+
pass
62+
63+
64+
def get(since: datetime, until: datetime) -> list[dict] | None:
65+
"""Return cached trails or None if not cached."""
66+
if not _is_cacheable(until):
67+
return None
68+
path = CACHE_DIR / f"{_cache_key(since, until)}.json"
69+
if not path.exists():
70+
return None
71+
try:
72+
# Touch access time for LRU
73+
os.utime(path)
74+
data = json.loads(path.read_text())
75+
logger.debug("cache hit: %s", path.name)
76+
return data
77+
except (OSError, json.JSONDecodeError):
78+
return None
79+
80+
81+
def put(since: datetime, until: datetime, trails: list[dict]):
82+
"""Store trails in cache if the query is cacheable."""
83+
if not _is_cacheable(until):
84+
return
85+
_ensure_dir()
86+
_evict_if_needed()
87+
path = CACHE_DIR / f"{_cache_key(since, until)}.json"
88+
try:
89+
path.write_text(json.dumps(trails))
90+
logger.debug("cache put: %s (%d trails)", path.name, len(trails))
91+
except OSError:
92+
pass

src/where_the_plow/collector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from where_the_plow.client import fetch_vehicles, parse_avl_response
99
from where_the_plow.db import Database
1010
from where_the_plow.config import settings
11+
from where_the_plow.snapshot import build_realtime_snapshot
1112

1213
logger = logging.getLogger(__name__)
1314

@@ -20,7 +21,7 @@ def process_poll(db: Database, response: dict) -> int:
2021
return inserted
2122

2223

23-
async def run(db: Database):
24+
async def run(db: Database, store: dict):
2425
logger.info("Collector starting — polling every %ds", settings.poll_interval)
2526

2627
stats = db.get_stats()
@@ -41,6 +42,7 @@ async def run(db: Database):
4142
len(features),
4243
inserted,
4344
)
45+
store["realtime"] = build_realtime_snapshot(db)
4446
except asyncio.CancelledError:
4547
logger.info("Collector shutting down")
4648
raise

src/where_the_plow/db.py

Lines changed: 137 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import duckdb
55
from datetime import datetime
66
from itertools import groupby
7-
from operator import itemgetter
87

98

109
class Database:
@@ -53,6 +52,22 @@ def init(self):
5352
CREATE INDEX IF NOT EXISTS idx_positions_time_geo
5453
ON positions (timestamp, latitude, longitude)
5554
""")
55+
cur.execute("""
56+
CREATE SEQUENCE IF NOT EXISTS viewports_seq
57+
""")
58+
cur.execute("""
59+
CREATE TABLE IF NOT EXISTS viewports (
60+
id BIGINT DEFAULT nextval('viewports_seq') PRIMARY KEY,
61+
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
62+
zoom DOUBLE NOT NULL,
63+
center_lng DOUBLE NOT NULL,
64+
center_lat DOUBLE NOT NULL,
65+
sw_lng DOUBLE NOT NULL,
66+
sw_lat DOUBLE NOT NULL,
67+
ne_lng DOUBLE NOT NULL,
68+
ne_lat DOUBLE NOT NULL
69+
)
70+
""")
5671

5772
# Migration: add geom column to existing tables that lack it
5873
cols = cur.execute(
@@ -134,6 +149,53 @@ def get_latest_positions(
134149
rows = self._cursor().execute(query, [after, limit]).fetchall()
135150
return [self._row_to_dict(r) for r in rows]
136151

152+
def get_latest_positions_with_trails(
153+
self, trail_points: int = 6, max_gap_s: int = 120
154+
) -> list[dict]:
155+
"""Get the latest position for each vehicle plus a mini-trail of recent coords.
156+
157+
Positions separated by more than max_gap_s seconds are treated as a
158+
discontinuity — the trail is truncated to only the contiguous segment
159+
ending at the most recent position.
160+
"""
161+
query = """
162+
WITH ranked AS (
163+
SELECT p.vehicle_id, p.timestamp, p.longitude, p.latitude,
164+
p.bearing, p.speed, p.is_driving,
165+
v.description, v.vehicle_type,
166+
ROW_NUMBER() OVER (PARTITION BY p.vehicle_id ORDER BY p.timestamp DESC) as rn
167+
FROM positions p
168+
JOIN vehicles v ON p.vehicle_id = v.vehicle_id
169+
)
170+
SELECT vehicle_id, timestamp, longitude, latitude, bearing, speed,
171+
is_driving, description, vehicle_type
172+
FROM ranked
173+
WHERE rn <= $1
174+
ORDER BY vehicle_id, timestamp ASC
175+
"""
176+
rows = self._cursor().execute(query, [trail_points]).fetchall()
177+
all_dicts = [self._row_to_dict(r) for r in rows]
178+
179+
# Group by vehicle_id: last row is current position, all rows form trail.
180+
# Walk backwards to find the contiguous segment (no gap > max_gap_s).
181+
results = []
182+
for _, group in groupby(all_dicts, key=lambda r: r["vehicle_id"]):
183+
points = list(group)
184+
# Find the start of the contiguous segment ending at the latest point
185+
start = len(points) - 1
186+
for i in range(len(points) - 1, 0, -1):
187+
gap = (
188+
points[i]["timestamp"] - points[i - 1]["timestamp"]
189+
).total_seconds()
190+
if gap > max_gap_s:
191+
break
192+
start = i - 1
193+
contiguous = points[start:]
194+
current = contiguous[-1] # most recent
195+
current["trail"] = [[p["longitude"], p["latitude"]] for p in contiguous]
196+
results.append(current)
197+
return results
198+
137199
def get_nearby_vehicles(
138200
self,
139201
lat: float,
@@ -225,72 +287,70 @@ def get_coverage_trails(
225287
self,
226288
since: datetime,
227289
until: datetime,
228-
min_interval_s: float = 30.0,
229-
max_gap_s: float = 120.0,
230290
) -> list[dict]:
231-
"""Get per-vehicle LineString trails in a time range, downsampled and gap-split."""
291+
"""Get per-vehicle LineString trails in a time range.
292+
293+
Uses SQL-side gap detection (>120s breaks a segment) and
294+
time_bucket downsampling (~1 point per 30s) to minimise
295+
the number of rows transferred to Python.
296+
"""
232297
query = """
233-
SELECT p.vehicle_id, p.timestamp, p.longitude, p.latitude,
234-
v.description, v.vehicle_type
235-
FROM positions p
236-
JOIN vehicles v ON p.vehicle_id = v.vehicle_id
237-
WHERE p.timestamp >= $1
238-
AND p.timestamp <= $2
239-
ORDER BY p.vehicle_id, p.timestamp ASC
298+
WITH with_gap AS (
299+
SELECT
300+
p.vehicle_id,
301+
p.timestamp,
302+
p.longitude,
303+
p.latitude,
304+
v.description,
305+
v.vehicle_type,
306+
EPOCH(p.timestamp - LAG(p.timestamp) OVER (
307+
PARTITION BY p.vehicle_id ORDER BY p.timestamp
308+
)) AS gap_s
309+
FROM positions p
310+
JOIN vehicles v ON p.vehicle_id = v.vehicle_id
311+
WHERE p.timestamp >= $1
312+
AND p.timestamp <= $2
313+
),
314+
with_segment AS (
315+
SELECT *,
316+
SUM(CASE WHEN gap_s IS NULL OR gap_s > 120 THEN 1 ELSE 0 END)
317+
OVER (PARTITION BY vehicle_id ORDER BY timestamp) AS segment_id
318+
FROM with_gap
319+
),
320+
bucketed AS (
321+
SELECT *,
322+
ROW_NUMBER() OVER (
323+
PARTITION BY vehicle_id, segment_id,
324+
time_bucket(INTERVAL '30 seconds', timestamp)
325+
ORDER BY timestamp
326+
) AS bucket_rn
327+
FROM with_segment
328+
)
329+
SELECT vehicle_id, segment_id, timestamp, longitude, latitude,
330+
description, vehicle_type
331+
FROM bucketed
332+
WHERE bucket_rn = 1
333+
ORDER BY vehicle_id, segment_id, timestamp
240334
"""
241335
rows = self._cursor().execute(query, [since, until]).fetchall()
242336

243337
trails = []
244-
for vid, group in groupby(rows, key=itemgetter(0)):
338+
for (vid, seg_id), group in groupby(rows, key=lambda r: (r[0], r[1])):
245339
points = list(group)
246340
if len(points) < 2:
247341
continue
248-
249-
# Downsample: keep first point, then skip until >= min_interval_s
250-
sampled = [points[0]]
251-
for pt in points[1:]:
252-
elapsed = (pt[1] - sampled[-1][1]).total_seconds()
253-
if elapsed >= min_interval_s:
254-
sampled.append(pt)
255-
# Always include the last point
256-
if sampled[-1] != points[-1]:
257-
sampled.append(points[-1])
258-
259-
if len(sampled) < 2:
260-
continue
261-
262-
# Split at gaps > max_gap_s
263-
segments = []
264-
current_segment = [sampled[0]]
265-
for pt in sampled[1:]:
266-
gap = (pt[1] - current_segment[-1][1]).total_seconds()
267-
if gap > max_gap_s:
268-
if len(current_segment) >= 2:
269-
segments.append(current_segment)
270-
current_segment = [pt]
271-
else:
272-
current_segment.append(pt)
273-
if len(current_segment) >= 2:
274-
segments.append(current_segment)
275-
276-
description = sampled[0][4]
277-
vehicle_type = sampled[0][5]
278-
279-
for seg in segments:
280-
trails.append(
281-
{
282-
"vehicle_id": vid,
283-
"description": description,
284-
"vehicle_type": vehicle_type,
285-
"coordinates": [[p[2], p[3]] for p in seg],
286-
"timestamps": [
287-
p[1].isoformat()
288-
if isinstance(p[1], datetime)
289-
else str(p[1])
290-
for p in seg
291-
],
292-
}
293-
)
342+
trails.append(
343+
{
344+
"vehicle_id": vid,
345+
"description": points[0][5],
346+
"vehicle_type": points[0][6],
347+
"coordinates": [[p[3], p[4]] for p in points],
348+
"timestamps": [
349+
p[2].isoformat() if isinstance(p[2], datetime) else str(p[2])
350+
for p in points
351+
],
352+
}
353+
)
294354

295355
return trails
296356

@@ -339,5 +399,24 @@ def get_stats(self) -> dict:
339399
result["latest"] = row[1]
340400
return result
341401

402+
def insert_viewport(
403+
self,
404+
zoom: float,
405+
center_lng: float,
406+
center_lat: float,
407+
sw_lng: float,
408+
sw_lat: float,
409+
ne_lng: float,
410+
ne_lat: float,
411+
):
412+
"""Record a user viewport focus event."""
413+
self._cursor().execute(
414+
"""
415+
INSERT INTO viewports (zoom, center_lng, center_lat, sw_lng, sw_lat, ne_lng, ne_lat)
416+
VALUES (?, ?, ?, ?, ?, ?, ?)
417+
""",
418+
[zoom, center_lng, center_lat, sw_lng, sw_lat, ne_lng, ne_lat],
419+
)
420+
342421
def close(self):
343422
self.conn.close()

src/where_the_plow/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ async def lifespan(app: FastAPI):
2525
db = Database(settings.db_path)
2626
db.init()
2727
app.state.db = db
28+
app.state.store = {}
2829
logger.info("Database initialized at %s", settings.db_path)
2930

30-
task = asyncio.create_task(collector.run(db))
31+
task = asyncio.create_task(collector.run(db, app.state.store))
3132
yield
3233
task.cancel()
3334
try:

src/where_the_plow/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class FeatureProperties(BaseModel):
2727
bearing: int | None = Field(None, description="Heading in degrees (0-360)")
2828
is_driving: str | None = Field(None, description="Driving status: 'maybe' or 'no'")
2929
timestamp: str = Field(..., description="Position timestamp (ISO 8601)")
30+
trail: list[list[float]] | None = Field(
31+
None, description="Recent trail coordinates [[lng, lat], ...]"
32+
)
3033

3134

3235
class Feature(BaseModel):
@@ -80,6 +83,20 @@ class CoverageFeatureCollection(BaseModel):
8083
features: list[CoverageFeature]
8184

8285

86+
class ViewportTrack(BaseModel):
87+
zoom: float = Field(..., description="Current map zoom level")
88+
center: list[float] = Field(
89+
...,
90+
description="[longitude, latitude] of map center",
91+
min_length=2,
92+
max_length=2,
93+
)
94+
bounds: dict = Field(
95+
...,
96+
description="Map bounds: {sw: [lng, lat], ne: [lng, lat]}",
97+
)
98+
99+
83100
class StatsResponse(BaseModel):
84101
total_positions: int = Field(..., description="Total position records stored")
85102
total_vehicles: int = Field(..., description="Total unique vehicles seen")

0 commit comments

Comments
 (0)