use serde::{Deserialize, Serialize};
pub const ADC_FULL_SCALE_T: f64 = 10.0e-6;
pub const ADC_BITS: u32 = 16;
pub const ADC_LSB_T: f64 = ADC_FULL_SCALE_T / 32_767.0;
pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
if !b_in_t.is_finite() {
return (0, true);
}
let code_f = (b_in_t / ADC_LSB_T).round();
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; let min_code = -max_code; if code_f >= max_code as f64 {
(max_code, true)
} else if code_f <= min_code as f64 {
(min_code, true)
} else {
(code_f as i32, false)
}
}
#[inline]
pub fn adc_dequantise(code: i32) -> f64 {
code as f64 * ADC_LSB_T
}
#[derive(Debug, Clone, Copy)]
pub struct LowPass {
alpha: f64,
last: f64,
}
impl LowPass {
pub fn new(f_c_hz: f64, f_s_hz: f64) -> Self {
let alpha = 1.0 - (-2.0 * std::f64::consts::PI * f_c_hz / f_s_hz).exp();
Self { alpha, last: 0.0 }
}
pub fn process(&mut self, x: f64) -> f64 {
let y = self.alpha * x + (1.0 - self.alpha) * self.last;
self.last = y;
y
}
}
#[derive(Debug, Clone, Copy)]
pub struct Lockin {
f_mod_hz: f64,
f_s_hz: f64,
sample_idx: u64,
lp: LowPass,
}
impl Lockin {
pub fn new(f_mod_hz: f64, f_s_hz: f64) -> Self {
Self {
f_mod_hz,
f_s_hz,
sample_idx: 0,
lp: LowPass::new(f_s_hz / 1000.0, f_s_hz),
}
}
pub fn process(&mut self, x: f64) -> f64 {
let t = self.sample_idx as f64 / self.f_s_hz;
self.sample_idx = self.sample_idx.wrapping_add(1);
let reference = (2.0 * std::f64::consts::PI * self.f_mod_hz * t).cos();
let product = x * reference;
2.0 * self.lp.process(product)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct DigitiserConfig {
pub f_s_hz: f64,
pub f_mod_hz: f64,
}
impl Default for DigitiserConfig {
fn default() -> Self {
Self {
f_s_hz: DEFAULT_SAMPLE_RATE_HZ,
f_mod_hz: DEFAULT_F_MOD_HZ,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn adc_round_trip_within_half_lsb() {
let inputs = [0.0, 1.5e-7, -3.2e-7, 1.0e-6, -9.0e-6];
for &b in &inputs {
let (code, saturated) = adc_quantise(b);
assert!(!saturated);
let recovered = adc_dequantise(code);
assert!(
(recovered - b).abs() <= ADC_LSB_T * 0.5,
"round-trip error {} > 0.5 LSB for input {b}",
recovered - b
);
}
}
#[test]
fn adc_quantise_flags_non_finite_as_saturated() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (code, sat) = adc_quantise(bad);
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
assert!(sat, "non-finite input {bad} must raise the saturation flag");
}
let (_, sat) = adc_quantise(1.0e-7);
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
}
#[test]
fn adc_saturates_above_full_scale() {
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
let (code_neg, sat_neg) = adc_quantise(-20.0e-6);
assert!(sat_pos);
assert!(sat_neg);
let max_code = (1_i32 << (ADC_BITS - 1)) - 1;
assert_eq!(code_pos, max_code);
assert_eq!(code_neg, -max_code);
}
#[test]
fn low_pass_dc_gain_is_unity() {
let mut lp = LowPass::new(100.0, 10_000.0);
let mut last = 0.0;
for _ in 0..1000 {
last = lp.process(1.0);
}
assert_relative_eq!(last, 1.0, max_relative = 1e-3);
}
#[test]
fn low_pass_attenuates_above_cutoff() {
let f_s = 10_000.0;
let f_c = 100.0;
let f_test = 5_000.0;
let mut lp = LowPass::new(f_c, f_s);
let n = 4096;
let mut peak = 0.0_f64;
for i in 0..n {
let t = i as f64 / f_s;
let x = (2.0 * std::f64::consts::PI * f_test * t).sin();
let y = lp.process(x);
if i > n / 2 {
peak = peak.max(y.abs());
}
}
let atten_db = 20.0 * peak.log10().abs(); assert!(
atten_db >= 30.0,
"low-pass attenuation {atten_db:.1} dB at f_s/2 < 30 dB threshold"
);
}
#[test]
fn lockin_recovers_in_phase_amplitude() {
let f_mod = 1_000.0;
let f_s = 10_000.0;
let mut lockin = Lockin::new(f_mod, f_s);
let n = (f_s as usize) * 2; let mut last = 0.0;
for i in 0..n {
let t = i as f64 / f_s;
let x = (2.0 * std::f64::consts::PI * f_mod * t).cos();
last = lockin.process(x);
}
assert!(
(last - 1.0).abs() < 0.1,
"lockin recovered {last}, expected ~1.0"
);
}
#[test]
fn lockin_rejects_off_resonance_signal() {
let f_mod = 1_000.0;
let f_off = 3_000.0;
let f_s = 10_000.0;
let mut lockin = Lockin::new(f_mod, f_s);
let n = (f_s as usize) * 2;
let mut last = 0.0;
for i in 0..n {
let t = i as f64 / f_s;
let x = (2.0 * std::f64::consts::PI * f_off * t).cos();
last = lockin.process(x);
}
assert!(last.abs() < 0.1, "off-resonance output {last} should be ~0");
}
}