Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/src/motion.rs
T
rUv 8c24b8bdfe refactor(beyond-sota): ADR-154 M3 — clear §7.4 P3 backlog (22 de-magic + 6 boundary tests, backlog 36→0) (#1057)
* refactor(signal): de-magic motion.rs tuning constants (ADR-154 §7.4 #18)

Lift the bare fusion weights, normalization scales, confidence-indicator
weights, and adaptive-threshold clamp bounds in motion.rs out of the
scoring functions into named, documented EMPIRICAL-DEFAULT consts. Values
are bit-identical to the prior literals — this is cleanup, no behaviour
change.

Adds boundary/characterization tests pinning current behaviour:
- motion_tuning_consts_unchanged_from_literals (consts == old literals)
- doppler_component_saturates_at_full_scale (/100 then clamp(0,1))
- correlation_score_zero_below_n2_boundary (n<2 guard)
- temporal_variance_zero_below_two_history (len<2 guard)
- adaptive_threshold_engages_at_history_boundary (history 9 vs 10)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): gesture.rs euclidean length guard + de-magic (ADR-154 §7.4 #12)

- Add a debug_assert! to euclidean_distance documenting the same-dimension
  caller contract: zip() silently truncates on a length mismatch, so a
  mismatch is now loud in debug builds while the release operating path and
  output are unchanged.
- De-magic the bare 1e-10 confidence epsilon into a documented const
  CONFIDENCE_SECOND_BEST_EPSILON (value unchanged).

Tests pinning current behaviour:
- confidence_epsilon_unchanged_from_literal
- dtw_empty_sequence_is_infinite (n=0/m=0 boundary)
- euclidean_distance_equal_length_is_l2 (same-dim contract)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic longitudinal.rs drift thresholds (ADR-154 §7.4)

Lift the bare drift-detection literals (7-day baseline, 2-sigma z-score,
3-day sustained, 7-day escalation, EMA alpha, cosine epsilon) into named,
documented EMPIRICAL-DEFAULT consts encoding the module's Key Invariants.
The duplicated `>= 7` in is_ready/is_ready_at now share one const. EMA alpha
kept as the exact 0.05 literal (1.0 - 0.95_f32 is not bit-identical in f32).
Values unchanged.

Tests:
- drift_consts_unchanged_from_literals
- is_ready_at_day_boundary (day 6 vs 7)
- cosine_similarity_zero_vector_is_zero (zero-norm guard)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic division/zero-norm epsilons + boundary tests (ADR-154 §7.4)

De-magic the bare division-guard epsilons in four modules into named,
documented consts (values unchanged) and pin the previously-untested
zero-norm / zero-variance / degenerate boundaries:

- cross_room.rs: COSINE_SIMILARITY_EPSILON (1e-9) + test_cosine_similarity_zero_vector
- multiband.rs: PEARSON_DENOMINATOR_EPSILON (1e-12) + pearson_correlation_zero_variance
- intention.rs: LEAD_TIME_MIN_ACCEL (1e-10) + lead_time_zero_for_static_stream
- hampel.rs: ZERO_MAD_EPSILON (1e-15) + test_zero_half_window_error
  + test_zero_mad_constant_window; documented hampel_filter # Errors

Each module also gets a *_unchanged_from_literal const-pin test.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic rf_slam + attractor_drift constants (ADR-154 §7.4)

rf_slam.rs:
- NS_PER_DAY (86_400_000_000_000.0), MIGRATION_MIN_SPAN_DAYS (1e-9), and the
  fixed-map defaults (FIXED_MAP_ASSOC_RADIUS_M/MIN_SIGHTINGS/MIN_COHERENCE)
  lifted out of inline literals (values unchanged).
- migration_zero_span_is_zero_rate pins the single-sighting zero-span guard.

attractor_drift.rs:
- METRIC_BUFFER_CAPACITY (365), STABLE_CENTER_WINDOW (10) de-magicked.
- Documented the implicit recent.len()>=1 divide-safety in the PointAttractor
  branch (guaranteed by the count < min_observations guard).
- analyze_min_observations_boundary pins the off-by-one boundary.

Each module gets a *_consts_unchanged_from_literals pin test.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic coherence.rs variance floor + default decay (ADR-154 §7.4)

Completes the M1 #9 de-magic for coherence.rs: the four bare 1e-6 variance-floor
literals (update_reference floor + coherence_score/per_subcarrier_zscores epsilon)
collapse to one VARIANCE_FLOOR const, and the inline 0.95 default decay becomes
DEFAULT_EMA_DECAY. Values unchanged.

Tests:
- drift_consts_unchanged_from_literals extended (VARIANCE_FLOOR, DEFAULT_EMA_DECAY)
- coherence_score_finite_with_zero_variance pins the floor's effect

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic calibration.rs thresholds + min-frames default (ADR-154 §7.4 #2)

Lift the bare calibration literals into named EMPIRICAL-DEFAULT consts (values
unchanged, bit-identical; calibration is off the Python proof path):
- DEFAULT_MIN_FRAMES (600) — was repeated across all four tier constructors
- AMP_STD_FLOOR (1e-12) z-score divisor floor
- MOTION_AMP_Z_THRESHOLD (2.0) / MOTION_PHASE_DRIFT_THRESHOLD (π/6) — the two
  motion_flagged sites now share one definition
- SUBTRACT_MIN_NORM (1e-30) baseline-subtraction guard

Test calibration_consts_unchanged_from_literals pins all five and asserts every
tier constructor shares DEFAULT_MIN_FRAMES.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic fusion_quality + temporal_gesture constants (ADR-154 §7.4)

fusion_quality.rs:
- CONTRADICTION_PENALTY (0.8) and CONTRADICTION_BOUND_HALFWIDTH (0.1) named.
- no_contradiction_is_identity pins the n=0 boundary (penalty 0.8^0 = 1.0,
  zero-width bounds).

temporal_gesture.rs:
- CONFIDENCE_SECOND_BEST_EPSILON (1e-10, mirrors gesture.rs) and
  NORM_QUANTIZATION_SCALE (1000.0) named.

Each module gets a *_consts_unchanged_from_literals pin test. Values unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): record Milestone-3 — §7.4 row #21-45 P3 backlog cleared

Replace the lumped #21-45 backlog row with the enumerated M3 resolution: 22
magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned ==
prior literal), 6 boundary/characterization tests, ~4 doc-only, across 11
modules; not-real findings reported + skipped (unreachable attractor_drift
div0, non-existent gesture thresholds, proof-path features.rs). Update residual
P3 rows #2/#12/#17/#18 to RESOLVED, the deferred count (36 -> 0), the scope
field, and the Horizon-ledger one-liner. §7.4 backlog fully cleared across
M0-M3. CHANGELOG [Unreleased] entry added.

Validation: signal lib --no-default-features 476/0/1; --features cir 476/0;
workspace 3,275/0; Python proof PASS, hash f8e76f21...46f7a UNCHANGED.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-13 19:36:05 -04:00

1027 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Motion Detection Module
//!
//! This module provides motion detection and human presence detection
//! capabilities based on CSI features.
use crate::features::{AmplitudeFeatures, CorrelationFeatures, CsiFeatures, PhaseFeatures};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// Tuning constants (ADR-154 §7.4 #18 — de-magicked; EMPIRICAL DEFAULTS).
//
// These were previously bare literals inside the scoring functions. They are
// lifted to named, documented consts so the implicit weighting becomes
// explicit and a future retune is a visible, tested change. The values are
// **unchanged** from the original literals — boundary/characterization tests
// pin the current behaviour. None of these is calibrated against labelled
// occupancy data; they are heuristic fusion weights.
// ---------------------------------------------------------------------------
/// Motion-score fusion weights when a Doppler component is present.
/// `(variance, correlation, phase, doppler)` — sums to 1.0.
const MOTION_WEIGHTS_WITH_DOPPLER: (f64, f64, f64, f64) = (0.3, 0.2, 0.2, 0.3);
/// Motion-score fusion weights with no Doppler component.
/// `(variance, correlation, phase)` — sums to 1.0.
const MOTION_WEIGHTS_NO_DOPPLER: (f64, f64, f64) = (0.4, 0.3, 0.3);
/// Doppler magnitude (Hz-ish, arbitrary units) that maps to a full-scale
/// (1.0) Doppler motion component. Larger magnitudes saturate at 1.0.
const DOPPLER_FULL_SCALE_MAGNITUDE: f64 = 100.0;
/// Reference variance that maps to a full-scale (1.0) heuristic motion score
/// when no calibrated baseline is available. Empirical default.
const VARIANCE_HEURISTIC_FULL_SCALE: f64 = 0.5;
/// Reference phase variance that maps to a full-scale (1.0) phase motion
/// component. Empirical default.
const PHASE_VARIANCE_FULL_SCALE: f64 = 0.5;
/// Blend weight between phase-variance and phase-coherence in the phase score.
const PHASE_SCORE_VARIANCE_WEIGHT: f64 = 0.5;
/// Reference dynamic range that maps to a full-scale (1.0) amplitude-quality
/// confidence indicator. Empirical default.
const AMP_QUALITY_FULL_SCALE_RANGE: f64 = 2.0;
/// Confidence-indicator blend weights (`amplitude`, `phase`, `correlation`,
/// `doppler`) — each is the fraction of total confidence that indicator
/// contributes when present.
const CONF_WEIGHT_AMPLITUDE: f64 = 0.3;
const CONF_WEIGHT_PHASE: f64 = 0.3;
const CONF_WEIGHT_CORRELATION: f64 = 0.2;
const CONF_WEIGHT_DOPPLER: f64 = 0.2;
/// Minimum baseline floor added before dividing by the calibration baseline
/// variance, preventing a divide-by-zero on an all-constant calibration.
const BASELINE_VARIANCE_FLOOR: f64 = 1e-10;
/// Lower / upper clamp for the adaptive human-detection threshold
/// (`mean + 1σ` of recent motion scores). Keeps the adaptive threshold inside
/// a sane operating band. Empirical default.
const ADAPTIVE_THRESHOLD_MIN: f64 = 0.3;
const ADAPTIVE_THRESHOLD_MAX: f64 = 0.95;
/// Minimum history length before the adaptive threshold engages; below this
/// the configured fixed threshold is used.
const ADAPTIVE_THRESHOLD_MIN_HISTORY: usize = 10;
/// Motion score with component breakdown
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotionScore {
/// Overall motion score (0.0 to 1.0)
pub total: f64,
/// Variance-based motion component
pub variance_component: f64,
/// Correlation-based motion component
pub correlation_component: f64,
/// Phase-based motion component
pub phase_component: f64,
/// Doppler-based motion component (if available)
pub doppler_component: Option<f64>,
}
impl MotionScore {
/// Create a new motion score
pub fn new(
variance_component: f64,
correlation_component: f64,
phase_component: f64,
doppler_component: Option<f64>,
) -> Self {
// Calculate weighted total
let total = if let Some(doppler) = doppler_component {
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
wv * variance_component + wc * correlation_component + wp * phase_component + wd * doppler
} else {
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
wv * variance_component + wc * correlation_component + wp * phase_component
};
Self {
total: total.clamp(0.0, 1.0),
variance_component,
correlation_component,
phase_component,
doppler_component,
}
}
/// Check if motion is detected above threshold
pub fn is_motion_detected(&self, threshold: f64) -> bool {
self.total >= threshold
}
}
/// Motion analysis results
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotionAnalysis {
/// Motion score
pub score: MotionScore,
/// Temporal variance of motion
pub temporal_variance: f64,
/// Spatial variance of motion
pub spatial_variance: f64,
/// Estimated motion velocity (arbitrary units)
pub estimated_velocity: f64,
/// Motion direction estimate (radians, if available)
pub motion_direction: Option<f64>,
/// Confidence in the analysis
pub confidence: f64,
}
/// Human detection result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanDetectionResult {
/// Whether a human was detected
pub human_detected: bool,
/// Detection confidence (0.0 to 1.0)
pub confidence: f64,
/// Motion score
pub motion_score: f64,
/// Raw (unsmoothed) confidence
pub raw_confidence: f64,
/// Timestamp of detection
pub timestamp: DateTime<Utc>,
/// Detection threshold used
pub threshold: f64,
/// Detailed motion analysis
pub motion_analysis: MotionAnalysis,
/// Additional metadata
#[serde(default)]
pub metadata: DetectionMetadata,
}
/// Metadata for detection results
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DetectionMetadata {
/// Number of features used
pub features_used: usize,
/// Processing time in milliseconds
pub processing_time_ms: Option<f64>,
/// Whether Doppler was available
pub doppler_available: bool,
/// History length used
pub history_length: usize,
}
/// Configuration for motion detector
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotionDetectorConfig {
/// Human detection threshold (0.0 to 1.0)
pub human_detection_threshold: f64,
/// Motion detection threshold (0.0 to 1.0)
pub motion_threshold: f64,
/// Temporal smoothing factor (0.0 to 1.0)
/// Higher values give more weight to previous detections
pub smoothing_factor: f64,
/// Minimum amplitude indicator threshold
pub amplitude_threshold: f64,
/// Minimum phase indicator threshold
pub phase_threshold: f64,
/// History size for temporal analysis
pub history_size: usize,
/// Enable adaptive thresholding
pub adaptive_threshold: bool,
/// Weight for amplitude indicator
pub amplitude_weight: f64,
/// Weight for phase indicator
pub phase_weight: f64,
/// Weight for motion indicator
pub motion_weight: f64,
}
impl Default for MotionDetectorConfig {
fn default() -> Self {
Self {
human_detection_threshold: 0.8,
motion_threshold: 0.3,
smoothing_factor: 0.9,
amplitude_threshold: 0.1,
phase_threshold: 0.05,
history_size: 100,
adaptive_threshold: false,
amplitude_weight: 0.4,
phase_weight: 0.3,
motion_weight: 0.3,
}
}
}
impl MotionDetectorConfig {
/// Create a new builder
pub fn builder() -> MotionDetectorConfigBuilder {
MotionDetectorConfigBuilder::new()
}
}
/// Builder for MotionDetectorConfig
#[derive(Debug, Default)]
pub struct MotionDetectorConfigBuilder {
config: MotionDetectorConfig,
}
impl MotionDetectorConfigBuilder {
/// Create new builder
pub fn new() -> Self {
Self {
config: MotionDetectorConfig::default(),
}
}
/// Set human detection threshold
pub fn human_detection_threshold(mut self, threshold: f64) -> Self {
self.config.human_detection_threshold = threshold;
self
}
/// Set motion threshold
pub fn motion_threshold(mut self, threshold: f64) -> Self {
self.config.motion_threshold = threshold;
self
}
/// Set smoothing factor
pub fn smoothing_factor(mut self, factor: f64) -> Self {
self.config.smoothing_factor = factor;
self
}
/// Set amplitude threshold
pub fn amplitude_threshold(mut self, threshold: f64) -> Self {
self.config.amplitude_threshold = threshold;
self
}
/// Set phase threshold
pub fn phase_threshold(mut self, threshold: f64) -> Self {
self.config.phase_threshold = threshold;
self
}
/// Set history size
pub fn history_size(mut self, size: usize) -> Self {
self.config.history_size = size;
self
}
/// Enable adaptive thresholding
pub fn adaptive_threshold(mut self, enable: bool) -> Self {
self.config.adaptive_threshold = enable;
self
}
/// Set indicator weights
pub fn weights(mut self, amplitude: f64, phase: f64, motion: f64) -> Self {
self.config.amplitude_weight = amplitude;
self.config.phase_weight = phase;
self.config.motion_weight = motion;
self
}
/// Build configuration
pub fn build(self) -> MotionDetectorConfig {
self.config
}
}
/// Motion detector for human presence detection
#[derive(Debug)]
pub struct MotionDetector {
config: MotionDetectorConfig,
previous_confidence: f64,
motion_history: VecDeque<MotionScore>,
detection_count: usize,
total_detections: usize,
baseline_variance: Option<f64>,
}
impl MotionDetector {
/// Create a new motion detector
pub fn new(config: MotionDetectorConfig) -> Self {
Self {
motion_history: VecDeque::with_capacity(config.history_size),
config,
previous_confidence: 0.0,
detection_count: 0,
total_detections: 0,
baseline_variance: None,
}
}
/// Create with default configuration
pub fn default_config() -> Self {
Self::new(MotionDetectorConfig::default())
}
/// Get configuration
pub fn config(&self) -> &MotionDetectorConfig {
&self.config
}
/// Analyze motion patterns from CSI features
pub fn analyze_motion(&self, features: &CsiFeatures) -> MotionAnalysis {
// Calculate variance-based motion score
let variance_score = self.calculate_variance_score(&features.amplitude);
// Calculate correlation-based motion score
let correlation_score = self.calculate_correlation_score(&features.correlation);
// Calculate phase-based motion score
let phase_score = self.calculate_phase_score(&features.phase);
// Calculate Doppler-based score if available
let doppler_score = features.doppler.as_ref().map(|d| {
// Normalize Doppler magnitude to 0-1 range
(d.mean_magnitude / DOPPLER_FULL_SCALE_MAGNITUDE).clamp(0.0, 1.0)
});
let motion_score = MotionScore::new(
variance_score,
correlation_score,
phase_score,
doppler_score,
);
// Calculate temporal and spatial variance
let temporal_variance = self.calculate_temporal_variance();
let spatial_variance = features.amplitude.variance.iter().sum::<f64>()
/ features.amplitude.variance.len() as f64;
// Estimate velocity from Doppler if available
let estimated_velocity = features
.doppler
.as_ref()
.map(|d| d.mean_magnitude)
.unwrap_or(0.0);
// Motion direction from phase gradient
let motion_direction = if !features.phase.gradient.is_empty() {
let mean_grad: f64 =
features.phase.gradient.iter().sum::<f64>() / features.phase.gradient.len() as f64;
Some(mean_grad.atan())
} else {
None
};
// Calculate confidence based on signal quality indicators
let confidence = self.calculate_motion_confidence(features);
MotionAnalysis {
score: motion_score,
temporal_variance,
spatial_variance,
estimated_velocity,
motion_direction,
confidence,
}
}
/// Calculate variance-based motion score
fn calculate_variance_score(&self, amplitude: &AmplitudeFeatures) -> f64 {
let mean_variance =
amplitude.variance.iter().sum::<f64>() / amplitude.variance.len() as f64;
// Normalize using baseline if available
if let Some(baseline) = self.baseline_variance {
let ratio = mean_variance / (baseline + BASELINE_VARIANCE_FLOOR);
(ratio - 1.0).max(0.0).tanh()
} else {
// Use heuristic normalization
(mean_variance / VARIANCE_HEURISTIC_FULL_SCALE).clamp(0.0, 1.0)
}
}
/// Calculate correlation-based motion score
fn calculate_correlation_score(&self, correlation: &CorrelationFeatures) -> f64 {
let n = correlation.matrix.dim().0;
if n < 2 {
return 0.0;
}
// Calculate mean deviation from identity matrix
let mut deviation_sum = 0.0;
let mut count = 0;
for i in 0..n {
for j in 0..n {
let expected = if i == j { 1.0 } else { 0.0 };
deviation_sum += (correlation.matrix[[i, j]] - expected).abs();
count += 1;
}
}
let mean_deviation = deviation_sum / count as f64;
mean_deviation.clamp(0.0, 1.0)
}
/// Calculate phase-based motion score
fn calculate_phase_score(&self, phase: &PhaseFeatures) -> f64 {
// Use phase variance and coherence
let mean_variance = phase.variance.iter().sum::<f64>() / phase.variance.len() as f64;
let coherence_factor = 1.0 - phase.coherence.abs();
// Combine factors
let w = PHASE_SCORE_VARIANCE_WEIGHT;
let score = w * (mean_variance / PHASE_VARIANCE_FULL_SCALE).clamp(0.0, 1.0)
+ (1.0 - w) * coherence_factor;
score.clamp(0.0, 1.0)
}
/// Calculate temporal variance from motion history
fn calculate_temporal_variance(&self) -> f64 {
if self.motion_history.len() < 2 {
return 0.0;
}
let scores: Vec<f64> = self.motion_history.iter().map(|m| m.total).collect();
let mean: f64 = scores.iter().sum::<f64>() / scores.len() as f64;
let variance: f64 =
scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64;
variance.sqrt()
}
/// Calculate confidence in motion detection
fn calculate_motion_confidence(&self, features: &CsiFeatures) -> f64 {
let mut confidence = 0.0;
let mut weight_sum = 0.0;
// Amplitude quality indicator
let amp_quality =
(features.amplitude.dynamic_range / AMP_QUALITY_FULL_SCALE_RANGE).clamp(0.0, 1.0);
confidence += amp_quality * CONF_WEIGHT_AMPLITUDE;
weight_sum += CONF_WEIGHT_AMPLITUDE;
// Phase coherence indicator
let phase_quality = features.phase.coherence.abs();
confidence += phase_quality * CONF_WEIGHT_PHASE;
weight_sum += CONF_WEIGHT_PHASE;
// Correlation consistency indicator
let corr_quality = (1.0 - features.correlation.correlation_spread).clamp(0.0, 1.0);
confidence += corr_quality * CONF_WEIGHT_CORRELATION;
weight_sum += CONF_WEIGHT_CORRELATION;
// Doppler quality if available
if let Some(ref doppler) = features.doppler {
let doppler_quality =
(doppler.spread / doppler.mean_magnitude.max(1.0)).clamp(0.0, 1.0);
confidence += (1.0 - doppler_quality) * CONF_WEIGHT_DOPPLER;
weight_sum += CONF_WEIGHT_DOPPLER;
}
if weight_sum > 0.0 {
confidence / weight_sum
} else {
0.0
}
}
/// Calculate detection confidence from features and motion score
fn calculate_detection_confidence(&self, features: &CsiFeatures, motion_score: f64) -> f64 {
// Amplitude indicator
let amplitude_mean =
features.amplitude.mean.iter().sum::<f64>() / features.amplitude.mean.len() as f64;
let amplitude_indicator = if amplitude_mean > self.config.amplitude_threshold {
1.0
} else {
0.0
};
// Phase indicator
let phase_std = features.phase.variance.iter().sum::<f64>().sqrt()
/ features.phase.variance.len() as f64;
let phase_indicator = if phase_std > self.config.phase_threshold {
1.0
} else {
0.0
};
// Motion indicator
let motion_indicator = if motion_score > self.config.motion_threshold {
1.0
} else {
0.0
};
// Weighted combination
let confidence = self.config.amplitude_weight * amplitude_indicator
+ self.config.phase_weight * phase_indicator
+ self.config.motion_weight * motion_indicator;
confidence.clamp(0.0, 1.0)
}
/// Apply temporal smoothing (exponential moving average)
fn apply_temporal_smoothing(&mut self, raw_confidence: f64) -> f64 {
let smoothed = self.config.smoothing_factor * self.previous_confidence
+ (1.0 - self.config.smoothing_factor) * raw_confidence;
self.previous_confidence = smoothed;
smoothed
}
/// Detect human presence from CSI features
pub fn detect_human(&mut self, features: &CsiFeatures) -> HumanDetectionResult {
// Analyze motion
let motion_analysis = self.analyze_motion(features);
// Add to history
if self.motion_history.len() >= self.config.history_size {
self.motion_history.pop_front();
}
self.motion_history.push_back(motion_analysis.score.clone());
// Calculate detection confidence
let raw_confidence =
self.calculate_detection_confidence(features, motion_analysis.score.total);
// Apply temporal smoothing
let smoothed_confidence = self.apply_temporal_smoothing(raw_confidence);
// Get effective threshold (adaptive if enabled)
let threshold = if self.config.adaptive_threshold {
self.calculate_adaptive_threshold()
} else {
self.config.human_detection_threshold
};
// Determine detection
let human_detected = smoothed_confidence >= threshold;
self.total_detections += 1;
if human_detected {
self.detection_count += 1;
}
let metadata = DetectionMetadata {
features_used: 4, // amplitude, phase, correlation, psd
processing_time_ms: None,
doppler_available: features.doppler.is_some(),
history_length: self.motion_history.len(),
};
HumanDetectionResult {
human_detected,
confidence: smoothed_confidence,
motion_score: motion_analysis.score.total,
raw_confidence,
timestamp: Utc::now(),
threshold,
motion_analysis,
metadata,
}
}
/// Calculate adaptive threshold based on recent history
fn calculate_adaptive_threshold(&self) -> f64 {
if self.motion_history.len() < ADAPTIVE_THRESHOLD_MIN_HISTORY {
return self.config.human_detection_threshold;
}
let scores: Vec<f64> = self.motion_history.iter().map(|m| m.total).collect();
let mean: f64 = scores.iter().sum::<f64>() / scores.len() as f64;
let std: f64 = {
let var: f64 =
scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64;
var.sqrt()
};
// Threshold is mean + 1 std deviation, clamped to reasonable range
(mean + std).clamp(ADAPTIVE_THRESHOLD_MIN, ADAPTIVE_THRESHOLD_MAX)
}
/// Update baseline variance (for calibration)
pub fn calibrate(&mut self, features: &CsiFeatures) {
let mean_variance = features.amplitude.variance.iter().sum::<f64>()
/ features.amplitude.variance.len() as f64;
self.baseline_variance = Some(mean_variance);
}
/// Clear calibration
pub fn clear_calibration(&mut self) {
self.baseline_variance = None;
}
/// Get detection statistics
pub fn get_statistics(&self) -> DetectionStatistics {
DetectionStatistics {
total_detections: self.total_detections,
positive_detections: self.detection_count,
detection_rate: if self.total_detections > 0 {
self.detection_count as f64 / self.total_detections as f64
} else {
0.0
},
history_size: self.motion_history.len(),
is_calibrated: self.baseline_variance.is_some(),
}
}
/// Reset detector state
pub fn reset(&mut self) {
self.previous_confidence = 0.0;
self.motion_history.clear();
self.detection_count = 0;
self.total_detections = 0;
}
/// Get previous confidence value
pub fn previous_confidence(&self) -> f64 {
self.previous_confidence
}
}
/// Detection statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionStatistics {
/// Total number of detection attempts
pub total_detections: usize,
/// Number of positive detections
pub positive_detections: usize,
/// Detection rate (0.0 to 1.0)
pub detection_rate: f64,
/// Current history size
pub history_size: usize,
/// Whether detector is calibrated
pub is_calibrated: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::csi_processor::CsiData;
use crate::features::FeatureExtractor;
use ndarray::Array2;
fn create_test_csi_data(motion_level: f64) -> CsiData {
let amplitude = Array2::from_shape_fn((4, 64), |(i, j)| {
1.0 + motion_level * 0.5 * ((i + j) as f64 * 0.1).sin()
});
let phase = Array2::from_shape_fn((4, 64), |(i, j)| {
motion_level * 0.3 * ((i + j) as f64 * 0.15).sin()
});
CsiData::builder()
.amplitude(amplitude)
.phase(phase)
.frequency(5.0e9)
.bandwidth(20.0e6)
.snr(25.0)
.build()
.unwrap()
}
fn create_test_features(motion_level: f64) -> CsiFeatures {
let csi_data = create_test_csi_data(motion_level);
let extractor = FeatureExtractor::default_config();
extractor.extract(&csi_data)
}
#[test]
fn test_motion_score() {
let score = MotionScore::new(0.5, 0.6, 0.4, None);
assert!(score.total > 0.0 && score.total <= 1.0);
assert_eq!(score.variance_component, 0.5);
assert_eq!(score.correlation_component, 0.6);
assert_eq!(score.phase_component, 0.4);
}
#[test]
fn test_motion_score_with_doppler() {
let score = MotionScore::new(0.5, 0.6, 0.4, Some(0.7));
assert!(score.total > 0.0 && score.total <= 1.0);
assert_eq!(score.doppler_component, Some(0.7));
}
#[test]
fn test_motion_detector_creation() {
let config = MotionDetectorConfig::default();
let detector = MotionDetector::new(config);
assert_eq!(detector.previous_confidence(), 0.0);
}
#[test]
fn test_motion_analysis() {
let detector = MotionDetector::default_config();
let features = create_test_features(0.5);
let analysis = detector.analyze_motion(&features);
assert!(analysis.score.total >= 0.0 && analysis.score.total <= 1.0);
assert!(analysis.confidence >= 0.0 && analysis.confidence <= 1.0);
}
#[test]
fn test_human_detection() {
let config = MotionDetectorConfig::builder()
.human_detection_threshold(0.5)
.smoothing_factor(0.5)
.build();
let mut detector = MotionDetector::new(config);
let features = create_test_features(0.8);
let result = detector.detect_human(&features);
assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
assert!(result.motion_score >= 0.0 && result.motion_score <= 1.0);
}
#[test]
fn test_temporal_smoothing() {
let config = MotionDetectorConfig::builder()
.smoothing_factor(0.9)
.build();
let mut detector = MotionDetector::new(config);
// First detection with low confidence
let features_low = create_test_features(0.1);
let result1 = detector.detect_human(&features_low);
// Second detection with high confidence should be smoothed
let features_high = create_test_features(0.9);
let result2 = detector.detect_human(&features_high);
// Due to smoothing, result2.confidence should be between result1 and raw
assert!(result2.confidence >= result1.confidence);
}
#[test]
fn test_calibration() {
let mut detector = MotionDetector::default_config();
let features = create_test_features(0.5);
assert!(!detector.get_statistics().is_calibrated);
detector.calibrate(&features);
assert!(detector.get_statistics().is_calibrated);
detector.clear_calibration();
assert!(!detector.get_statistics().is_calibrated);
}
#[test]
fn test_detection_statistics() {
let mut detector = MotionDetector::default_config();
for i in 0..5 {
let features = create_test_features((i as f64) / 5.0);
let _ = detector.detect_human(&features);
}
let stats = detector.get_statistics();
assert_eq!(stats.total_detections, 5);
assert!(stats.detection_rate >= 0.0 && stats.detection_rate <= 1.0);
}
#[test]
fn test_reset() {
let mut detector = MotionDetector::default_config();
let features = create_test_features(0.5);
for _ in 0..5 {
let _ = detector.detect_human(&features);
}
detector.reset();
let stats = detector.get_statistics();
assert_eq!(stats.total_detections, 0);
assert_eq!(stats.history_size, 0);
assert_eq!(detector.previous_confidence(), 0.0);
}
#[test]
fn test_adaptive_threshold() {
let config = MotionDetectorConfig::builder()
.adaptive_threshold(true)
.history_size(20)
.build();
let mut detector = MotionDetector::new(config);
// Build up history
for i in 0..15 {
let features = create_test_features((i as f64 % 5.0) / 5.0);
let _ = detector.detect_human(&features);
}
// The adaptive threshold should now be calculated
let features = create_test_features(0.5);
let result = detector.detect_human(&features);
// Threshold should be different from default
// (this is a weak assertion, mainly checking it runs)
assert!(result.threshold > 0.0);
}
#[test]
fn test_config_builder() {
let config = MotionDetectorConfig::builder()
.human_detection_threshold(0.7)
.motion_threshold(0.4)
.smoothing_factor(0.85)
.amplitude_threshold(0.15)
.phase_threshold(0.08)
.history_size(200)
.adaptive_threshold(true)
.weights(0.35, 0.35, 0.30)
.build();
assert_eq!(config.human_detection_threshold, 0.7);
assert_eq!(config.motion_threshold, 0.4);
assert_eq!(config.smoothing_factor, 0.85);
assert_eq!(config.amplitude_threshold, 0.15);
assert_eq!(config.phase_threshold, 0.08);
assert_eq!(config.history_size, 200);
assert!(config.adaptive_threshold);
assert_eq!(config.amplitude_weight, 0.35);
assert_eq!(config.phase_weight, 0.35);
assert_eq!(config.motion_weight, 0.30);
}
#[test]
fn test_low_motion_no_detection() {
let config = MotionDetectorConfig::builder()
.human_detection_threshold(0.8)
.smoothing_factor(0.0) // No smoothing for clear test
.build();
let mut detector = MotionDetector::new(config);
// Very low motion should not trigger detection
let features = create_test_features(0.01);
let result = detector.detect_human(&features);
// With very low motion, detection should likely be false
// (depends on thresholds, but confidence should be low)
assert!(result.motion_score < 0.5);
}
#[test]
fn test_motion_history() {
let config = MotionDetectorConfig::builder().history_size(10).build();
let mut detector = MotionDetector::new(config);
for i in 0..15 {
let features = create_test_features((i as f64) / 15.0);
let _ = detector.detect_human(&features);
}
let stats = detector.get_statistics();
assert_eq!(stats.history_size, 10); // Should not exceed max
}
// -- ADR-154 §7.4 #18: de-magic-constant + boundary characterization tests.
// These pin CURRENT behaviour so a future retune is a visible, tested change.
/// The de-magicked tuning consts MUST equal the prior bare literals exactly
/// (this milestone is cleanup — operating values are unchanged).
#[test]
fn motion_tuning_consts_unchanged_from_literals() {
assert_eq!(MOTION_WEIGHTS_WITH_DOPPLER, (0.3, 0.2, 0.2, 0.3));
assert_eq!(MOTION_WEIGHTS_NO_DOPPLER, (0.4, 0.3, 0.3));
assert_eq!(DOPPLER_FULL_SCALE_MAGNITUDE, 100.0);
assert_eq!(VARIANCE_HEURISTIC_FULL_SCALE, 0.5);
assert_eq!(PHASE_VARIANCE_FULL_SCALE, 0.5);
assert_eq!(PHASE_SCORE_VARIANCE_WEIGHT, 0.5);
assert_eq!(AMP_QUALITY_FULL_SCALE_RANGE, 2.0);
assert_eq!(CONF_WEIGHT_AMPLITUDE, 0.3);
assert_eq!(CONF_WEIGHT_PHASE, 0.3);
assert_eq!(CONF_WEIGHT_CORRELATION, 0.2);
assert_eq!(CONF_WEIGHT_DOPPLER, 0.2);
assert_eq!(BASELINE_VARIANCE_FLOOR, 1e-10);
assert_eq!(ADAPTIVE_THRESHOLD_MIN, 0.3);
assert_eq!(ADAPTIVE_THRESHOLD_MAX, 0.95);
assert_eq!(ADAPTIVE_THRESHOLD_MIN_HISTORY, 10);
// Fusion weights are a convex combination (sum to 1.0).
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
assert!((wv + wc + wp + wd - 1.0).abs() < 1e-12);
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
assert!((wv + wc + wp - 1.0).abs() < 1e-12);
}
/// Doppler component saturates at full scale (`/100.0` then clamp(0,1)).
/// Pins behaviour at/just-below/just-above the full-scale magnitude.
#[test]
fn doppler_component_saturates_at_full_scale() {
use crate::features::DopplerFeatures;
use ndarray::Array1;
let make = |mag: f64| DopplerFeatures {
shifts: Array1::zeros(1),
peak_frequency: 0.0,
mean_magnitude: mag,
spread: 0.0,
};
let detector = MotionDetector::default_config();
// just below full scale -> < 1.0
let mut features = create_test_features(0.5);
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE - 1.0));
let below = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert!(below < 1.0 && below > 0.98);
// exactly full scale -> 1.0
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE));
let at = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert_eq!(at, 1.0);
// above full scale -> clamped to 1.0
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE * 10.0));
let above = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert_eq!(above, 1.0);
}
/// `calculate_correlation_score` returns 0.0 for n<2 (the small-matrix
/// guard) and a finite, clamped value for n>=2. Pins the n=1 boundary.
#[test]
fn correlation_score_zero_below_n2_boundary() {
use crate::features::CorrelationFeatures;
use ndarray::Array2;
let detector = MotionDetector::default_config();
let one = CorrelationFeatures {
matrix: Array2::from_elem((1, 1), 1.0),
mean_correlation: 0.0,
max_correlation: 0.0,
correlation_spread: 0.0,
};
assert_eq!(detector.calculate_correlation_score(&one), 0.0);
let two = CorrelationFeatures {
matrix: Array2::from_shape_fn((2, 2), |(i, j)| if i == j { 1.0 } else { 0.0 }),
mean_correlation: 0.0,
max_correlation: 0.0,
correlation_spread: 0.0,
};
let s = detector.calculate_correlation_score(&two);
assert!(s.is_finite() && (0.0..=1.0).contains(&s));
}
/// `calculate_temporal_variance` returns 0.0 with fewer than 2 history
/// entries, finite otherwise. Pins the len<2 boundary.
#[test]
fn temporal_variance_zero_below_two_history() {
let mut detector = MotionDetector::default_config();
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 0 entries
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 1 entry
detector
.motion_history
.push_back(MotionScore::new(0.1, 0.1, 0.1, None));
assert!(detector.calculate_temporal_variance() > 0.0); // 2 entries
}
/// The adaptive threshold engages only at/after `ADAPTIVE_THRESHOLD_MIN_HISTORY`
/// history entries; below it falls back to the configured fixed threshold.
/// Pins the history=9 (fixed) vs history=10 (adaptive) boundary.
#[test]
fn adaptive_threshold_engages_at_history_boundary() {
let config = MotionDetectorConfig::builder()
.adaptive_threshold(true)
.human_detection_threshold(0.8)
.history_size(50)
.build();
let mut detector = MotionDetector::new(config);
// Push exactly 9 entries: still uses the fixed configured threshold.
for _ in 0..(ADAPTIVE_THRESHOLD_MIN_HISTORY - 1) {
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
}
assert_eq!(detector.calculate_adaptive_threshold(), 0.8);
// 10th entry: adaptive band kicks in, clamped to [MIN, MAX].
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
let t = detector.calculate_adaptive_threshold();
assert!((ADAPTIVE_THRESHOLD_MIN..=ADAPTIVE_THRESHOLD_MAX).contains(&t));
}
}