edge/engine.py _mk hardcoded duration_s=0.0 for every event. The §7.3
wire contract / P1-B dashboard expects the real incident duration for
no_motion, immobility, and ghost events.
_mk now derives duration_s from the metrics dict: immobility_s (fall
no_motion, immobility watchdog) or waited_s (ghost), falling back to 0.0
for instantaneous confirms (fall, zone).
Tests: tests/test_engine.py adds test_no_motion_event_carries_duration,
driving a track to NO_MOTION and asserting the emitted event's duration_s
is > 0 and equals metrics["immobility_s"].
edge/detectors/fall.py update(): a single frame where prone was false while
state was in {FALLING, FALLEN, NO_MOTION} immediately emitted "recover" and
reset _State. One jittery frame (a momentary pose-estimation miss) could
wipe an in-progress CRITICAL no_motion incident.
Add FallConfig.recover_grace_s (default 1.0s) and _State.not_prone_since.
On the first non-prone frame while in an incident state, start the grace
timer instead of recovering immediately; only reset to STANDING once
recover_grace_s of sustained non-prone has elapsed. A re-prone frame while
in an incident state clears the pending grace timer (cancels the recover
attempt). The non-incident STANDING path is unchanged.
BEHAVIOR CHANGE: recover now requires recover_grace_s of sustained
non-prone instead of firing on a single frame.
Tests: test_recovery_clears_state (tests/test_fall.py) strengthened per
review brief to assert a single standing frame does NOT recover but
sustained standing across the grace window does. Added
test_single_noisy_nonprone_frame_does_not_cancel_no_motion covering the
life-safety scenario: one noisy non-prone frame during NO_MOTION must not
cancel the incident.
edge/app.py run_once used ONE shared KeypointSmoother across all persons
BEFORE tracking assigned IDs. The smoother holds per-joint-index One-Euro
filter state, so with >1 person in frame the filter state was shared and
corrupted across different people, and same-timestamp calls within a frame
would pass through unsmoothed while still clobbering the shared state.
Multi-person scenes could get corrupted torso angles -> missed/false falls.
Add TrackSmootherPool (edge/smoothing.py) that lazily allocates one
KeypointSmoother per track_id. run_once now: tracks raw keypoints first,
then smooths each track's latest frame in place using its own pooled
smoother, and forgets a track's smoother when the track is lost (ties into
FIX 5 lost-track cleanup).
BEHAVIOR CHANGE: keypoints are now smoothed after tracking instead of
before. Single-person pipelines are unaffected (still one smoother, one
identity). Multi-person pipelines get correct, non-cross-contaminated
smoothing.
run_once signature: smoother param replaced with pool: TrackSmootherPool.
build_pipeline now constructs "pool" instead of "smoother".
Tests: tests/test_smoothing.py adds TrackSmootherPool identity/forget
coverage; tests/test_app.py adds test_two_persons_get_independent_smoothing
demonstrating each track's smoothed keypoints stay on its own side instead
of being pulled toward the other person.