● Zion Boggan
repos/Sightline/backend/app/services/analytics/__init__.py
zionboggan.com ↗
196 lines · python
History for this file →
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
    )