Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions bhulan/analytics/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Stop,
StopScanBudgetExceeded,
_MAX_STOP_SCAN_WORK,
_PROGRESSIVE_DRIFT_FRACTION,
detect_stops,
merge_nearby_stops,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 13 additions & 3 deletions bhulan/analytics/stops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
8 changes: 5 additions & 3 deletions spec/adrs/0014-detect-stops-rejects-progressive-movement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/adversary/test_detect_stops_rejects_progressive_walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading