diff --git a/bhulan/analytics/insights.py b/bhulan/analytics/insights.py index ed67bfb..86775d2 100644 --- a/bhulan/analytics/insights.py +++ b/bhulan/analytics/insights.py @@ -28,6 +28,7 @@ Stop, StopScanBudgetExceeded, _MAX_STOP_SCAN_WORK, + _PROGRESSIVE_DRIFT_FRACTION, detect_stops, merge_nearby_stops, ) @@ -103,6 +104,19 @@ class InsightsOptions(BaseModel): DEFAULT_MIN_DURATION_S / 60.0, gt=0, le=24 * 60, description="Minutes" ) moving_speed_kmh: float = Field(3.6, ge=0, le=300, description="Speed threshold for 'moving'") + progressive_drift_fraction: float = Field( + _PROGRESSIVE_DRIFT_FRACTION, + gt=0, + le=10, + description=( + "Walk-vs-dwell sensitivity for stop detection. A cluster is treated " + "as movement (a walk/drive), not a stop, when its first-half-to-" + "second-half centroid drift exceeds this fraction of stop_radius_m. " + "Default 0.5 cleanly separates a directional fill of the radius from " + "random GPS jitter; lower rejects movement more aggressively, and a " + "large value effectively disables the check." + ), + ) merge_stops_within_m: Optional[float] = Field( None, ge=0, @@ -466,6 +480,7 @@ def compute_insights(request: InsightsRequest) -> InsightsReport: # drift, or a same-timestamp mass) can't tie up a worker — see ADR # 0016. A real track does far less work than this absolute cap. max_scan_work=_MAX_STOP_SCAN_WORK, + progressive_drift_fraction=opts.progressive_drift_fraction, ) except StopScanBudgetExceeded: # The input is too dense/degenerate for stop detection within budget. diff --git a/bhulan/analytics/stops.py b/bhulan/analytics/stops.py index 3497c76..857dd93 100644 --- a/bhulan/analytics/stops.py +++ b/bhulan/analytics/stops.py @@ -167,7 +167,12 @@ def _cluster_end( def _is_progressive_translation( - xs: np.ndarray, ys: np.ndarray, i: int, end: int, radius_m: float + xs: np.ndarray, + ys: np.ndarray, + i: int, + end: int, + radius_m: float, + drift_fraction: float = _PROGRESSIVE_DRIFT_FRACTION, ) -> bool: """True if samples ``i..end`` progressively translate (a walk/drive) rather than dwell around a fixed centre. @@ -179,6 +184,10 @@ def _is_progressive_translation( lasts longer than ``min_duration_s`` is chopped into ``radius_m``-sized chunks and each is reported as a phantom stop. A cluster of fewer than four samples is too short to judge direction and is treated as a dwell. + + ``drift_fraction`` is the sensitivity: a cluster is movement when its + half-to-half drift exceeds ``drift_fraction * radius_m``. Lower rejects more + aggressively; a large value effectively disables the check. """ m = end - i + 1 if m < 4: @@ -187,7 +196,7 @@ def _is_progressive_translation( fx1, fy1 = float(np.mean(xs[i:mid])), float(np.mean(ys[i:mid])) fx2, fy2 = float(np.mean(xs[mid : end + 1])), float(np.mean(ys[mid : end + 1])) drift = math.hypot(fx2 - fx1, fy2 - fy1) - return drift > _PROGRESSIVE_DRIFT_FRACTION * radius_m + return drift > drift_fraction * radius_m def detect_stops( @@ -196,6 +205,7 @@ def detect_stops( min_duration_s: float = DEFAULT_MIN_DURATION_S, split_gap_s: float = DEFAULT_SPLIT_GAP_S, max_scan_work: Optional[int] = None, + progressive_drift_fraction: float = _PROGRESSIVE_DRIFT_FRACTION, ) -> List[Stop]: """ Return chronologically ordered stops found in the track. @@ -257,7 +267,7 @@ def detect_stops( i = end + 1 continue if duration >= min_duration_s and not _is_progressive_translation( - xs, ys, i, end, radius_m + xs, ys, i, end, radius_m, progressive_drift_fraction ): xs_c = xs[i : end + 1] ys_c = ys[i : end + 1] diff --git a/spec/adrs/0014-detect-stops-rejects-progressive-movement.md b/spec/adrs/0014-detect-stops-rejects-progressive-movement.md index f933fd1..333ddb7 100644 --- a/spec/adrs/0014-detect-stops-rejects-progressive-movement.md +++ b/spec/adrs/0014-detect-stops-rejects-progressive-movement.md @@ -46,9 +46,11 @@ verified O(n) on a 16 000-sample walk. - `_PROGRESSIVE_DRIFT_FRACTION = 0.5` is a **tunable** threshold. 0.5 cleanly separates a directional fill of the radius (drift ≈ radius) from random jitter (drift → 0). A lower value rejects more aggressively (risking dropping a dwell - that slowly shifts within its radius); a higher value is more permissive. Left - as a module constant for now; could become an `InsightsOptions` knob if callers - need per-request control. + that slowly shifts within its radius); a higher value is more permissive. + **Exposed as `InsightsOptions.progressive_drift_fraction`** (default 0.5, + `gt=0, le=10`) so callers can tune the walk-vs-dwell sensitivity per request — + a large value effectively disables the filter. The module constant remains the + default for direct `detect_stops` callers. - **Interaction with the merge cap ([[0013]]).** Progressive-rejection is the *primary* defence — a walk never becomes stops, so it never reaches the merge. The cap remains correct as defence-in-depth for genuine nearby dwells, but is diff --git a/tests/adversary/test_detect_stops_rejects_progressive_walk.py b/tests/adversary/test_detect_stops_rejects_progressive_walk.py index 5541b7f..87fdf06 100644 --- a/tests/adversary/test_detect_stops_rejects_progressive_walk.py +++ b/tests/adversary/test_detect_stops_rejects_progressive_walk.py @@ -76,3 +76,41 @@ def test_jittery_dwell_is_still_reported_as_one_stop(client: TestClient): f"{len(stops)} — the progressive-movement filter must not reject a " f"genuine dwell" ) + + +def test_progressive_drift_fraction_option_tunes_walk_rejection(client: TestClient): + # The same steady walk: at the default it is rejected (0 stops); a large + # progressive_drift_fraction relaxes the filter so the chunks are kept, and + # a value at/above the disabling range must be accepted by validation. + walk = {"points": _walk(n=40, step_m=5.0, dt_s=30.0)} + + default = client.post( + "/v1/insights", + json={**walk, "options": {"stop_radius_m": 50.0, "min_stop_minutes": 5.0}}, + ) + assert default.status_code == 200 + assert len(default.json()["stops"]) == 0, "default (0.5) must reject the walk" + + relaxed = client.post( + "/v1/insights", + json={ + **walk, + "options": { + "stop_radius_m": 50.0, + "min_stop_minutes": 5.0, + "progressive_drift_fraction": 10.0, + }, + }, + ) + assert relaxed.status_code == 200 + assert len(relaxed.json()["stops"]) > 0, ( + "a large progressive_drift_fraction must relax the walk-vs-dwell filter " + "so the walk's chunks are no longer rejected" + ) + + # Out-of-range values are a clean validation error. + bad = client.post( + "/v1/insights", + json={**walk, "options": {"progressive_drift_fraction": 0.0}}, + ) + assert bad.status_code == 422