|
4 | 4 | import duckdb |
5 | 5 | from datetime import datetime |
6 | 6 | from itertools import groupby |
7 | | -from operator import itemgetter |
8 | 7 |
|
9 | 8 |
|
10 | 9 | class Database: |
@@ -53,6 +52,22 @@ def init(self): |
53 | 52 | CREATE INDEX IF NOT EXISTS idx_positions_time_geo |
54 | 53 | ON positions (timestamp, latitude, longitude) |
55 | 54 | """) |
| 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 | + """) |
56 | 71 |
|
57 | 72 | # Migration: add geom column to existing tables that lack it |
58 | 73 | cols = cur.execute( |
@@ -134,6 +149,53 @@ def get_latest_positions( |
134 | 149 | rows = self._cursor().execute(query, [after, limit]).fetchall() |
135 | 150 | return [self._row_to_dict(r) for r in rows] |
136 | 151 |
|
| 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 | + |
137 | 199 | def get_nearby_vehicles( |
138 | 200 | self, |
139 | 201 | lat: float, |
@@ -225,72 +287,70 @@ def get_coverage_trails( |
225 | 287 | self, |
226 | 288 | since: datetime, |
227 | 289 | until: datetime, |
228 | | - min_interval_s: float = 30.0, |
229 | | - max_gap_s: float = 120.0, |
230 | 290 | ) -> 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 | + """ |
232 | 297 | 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 |
240 | 334 | """ |
241 | 335 | rows = self._cursor().execute(query, [since, until]).fetchall() |
242 | 336 |
|
243 | 337 | 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])): |
245 | 339 | points = list(group) |
246 | 340 | if len(points) < 2: |
247 | 341 | 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 | + ) |
294 | 354 |
|
295 | 355 | return trails |
296 | 356 |
|
@@ -339,5 +399,24 @@ def get_stats(self) -> dict: |
339 | 399 | result["latest"] = row[1] |
340 | 400 | return result |
341 | 401 |
|
| 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 | + |
342 | 421 | def close(self): |
343 | 422 | self.conn.close() |
0 commit comments