Skip to main content

blr_active/active_learning/
multi_sensor.rs

1//! Multi-sensor orchestration support.
2//!
3//! Manages independent calibration sessions for multiple sensor types
4//! simultaneously. Each sensor maintains its own BLR+ARD model and
5//! precision history, enabling unified reporting.
6
7use crate::active_learning::acquisition::RecommendedSample;
8use crate::active_learning::orchestration::{CalibrationSession, IterationOutcome, SessionConfig};
9
10/// Aggregated recommendations from multiple sensors.
11#[derive(Debug, Clone)]
12pub struct MultiSensorRecommendation {
13    /// Sensor identifier label (e.g., "hall", "gas", "thermistor").
14    pub sensor_id: String,
15    /// Recommendations from this sensor's calibration session.
16    pub recommendations: Vec<RecommendedSample>,
17    /// Current 95th-percentile posterior std for this sensor.
18    pub current_p95_std: f64,
19    /// Whether this sensor has met its precision goal.
20    pub goal_met: bool,
21}
22
23/// Multi-sensor calibration coordinator.
24///
25/// Maintains a map of named sensor sessions. Each sensor has its own
26/// independent BLR+ARD model and calibration config (different input ranges,
27/// feature functions, and precision targets are all supported).
28pub struct MultiSensorSession {
29    sessions: Vec<(String, CalibrationSession)>,
30}
31
32impl MultiSensorSession {
33    /// Create an empty multi-sensor session.
34    pub fn new() -> Self {
35        Self {
36            sessions: Vec::new(),
37        }
38    }
39
40    /// Register a new sensor session.
41    ///
42    /// # Arguments
43    /// - `sensor_id`: unique identifier for this sensor
44    /// - `config`: session configuration for this sensor
45    /// - `feature_fn`: feature engineering function for this sensor
46    /// - `feature_dim`: number of features D
47    pub fn add_sensor(
48        &mut self,
49        sensor_id: impl Into<String>,
50        config: SessionConfig,
51        feature_fn: impl Fn(f64) -> Vec<f64> + 'static,
52        feature_dim: usize,
53    ) {
54        let session = CalibrationSession::new(config, feature_fn, feature_dim);
55        self.sessions.push((sensor_id.into(), session));
56    }
57
58    /// Add a measurement to the specified sensor session.
59    pub fn add_measurement(&mut self, sensor_id: &str, raw_input: f64, measured_output: f64) {
60        if let Some((_, session)) = self.sessions.iter_mut().find(|(id, _)| id == sensor_id) {
61            session.add_measurement(raw_input, measured_output);
62        }
63    }
64
65    /// Run one calibration iteration for all sensors.
66    ///
67    /// Returns recommendations aggregated across all sensors that still need samples.
68    /// Sensors that have met their goal or hit the noise floor are excluded from
69    /// recommendations but their status is still reported.
70    pub fn next_iteration_all(&mut self) -> Vec<MultiSensorRecommendation> {
71        let mut results = Vec::new();
72
73        for (sensor_id, session) in &mut self.sessions {
74            let outcome = session.next_iteration();
75
76            let (recs, goal_met) = match &outcome {
77                IterationOutcome::RecommendNext(r) => (r.clone(), false),
78                IterationOutcome::PrecisionMet => (Vec::new(), true),
79                IterationOutcome::NoiseFloorHit(_) => (Vec::new(), false),
80                IterationOutcome::MaxIterationsReached | IterationOutcome::FitError(_) => {
81                    (Vec::new(), false)
82                }
83            };
84
85            let current_p95 = session
86                .precision_history
87                .last()
88                .map(|r| r.percentile_95_std)
89                .unwrap_or(f64::INFINITY);
90
91            results.push(MultiSensorRecommendation {
92                sensor_id: sensor_id.clone(),
93                recommendations: recs,
94                current_p95_std: current_p95,
95                goal_met,
96            });
97        }
98
99        results
100    }
101
102    /// Check whether all registered sensors have met their precision goals.
103    pub fn all_goals_met(&self) -> bool {
104        self.sessions.iter().all(|(_, session)| {
105            session
106                .precision_history
107                .last()
108                .map(|r| r.goal_met)
109                .unwrap_or(false)
110        })
111    }
112
113    /// Per-sensor precision summary (sensor_id → current p95 std).
114    pub fn precision_summary(&self) -> Vec<(String, f64)> {
115        self.sessions
116            .iter()
117            .map(|(id, session)| {
118                let p95 = session
119                    .precision_history
120                    .last()
121                    .map(|r| r.percentile_95_std)
122                    .unwrap_or(f64::INFINITY);
123                (id.clone(), p95)
124            })
125            .collect()
126    }
127}
128
129impl Default for MultiSensorSession {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn polynomial_feature(x: f64) -> Vec<f64> {
140        vec![1.0, x, x * x]
141    }
142
143    fn make_config(input_max: f64, target: f64) -> SessionConfig {
144        SessionConfig {
145            target_precision: target,
146            max_iterations: 10,
147            top_k: 2,
148            grid_resolution: 20,
149            input_range: (0.0, input_max),
150            ..Default::default()
151        }
152    }
153
154    /// Two sensors should have independent precision tracks
155    #[test]
156    fn test_independent_precision_tracking() {
157        let mut ms = MultiSensorSession::new();
158        ms.add_sensor("hall", make_config(100.0, 0.5), polynomial_feature, 3);
159        ms.add_sensor("gas", make_config(50.0, 0.3), polynomial_feature, 3);
160
161        // Add measurements to hall sensor
162        for i in 0..5 {
163            ms.add_measurement("hall", i as f64 * 20.0, i as f64 * 2.0 + 0.01);
164        }
165        // Add measurements to gas sensor
166        for i in 0..5 {
167            ms.add_measurement("gas", i as f64 * 10.0, i as f64 * 0.5 + 0.005);
168        }
169
170        let results = ms.next_iteration_all();
171        assert_eq!(results.len(), 2, "should have results for both sensors");
172
173        // Precision tracks are independent
174        let hall = results.iter().find(|r| r.sensor_id == "hall").unwrap();
175        let gas = results.iter().find(|r| r.sensor_id == "gas").unwrap();
176        // p95 values may differ (different data & target)
177        // Just verify they are finite
178        assert!(hall.current_p95_std.is_finite());
179        assert!(gas.current_p95_std.is_finite());
180    }
181
182    /// all_goals_met returns false when sensors haven't converged
183    #[test]
184    fn test_all_goals_not_met_initially() {
185        let mut ms = MultiSensorSession::new();
186        ms.add_sensor("hall", make_config(100.0, 0.0001), polynomial_feature, 3); // very tight
187        ms.add_measurement("hall", 0.0, 0.0);
188        ms.add_measurement("hall", 50.0, 1.0);
189        ms.next_iteration_all();
190        assert!(!ms.all_goals_met());
191    }
192}