fix(adr-120): centralised motion-label smoothing — 0 flips in 30s
Previous smoothing covered only the adaptive_override path. The 5 other classification.motion_level writes (amp_presence_override and amp_classify_from_latest in 3 different tick handlers) wrote raw values that bypassed the smoother entirely — explaining the lingering "переключается со скоростью света" complaint after the two-layer fix. New finalize_motion_label(&mut classification) runs at end-of-tick AFTER all overrides have settled, applies the same two-layer (30-tick mode + 5-tick confirm) smoothing uniformly to whatever label survived the priority cascade. Called from 3 sites: - multi-BSSID tick handler - feature_state tick handler - per-node loop in broadcast tick task adaptive_override now emits raw model label (no double-smoothing). Verified: 30-second sample, user actively performing transitions, ZERO flips. Label persisted as `transition` all 30 samples. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
77d404d613
commit
2956414bf8
|
|
@ -2713,14 +2713,13 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
|
|||
model.classify(&feat_arr)
|
||||
};
|
||||
|
||||
// ADR-120 follow-up: majority-vote smoothing across ~700 ms of
|
||||
// history. Stops the per-tick flicker that made the live label
|
||||
// unreadable. Hybrid priority downstream re-checks via
|
||||
// adaptive_owns_class on the smoothed label, so waving/transition
|
||||
// ownership is preserved.
|
||||
let smoothed = adaptive_label_smooth(&label);
|
||||
classification.motion_level = smoothed.clone();
|
||||
classification.presence = smoothed != "absent";
|
||||
// ADR-120 follow-up #2: emit raw model label here. Smoothing is
|
||||
// applied centrally at end-of-tick via finalize_motion_label so
|
||||
// it covers BOTH the adaptive path AND the rule-based override
|
||||
// paths (amp_presence_override / amp_classify_from_latest) which
|
||||
// previously wrote raw values directly to motion_level.
|
||||
classification.motion_level = label.to_string();
|
||||
classification.presence = label != "absent";
|
||||
// Blend model confidence with existing smoothed confidence.
|
||||
classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0);
|
||||
}
|
||||
|
|
@ -2756,6 +2755,19 @@ fn adaptive_committed_init() -> &'static Mutex<(String, String, u32)> {
|
|||
ADAPTIVE_COMMITTED.get_or_init(|| Mutex::new((String::new(), String::new(), 0)))
|
||||
}
|
||||
|
||||
/// ADR-120 follow-up #2: smooth WHATEVER label the cascade of overrides
|
||||
/// produced, regardless of source (adaptive model OR amp_presence_override
|
||||
/// OR amp_classify_from_latest). The earlier adaptive_label_smooth ONLY
|
||||
/// covered the adaptive output — anything else (the 4 baseline classes)
|
||||
/// passed through raw, so the live label kept flipping on every tick.
|
||||
/// This is the final chokepoint called from each tick handler after all
|
||||
/// overrides have run.
|
||||
pub fn finalize_motion_label(classification: &mut ClassificationInfo) {
|
||||
let smoothed = adaptive_label_smooth(&classification.motion_level);
|
||||
classification.presence = smoothed != "absent";
|
||||
classification.motion_level = smoothed;
|
||||
}
|
||||
|
||||
/// Push `raw_label` into Layer 1 (rolling history) and compute its mode.
|
||||
/// Then run Layer 2 (candidate confirmation): a label different from the
|
||||
/// committed one must persist for ADAPTIVE_CONFIRM_TICKS consecutive
|
||||
|
|
@ -3150,6 +3162,11 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
|||
// ADR-104 phase-domain: update phase drift score for this node
|
||||
// alongside the amplitude classifier. No-op if no phase baseline.
|
||||
phase_drift_update(frame.node_id, &frame.phases);
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass over the post-
|
||||
// override classification. Catches flicker from BOTH adaptive
|
||||
// and rule-based paths.
|
||||
finalize_motion_label(&mut classification);
|
||||
drop(s_write_pre);
|
||||
|
||||
// ── Step 5: Build enhanced fields from pipeline result ───────
|
||||
|
|
@ -6160,6 +6177,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
}
|
||||
}
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass — uniformly
|
||||
// damps flicker from both adaptive and rule-based outputs.
|
||||
finalize_motion_label(&mut classification);
|
||||
|
||||
// ADR-112: prefer multistatic-derived signal_field
|
||||
// when ≥ 2 ESP32 nodes are active; falls back to
|
||||
// ADR-105's zero grid on single-sensor / fusion-fail.
|
||||
|
|
@ -6417,6 +6438,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
phase_drift_update(node_id, ph);
|
||||
}
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass on the
|
||||
// per-node loop's classification. Same shared smoother
|
||||
// state as the other two tick sites — single source
|
||||
// of truth for the displayed label.
|
||||
finalize_motion_label(&mut classification);
|
||||
|
||||
ns.rssi_history.push_back(features.mean_rssi);
|
||||
if ns.rssi_history.len() > 60 {
|
||||
ns.rssi_history.pop_front();
|
||||
|
|
|
|||
Loading…
Reference in New Issue