| 1 | """ALPR movement intelligence. Pure functions over read dicts so they are unit-testable |
| 2 | without a database. A 'read' is a dict with at least: |
| 3 | {plate_norm, read_ts (datetime, tz-aware), camera_id, lat, lon, confidence} |
| 4 | """ |
| 5 | from __future__ import annotations |
| 6 | |
| 7 | import math |
| 8 | from collections import Counter, defaultdict |
| 9 | from dataclasses import dataclass, field |
| 10 | from datetime import datetime |
| 11 | |
| 12 | |
| 13 | def haversine_km(lat1, lon1, lat2, lon2) -> float | None: |
| 14 | if None in (lat1, lon1, lat2, lon2): |
| 15 | return None |
| 16 | r = 6371.0 |
| 17 | p1, p2 = math.radians(lat1), math.radians(lat2) |
| 18 | dphi = math.radians(lat2 - lat1) |
| 19 | dlam = math.radians(lon2 - lon1) |
| 20 | a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlam / 2) ** 2 |
| 21 | return 2 * r * math.asin(math.sqrt(a)) |
| 22 | |
| 23 | |
| 24 | def first_last_seen(reads: list[dict]) -> dict: |
| 25 | if not reads: |
| 26 | return {"first_seen": None, "last_seen": None, "count": 0} |
| 27 | ts = sorted(r["read_ts"] for r in reads) |
| 28 | return {"first_seen": ts[0], "last_seen": ts[-1], "count": len(reads)} |
| 29 | |
| 30 | |
| 31 | def sightings_by_camera(reads: list[dict]) -> list[dict]: |
| 32 | c = Counter(r.get("camera_id") for r in reads) |
| 33 | return [{"camera_id": k, "count": v} for k, v in c.most_common()] |
| 34 | |
| 35 | |
| 36 | def frequency_by_hour(reads: list[dict]) -> dict[int, int]: |
| 37 | """Time-of-day histogram - surfaces home/work anchor patterns.""" |
| 38 | hist: dict[int, int] = defaultdict(int) |
| 39 | for r in reads: |
| 40 | hist[r["read_ts"].hour] += 1 |
| 41 | return dict(sorted(hist.items())) |
| 42 | |
| 43 | |
| 44 | @dataclass |
| 45 | class RouteLeg: |
| 46 | from_camera: str | None |
| 47 | to_camera: str | None |
| 48 | depart_ts: datetime |
| 49 | arrive_ts: datetime |
| 50 | elapsed_s: float |
| 51 | distance_km: float | None |
| 52 | speed_kmh: float | None |
| 53 | implausible: bool |
| 54 | |
| 55 | |
| 56 | def reconstruct_route(reads: list[dict], cameras: dict[str, dict] | None = None, |
| 57 | impossible_speed_kmh: float = 250.0) -> list[RouteLeg]: |
| 58 | """Order reads by time and emit camera->camera legs with elapsed time, distance, speed. |
| 59 | `cameras` maps camera_id -> {lat, lon} for distance when reads lack coordinates.""" |
| 60 | cameras = cameras or {} |
| 61 | ordered = sorted(reads, key=lambda r: r["read_ts"]) |
| 62 | legs: list[RouteLeg] = [] |
| 63 | for a, b in zip(ordered, ordered[1:]): |
| 64 | elapsed = (b["read_ts"] - a["read_ts"]).total_seconds() |
| 65 | la, lo = _coord(a, cameras) |
| 66 | lb, lob = _coord(b, cameras) |
| 67 | dist = haversine_km(la, lo, lb, lob) |
| 68 | speed = (dist / (elapsed / 3600.0)) if (dist is not None and elapsed > 0) else None |
| 69 | legs.append(RouteLeg( |
| 70 | from_camera=a.get("camera_id"), to_camera=b.get("camera_id"), |
| 71 | depart_ts=a["read_ts"], arrive_ts=b["read_ts"], elapsed_s=elapsed, |
| 72 | distance_km=dist, speed_kmh=speed, |
| 73 | implausible=bool(speed is not None and speed > impossible_speed_kmh), |
| 74 | )) |
| 75 | return legs |
| 76 | |
| 77 | |
| 78 | def _coord(read: dict, cameras: dict[str, dict]): |
| 79 | if read.get("lat") is not None and read.get("lon") is not None: |
| 80 | return read["lat"], read["lon"] |
| 81 | cam = cameras.get(read.get("camera_id")) |
| 82 | if cam: |
| 83 | return cam.get("lat"), cam.get("lon") |
| 84 | return None, None |
| 85 | |
| 86 | |
| 87 | @dataclass |
| 88 | class DwellEpisode: |
| 89 | camera_id: str | None |
| 90 | start_ts: datetime |
| 91 | end_ts: datetime |
| 92 | duration_s: float |
| 93 | reads: int |
| 94 | |
| 95 | |
| 96 | def detect_dwell(reads: list[dict], gap_seconds: int = 1800) -> list[DwellEpisode]: |
| 97 | """Consecutive reads at the same camera within `gap_seconds` form a dwell episode.""" |
| 98 | ordered = sorted(reads, key=lambda r: r["read_ts"]) |
| 99 | episodes: list[DwellEpisode] = [] |
| 100 | cur: list[dict] = [] |
| 101 | for r in ordered: |
| 102 | if not cur: |
| 103 | cur = [r] |
| 104 | continue |
| 105 | same_cam = r.get("camera_id") == cur[-1].get("camera_id") |
| 106 | within = (r["read_ts"] - cur[-1]["read_ts"]).total_seconds() <= gap_seconds |
| 107 | if same_cam and within: |
| 108 | cur.append(r) |
| 109 | else: |
| 110 | episodes.append(_episode(cur)) |
| 111 | cur = [r] |
| 112 | if cur: |
| 113 | episodes.append(_episode(cur)) |
| 114 | return [e for e in episodes if e.reads > 1 or e.duration_s > 0] |
| 115 | |
| 116 | |
| 117 | def _episode(group: list[dict]) -> DwellEpisode: |
| 118 | start, end = group[0]["read_ts"], group[-1]["read_ts"] |
| 119 | return DwellEpisode(camera_id=group[0].get("camera_id"), start_ts=start, end_ts=end, |
| 120 | duration_s=(end - start).total_seconds(), reads=len(group)) |
| 121 | |
| 122 | |
| 123 | def camera_transitions(reads_by_vehicle: dict[str, list[dict]]) -> list[dict]: |
| 124 | """Directed camera->camera transition graph aggregated across vehicles.""" |
| 125 | edges: Counter = Counter() |
| 126 | for reads in reads_by_vehicle.values(): |
| 127 | ordered = sorted(reads, key=lambda r: r["read_ts"]) |
| 128 | for a, b in zip(ordered, ordered[1:]): |
| 129 | if a.get("camera_id") and b.get("camera_id") and a["camera_id"] != b["camera_id"]: |
| 130 | edges[(a["camera_id"], b["camera_id"])] += 1 |
| 131 | return [{"from": f, "to": t, "weight": w} for (f, t), w in edges.most_common()] |
| 132 | |
| 133 | |
| 134 | def find_cotravel(reads_by_vehicle: dict[str, list[dict]], target_plate: str, |
| 135 | window_seconds: int = 120, min_shared: int = 2) -> list[dict]: |
| 136 | """Vehicles repeatedly seen at the same camera within `window_seconds` of the target.""" |
| 137 | target = reads_by_vehicle.get(target_plate, []) |
| 138 | results: list[dict] = [] |
| 139 | for plate, reads in reads_by_vehicle.items(): |
| 140 | if plate == target_plate: |
| 141 | continue |
| 142 | shared = 0 |
| 143 | for tr in target: |
| 144 | for r in reads: |
| 145 | if r.get("camera_id") == tr.get("camera_id") and \ |
| 146 | abs((r["read_ts"] - tr["read_ts"]).total_seconds()) <= window_seconds: |
| 147 | shared += 1 |
| 148 | break |
| 149 | if shared >= min_shared: |
| 150 | results.append({"plate_norm": plate, "shared_sightings": shared}) |
| 151 | return sorted(results, key=lambda x: x["shared_sightings"], reverse=True) |
| 152 | |
| 153 | |
| 154 | def possible_duplicate_plates(reads: list[dict], impossible_speed_kmh: float = 250.0, |
| 155 | cameras: dict[str, dict] | None = None) -> list[dict]: |
| 156 | """Impossible-travel detector: legs faster than physically plausible suggest a misread |
| 157 | or a cloned/duplicated plate.""" |
| 158 | flags = [] |
| 159 | for leg in reconstruct_route(reads, cameras, impossible_speed_kmh): |
| 160 | if leg.implausible: |
| 161 | flags.append({ |
| 162 | "from_camera": leg.from_camera, "to_camera": leg.to_camera, |
| 163 | "depart_ts": leg.depart_ts, "arrive_ts": leg.arrive_ts, |
| 164 | "speed_kmh": round(leg.speed_kmh, 1) if leg.speed_kmh else None, |
| 165 | "reason": "impossible_travel_speed", |
| 166 | }) |
| 167 | return flags |
| 168 | |
| 169 | |
| 170 | @dataclass |
| 171 | class MovementSummary: |
| 172 | plate_norm: str |
| 173 | first_seen: datetime | None |
| 174 | last_seen: datetime | None |
| 175 | total_reads: int |
| 176 | distinct_cameras: int |
| 177 | by_camera: list[dict] = field(default_factory=list) |
| 178 | by_hour: dict[int, int] = field(default_factory=dict) |
| 179 | route: list[RouteLeg] = field(default_factory=list) |
| 180 | dwell: list[DwellEpisode] = field(default_factory=list) |
| 181 | anomalies: list[dict] = field(default_factory=list) |
| 182 | |
| 183 | |
| 184 | def movement_summary(plate_norm: str, reads: list[dict], cameras: dict[str, dict] | None = None, |
| 185 | impossible_speed_kmh: float = 250.0, dwell_gap_seconds: int = 1800) -> MovementSummary: |
| 186 | fl = first_last_seen(reads) |
| 187 | return MovementSummary( |
| 188 | plate_norm=plate_norm, |
| 189 | first_seen=fl["first_seen"], last_seen=fl["last_seen"], total_reads=fl["count"], |
| 190 | distinct_cameras=len({r.get("camera_id") for r in reads if r.get("camera_id")}), |
| 191 | by_camera=sightings_by_camera(reads), |
| 192 | by_hour=frequency_by_hour(reads), |
| 193 | route=reconstruct_route(reads, cameras, impossible_speed_kmh), |
| 194 | dwell=detect_dwell(reads, dwell_gap_seconds), |
| 195 | anomalies=possible_duplicate_plates(reads, impossible_speed_kmh, cameras), |
| 196 | ) |