fix(occupancy): bound eigenvalue person-count to single-link max — #894

field_bridge::occupancy_or_fallback returned FieldModel::estimate_occupancy
unbounded (internal ceiling 10), while the perturbation fallback below it
and score_to_person_count both cap at 3 ("1-3 for single ESP32"). On noisy
or under-calibrated CSI the eigenvalue count inflated → "10 persons when 1
present" (#894, seen when --model fails to load → heuristic mode). Bound the
eigenvalue path to a shared MAX_SINGLE_LINK_OCCUPANCY const (3) so every
single-link estimator agrees. Genuine higher counts come from the
multistatic fusion path. Build clean, field_bridge tests pass.
This commit is contained in:
ruv
2026-06-02 10:40:24 +02:00
parent 3fec67654a
commit f34b94aa46
2 changed files with 18 additions and 2 deletions
@@ -21,6 +21,15 @@ const ENERGY_THRESH_2: f64 = 12.0;
/// Perturbation energy threshold for detecting a third person.
const ENERGY_THRESH_3: f64 = 25.0;
/// Maximum occupancy a single ESP32 link can plausibly resolve (#894).
/// The score heuristic (`score_to_person_count`) and the perturbation-energy
/// fallback below both cap here; the eigenvalue path is bounded to match,
/// rather than leaking its internal `min(10)` ceiling on noisy / under-
/// calibrated CSI (the "10 persons reported when 1 present" symptom).
/// Resolving more than this from one link's subcarrier covariance is not
/// reliable — genuine higher counts come from the multistatic fusion path.
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
/// This avoids the DimensionMismatch error when feeding single-frame observations.
pub fn single_link_config() -> FieldModelConfig {
@@ -55,9 +64,15 @@ pub fn occupancy_or_fallback(
return score_to_person_count(smoothed_score, prev_count);
}
// Try eigenvalue-based occupancy first (best accuracy).
// Try eigenvalue-based occupancy first (best accuracy). Bound it to
// the same single-link maximum the sibling estimators use — the
// perturbation fallback below and score_to_person_count both cap at
// MAX_SINGLE_LINK_OCCUPANCY. Without this, estimate_occupancy's
// internal min(10) ceiling leaks up to 10 persons on noisy / under-
// calibrated CSI (#894), while every other path on the same data
// would report ≤3.
if let Ok(count) = field.estimate_occupancy(&frames) {
return count;
return count.min(MAX_SINGLE_LINK_OCCUPANCY);
} // else fall through to perturbation energy
// Fallback: perturbation energy thresholds.