Skip to main content

blr_core/
synthetic_data.rs

1//! Synthetic Hall-effect sensor data for the end-to-end calibration demo.
2//!
3//! Generates deterministic, reproducible sensor measurements using a seeded
4//! xorshift64 PRNG (no platform-specific dependencies; identical output on any target
5//! including `wasm32-wasip2`).
6//!
7//! ## Hall Sensor Model
8//!
9//! ```text
10//! V(B) = K0 + K1 × B_norm + ε        ε ~ N(0, σ_noise²)
11//! ```
12//!
13//! where `B_norm = B / B_MAX_MT ∈ [0, 1]`, `K0 = 0.5 V` (offset),
14//! `K1 = 0.5 V` (sensitivity), and `B_MAX_MT = 100 mT`.
15//!
16//! The true model is **linear** in B. Features [B², B³] are irrelevant; ARD
17//! correctly assigns them large precision (α → large), demonstrating sparsification.
18//!
19//! ## Features
20//!
21//! The BLR feature map is degree-3 polynomial:
22//!
23//! ```text
24//! φ(B) = [1, B_norm, B_norm², B_norm³]
25//! ```
26//!
27//! ARD learns:
28//! - α\[0\] (bias): moderate — captures voltage offset K0.
29//! - α\[1\] (B-field): very small — highly relevant linear term.
30//! - α\[2\] (B²): large — irrelevant quadratic (no quadratic signal).
31//! - α\[3\] (B³): very large — irrelevant cubic (no cubic signal).
32//!
33//! Ratio `α[3] / α[1] > 100×` is expected and validates physics correctness.
34//!
35//! ## Usage
36//!
37//! ```rust
38//! use blr_core::synthetic_data::{generate_hall_samples, hall_feature_fn, GROUND_TRUTH_NOISE_STD};
39//!
40//! let (b_vals, v_vals) = generate_hall_samples(10, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 42);
41//! assert_eq!(b_vals.len(), 10);
42//! assert_eq!(v_vals.len(), 10);
43//!
44//! let feats = hall_feature_fn(50.0);  // B = 50 mT
45//! assert!((feats[0] - 1.0).abs() < 1e-12);  // bias
46//! assert!((feats[1] - 0.5).abs() < 1e-12);  // B_norm = 50/100
47//! ```
48
49use core::f64::consts::PI;
50
51// ─── Physics constants ─────────────────────────────────────────────────────────
52
53/// Nominal bias voltage (Hall sensor output at zero B-field). Units: V.
54pub const K0: f64 = 0.5;
55
56/// Hall sensitivity gain (voltage change per normalized B-field unit). Units: V.
57pub const K1: f64 = 0.5;
58
59/// Maximum B-field used to normalize features. Units: mT.
60pub const B_MAX_MT: f64 = 100.0;
61
62/// Ground-truth sensor noise standard deviation used in synthetic data generation. Units: V.
63///
64/// Represents realistic Hall-sensor measurement noise at 10 kHz bandwidth.
65pub const GROUND_TRUTH_NOISE_STD: f64 = 0.008;
66
67/// Number of BLR input features: `[1, B_norm, B_norm², B_norm³]`.
68pub const N_FEATURES: usize = 4;
69
70/// Human-readable names for each BLR feature (index-aligned with [`hall_feature_fn`]).
71pub const FEATURE_NAMES: [&str; N_FEATURES] = ["bias", "B-field", "B-field²", "B-field³"];
72
73// ─── Seeded PRNG (xorshift64) ──────────────────────────────────────────────────
74
75/// Minimal seeded pseudo-random number generator.
76///
77/// Uses xorshift64: no heap allocations, no platform-specific dependencies,
78/// and produces identical output on `wasm32-wasip2` and `x86_64`.
79///
80/// NOT cryptographically secure — only for reproducible synthetic data.
81pub struct Rng {
82    state: u64,
83}
84
85impl Rng {
86    /// Create a new RNG seeded with `seed`.
87    ///
88    /// The seed is mixed with a constant to avoid degenerate all-zero state.
89    /// Eight warm-up steps eliminate any bias from the initial seed mix.
90    pub fn new(seed: u64) -> Self {
91        let state = seed ^ 0x6C62_272E_07BB_0142;
92        let state = if state == 0 {
93            0x6C62_272E_07BB_0142
94        } else {
95            state
96        };
97        let mut rng = Self { state };
98        for _ in 0..8 {
99            rng.next_u64();
100        }
101        rng
102    }
103
104    /// Advance state and return the next u64.
105    #[inline]
106    pub fn next_u64(&mut self) -> u64 {
107        self.state ^= self.state << 13;
108        self.state ^= self.state >> 7;
109        self.state ^= self.state << 17;
110        self.state
111    }
112
113    /// Uniform sample in `[min, max)`.
114    pub fn uniform(&mut self, min: f64, max: f64) -> f64 {
115        // Map 53-bit mantissa integer to [0.0, 1.0)
116        let bits = self.next_u64() >> 11;
117        let u = bits as f64 * (1.0 / 9_007_199_254_740_992.0_f64);
118        min + u * (max - min)
119    }
120
121    /// Standard normal sample via Box-Muller transform.
122    ///
123    /// Avoids `ln(0)` by clamping the uniform draw from below.
124    pub fn normal(&mut self) -> f64 {
125        let u1 = self.uniform(1e-15, 1.0);
126        let u2 = self.uniform(0.0, 2.0 * PI);
127        (-2.0 * u1.ln()).sqrt() * u2.cos()
128    }
129}
130
131// ─── Feature function ──────────────────────────────────────────────────────────
132
133/// Map a B-field value to the BLR feature vector.
134///
135/// Returns `[1.0, B_norm, B_norm², B_norm³]` where `B_norm = b_mt / B_MAX_MT`.
136///
137/// # Arguments
138/// - `b_mt` — B-field value in milli-Tesla.
139///
140/// # Example
141/// ```rust
142/// use blr_core::synthetic_data::hall_feature_fn;
143/// let phi = hall_feature_fn(50.0);
144/// assert!((phi[0] - 1.0).abs() < 1e-14);  // bias = 1
145/// assert!((phi[1] - 0.5).abs() < 1e-14);  // B_norm = 0.5
146/// assert!((phi[2] - 0.25).abs() < 1e-14); // B_norm² = 0.25
147/// assert!((phi[3] - 0.125).abs() < 1e-14);// B_norm³ = 0.125
148/// ```
149pub fn hall_feature_fn(b_mt: f64) -> Vec<f64> {
150    let b = b_mt / B_MAX_MT;
151    vec![1.0, b, b * b, b * b * b]
152}
153
154// ─── Hall model ────────────────────────────────────────────────────────────────
155
156/// True (noiseless) Hall sensor voltage at B-field `b_mt` mT.
157///
158/// `V_true(B) = K0 + K1 × (B / B_MAX_MT)`
159pub fn hall_voltage_true(b_mt: f64) -> f64 {
160    K0 + K1 * (b_mt / B_MAX_MT)
161}
162
163// ─── Data generation ───────────────────────────────────────────────────────────
164
165/// Generate `n` synthetic Hall-sensor measurements with Gaussian noise.
166///
167/// # Arguments
168/// - `n`         — number of (B, V) pairs to generate.
169/// - `b_min_mt`  — lower bound of B-field range in mT.
170/// - `b_max_mt`  — upper bound of B-field range in mT.
171/// - `noise_std` — Gaussian noise standard deviation (V).
172/// - `seed`      — RNG seed for deterministic reproducibility.
173///
174/// # Returns
175/// `(b_values, voltages)` — each a `Vec<f64>` of length `n`.
176///
177/// B-field values are sampled uniformly in `[b_min_mt, b_max_mt)`;
178/// voltages follow `V = K0 + K1×B_norm + N(0, noise_std²)`.
179///
180/// # Example
181/// ```rust
182/// use blr_core::synthetic_data::{generate_hall_samples, GROUND_TRUTH_NOISE_STD};
183///
184/// let (bs, vs) = generate_hall_samples(20, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 0);
185/// assert_eq!(bs.len(), 20);
186/// // Voltages should be near K0 + K1*B_norm ± 3σ
187/// for (&b, &v) in bs.iter().zip(vs.iter()) {
188///     let v_true = 0.5 + 0.5 * (b / 100.0);
189///     assert!((v - v_true).abs() < 0.05, "voltage too far from truth");
190/// }
191/// ```
192pub fn generate_hall_samples(
193    n: usize,
194    b_min_mt: f64,
195    b_max_mt: f64,
196    noise_std: f64,
197    seed: u64,
198) -> (Vec<f64>, Vec<f64>) {
199    let mut rng = Rng::new(seed);
200    let mut b_values = Vec::with_capacity(n);
201    let mut voltages = Vec::with_capacity(n);
202
203    for _ in 0..n {
204        let b = rng.uniform(b_min_mt, b_max_mt);
205        let v = hall_voltage_true(b) + noise_std * rng.normal();
206        b_values.push(b);
207        voltages.push(v);
208    }
209
210    (b_values, voltages)
211}
212
213/// Build a row-major feature matrix φ (N×D) from B-field values.
214///
215/// Each row `i` is `hall_feature_fn(b_vals[i])`.
216/// Layout: `phi[i * D + j]` = feature `j` for sample `i`.
217pub fn build_phi(b_vals: &[f64]) -> Vec<f64> {
218    let n = b_vals.len();
219    let mut phi = Vec::with_capacity(n * N_FEATURES);
220    for &b in b_vals {
221        phi.extend_from_slice(&hall_feature_fn(b));
222    }
223    phi
224}
225
226// ─── Tests ─────────────────────────────────────────────────────────────────────
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_rng_deterministic() {
234        let mut r1 = Rng::new(42);
235        let mut r2 = Rng::new(42);
236        for _ in 0..100 {
237            assert_eq!(r1.next_u64(), r2.next_u64());
238        }
239    }
240
241    #[test]
242    fn test_rng_different_seeds() {
243        let mut r1 = Rng::new(1);
244        let mut r2 = Rng::new(2);
245        // Very unlikely to be equal for first 10 samples
246        let same = (0..10).all(|_| r1.next_u64() == r2.next_u64());
247        assert!(!same, "Different seeds produced identical output");
248    }
249
250    #[test]
251    fn test_hall_feature_fn() {
252        let phi = hall_feature_fn(0.0);
253        assert_eq!(phi.len(), N_FEATURES);
254        assert!((phi[0] - 1.0).abs() < 1e-14);
255        assert!((phi[1] - 0.0).abs() < 1e-14);
256        assert!((phi[2] - 0.0).abs() < 1e-14);
257        assert!((phi[3] - 0.0).abs() < 1e-14);
258
259        let phi100 = hall_feature_fn(100.0);
260        assert!((phi100[1] - 1.0).abs() < 1e-14);
261        assert!((phi100[2] - 1.0).abs() < 1e-14);
262        assert!((phi100[3] - 1.0).abs() < 1e-14);
263
264        let phi50 = hall_feature_fn(50.0);
265        assert!((phi50[1] - 0.5).abs() < 1e-14);
266        assert!((phi50[2] - 0.25).abs() < 1e-14);
267        assert!((phi50[3] - 0.125).abs() < 1e-14);
268    }
269
270    #[test]
271    fn test_hall_voltage_true_range() {
272        // V(0mT) = K0 = 0.5V
273        assert!((hall_voltage_true(0.0) - 0.5).abs() < 1e-14);
274        // V(100mT) = K0 + K1 = 1.0V
275        assert!((hall_voltage_true(100.0) - 1.0).abs() < 1e-14);
276        // V(50mT) = 0.75V
277        assert!((hall_voltage_true(50.0) - 0.75).abs() < 1e-14);
278    }
279
280    #[test]
281    fn test_generate_hall_samples_length() {
282        let (bs, vs) = generate_hall_samples(20, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 42);
283        assert_eq!(bs.len(), 20);
284        assert_eq!(vs.len(), 20);
285    }
286
287    #[test]
288    fn test_generate_hall_samples_deterministic() {
289        let (bs1, vs1) = generate_hall_samples(10, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 99);
290        let (bs2, vs2) = generate_hall_samples(10, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 99);
291        assert_eq!(bs1, bs2);
292        assert_eq!(vs1, vs2);
293    }
294
295    #[test]
296    fn test_generate_hall_samples_range() {
297        let (bs, _vs) = generate_hall_samples(100, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 7);
298        for b in &bs {
299            assert!(*b >= 0.0 && *b < 100.0, "B out of range: {b}");
300        }
301    }
302
303    #[test]
304    fn test_generate_hall_samples_close_to_truth() {
305        let (bs, vs) = generate_hall_samples(1000, 0.0, 100.0, GROUND_TRUTH_NOISE_STD, 42);
306        let mut max_err = 0.0_f64;
307        for (&b, &v) in bs.iter().zip(vs.iter()) {
308            let err = (v - hall_voltage_true(b)).abs();
309            if err > max_err {
310                max_err = err;
311            }
312        }
313        // 6-sigma bound: max_err < 6 * 0.008 = 0.048V (essentially certain for N=1000)
314        assert!(
315            max_err < 0.1,
316            "Max error too large: {max_err:.4} (expected < 0.1 for noise_std=0.008)"
317        );
318    }
319
320    #[test]
321    fn test_build_phi_shape() {
322        let bs = vec![0.0, 50.0, 100.0];
323        let phi = build_phi(&bs);
324        assert_eq!(phi.len(), 3 * N_FEATURES);
325        // Row 1 (B=50mT): [1, 0.5, 0.25, 0.125]
326        assert!((phi[N_FEATURES + 1] - 0.5).abs() < 1e-12);
327    }
328}