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}