Skip to main content

sidereon_core/estimation/
primitives.rs

1//! Estimation and detection primitives.
2//!
3//! The primitives here are intentionally scalar, deterministic, and dependency-light.
4//! They are a direct implementation point for embedded and desktop callers.
5//!
6//! ## Provenance
7//!
8//! - Alpha-beta and equivalent scalar 2-state constant-velocity Kalman gains use the
9//!   Bar-Shalom steady-state family relations.
10//! - Innovation gating and NIS monitoring follow the standard chi-square gate
11//!   construction.
12//! - CFAR formulas use the cell-averaging exponential-tail model from Richards.
13//! - MAD and EWMA are the canonical scalar definitions used in this repository's
14//!   robust-statistics layer, with the Gaussian consistency constant for MAD
15//!   `1.482602...`.
16
17use crate::quality;
18
19/// Public error for estimation/detection primitive validation failures.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PrimitiveError {
22    /// Input field could not be used as given.
23    InvalidInput {
24        /// Field name in the failing input.
25        field: &'static str,
26        /// Human-readable reason for the failure.
27        reason: &'static str,
28    },
29}
30
31impl core::fmt::Display for PrimitiveError {
32    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
33        match self {
34            Self::InvalidInput { field, reason } => {
35                write!(f, "invalid input {field}: {reason}")
36            }
37        }
38    }
39}
40
41impl std::error::Error for PrimitiveError {}
42
43/// State of a scalar level + rate estimator.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct AlphaBetaState {
46    /// Level estimate (e.g., position).
47    pub level: f64,
48    /// Rate estimate (e.g., velocity).
49    pub rate: f64,
50}
51
52/// Alpha-beta steady-state gain set for one scalar channel.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct AlphaBetaGains {
55    /// Level gain `α`.
56    pub alpha: f64,
57    /// Rate gain `β`, which maps innovation by `β * innovation / dt`.
58    pub beta: f64,
59}
60
61/// One alpha-beta update result.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct AlphaBetaStep {
64    /// Predicted state before the measurement is applied.
65    pub predicted: AlphaBetaState,
66    /// Corrected state after applying the measurement.
67    pub updated: AlphaBetaState,
68    /// Innovation `z - level_pred`.
69    pub innovation: f64,
70}
71
72/// Steady-state gains of the equivalent 2-state scalar Kalman model.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct ScalarKalmanGains {
75    /// Position gain `K_x`.
76    pub position_gain: f64,
77    /// Rate gain `K_v` in 1/time units.
78    pub rate_gain: f64,
79}
80
81/// Result of an NIS gate test.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct NisGate {
84    /// Normalized innovation squared statistic.
85    pub nis: f64,
86    /// Chi-square threshold for the selected false-alarm probability and DOF.
87    pub threshold: f64,
88    /// `true` when `nis <= threshold`.
89    pub in_gate: bool,
90    /// Measurement dimension used by the gate.
91    pub dof: usize,
92}
93
94/// MAD-Gaussian consistency factor `1 / Φ^{-1}(3/4)`.
95pub const MAD_GAUSSIAN_CONSISTENCY: f64 = 1.482_602_218_505_602;
96
97/// Compute steady-state alpha-beta gains from the tracking index `λ`.
98///
99/// Inputs:
100///
101/// - `tracking_index > 0`, dimensionless.
102///
103/// Output:
104///
105/// - `α` and `β` from the Bar-Shalom steady-state equations.
106///
107/// The pair is linked by `β^2 = λ (1 - α)` and the closure equation
108/// `(2 - α) sqrt(λ(1 - α)) = α^2 + λ(1 - α)/6`.
109///
110/// Embedded consumers can keep the output as fixed-point coefficients for the
111/// same predict/update form.
112pub fn alpha_beta_steady_state_gains(
113    tracking_index: f64,
114) -> Result<AlphaBetaGains, PrimitiveError> {
115    validate_finite_positive(tracking_index, "tracking_index")?;
116    let alpha = alpha_beta_steady_state_alpha(tracking_index)?;
117    let beta_sq = tracking_index * (1.0 - alpha);
118    let beta = beta_sq.sqrt();
119    Ok(AlphaBetaGains { alpha, beta })
120}
121
122/// Predict step for scalar alpha-beta.
123///
124/// `x⁻ = x + dt * v`, `v⁻ = v`.
125///
126/// The predict step is the same as a constant-rate model projection.
127pub fn alpha_beta_predict(
128    state: AlphaBetaState,
129    dt: f64,
130) -> Result<AlphaBetaState, PrimitiveError> {
131    validate_finite_positive(dt, "dt")?;
132    Ok(AlphaBetaState {
133        level: state.level + dt * state.rate,
134        rate: state.rate,
135    })
136}
137
138/// Apply one measurement update on a predicted scalar alpha-beta state.
139///
140/// Update equations:
141///
142/// `x = x⁻ + α (z - x⁻)`  
143/// `v = v⁻ + β/dt (z - x⁻)`.
144pub fn alpha_beta_apply_measurement(
145    predicted: AlphaBetaState,
146    measurement: f64,
147    dt: f64,
148    gains: AlphaBetaGains,
149) -> Result<AlphaBetaState, PrimitiveError> {
150    validate_finite(predicted.level, "predicted_level")?;
151    validate_finite(predicted.rate, "predicted_rate")?;
152    validate_finite(measurement, "measurement")?;
153    validate_finite_positive(gains.alpha, "alpha")?;
154    validate_finite_nonnegative(gains.beta, "beta")?;
155    if gains.alpha > 1.0 {
156        return Err(invalid_input("alpha", "must be <= 1"));
157    }
158    validate_finite_positive(dt, "dt")?;
159
160    let innovation = measurement - predicted.level;
161    Ok(AlphaBetaState {
162        level: predicted.level + gains.alpha * innovation,
163        rate: predicted.rate + (gains.beta / dt) * innovation,
164    })
165}
166
167/// Run one alpha-beta predict/update step from prior state to posterior state.
168pub fn alpha_beta_filter_step(
169    state: AlphaBetaState,
170    measurement: f64,
171    dt: f64,
172    gains: AlphaBetaGains,
173) -> Result<AlphaBetaStep, PrimitiveError> {
174    let predicted = alpha_beta_predict(state, dt)?;
175    let updated = alpha_beta_apply_measurement(predicted, measurement, dt, gains)?;
176    let innovation = measurement - predicted.level;
177    Ok(AlphaBetaStep {
178        predicted,
179        updated,
180        innovation,
181    })
182}
183
184/// Steady-state gains for a 2-state scalar constant-velocity Kalman filter.
185///
186/// The process model is
187///
188/// `x_{k+1} = x_k + dt * v_k + 0.5 dt^2 w_k`  
189/// `v_{k+1} = v_k + dt w_k`  
190///
191/// with
192/// `Q = q [[dt^3/3, dt^2/2], [dt^2/2, dt]]` and `q = λ * R / dt^3`.
193///
194/// The equivalent relation to alpha-beta is `position_gain = α` and
195/// `rate_gain * dt = β`.
196pub fn kalman_cv_steady_state_gains(
197    tracking_index: f64,
198    dt: f64,
199    measurement_variance: f64,
200) -> Result<ScalarKalmanGains, PrimitiveError> {
201    validate_finite_positive(tracking_index, "tracking_index")?;
202    validate_finite_positive(dt, "dt")?;
203    validate_finite_positive(measurement_variance, "measurement_variance")?;
204
205    let q = tracking_index * measurement_variance / dt.powi(3);
206    let q11 = q * dt;
207    let q10 = q * dt * dt / 2.0;
208    let q00 = q * dt * dt * dt / 3.0;
209
210    let mut p00 = measurement_variance;
211    let mut p01 = 0.0;
212    let mut p11 = measurement_variance;
213    for _ in 0..5_000 {
214        let p00_pred = p00 + 2.0 * dt * p01 + dt * dt * p11 + q00;
215        let p01_pred = p01 + dt * p11 + q10;
216        let p11_pred = p11 + q11;
217
218        let s = p00_pred + measurement_variance;
219        validate_finite_positive(s, "measurement_gate_denominator")?;
220
221        let k0 = p00_pred / s;
222        let k1 = p01_pred / s;
223
224        let p00_next = (1.0 - k0) * p00_pred;
225        let p01_next = p01_pred - k1 * p00_pred;
226        let p11_next = p11_pred - k1 * p01_pred;
227
228        let delta = (p00_next - p00)
229            .abs()
230            .max((p01_next - p01).abs())
231            .max((p11_next - p11).abs());
232        p00 = p00_next;
233        p01 = p01_next;
234        p11 = p11_next;
235
236        if delta <= 1.0e-15 * measurement_variance {
237            break;
238        }
239    }
240
241    let p00_pred = p00 + 2.0 * dt * p01 + dt * dt * p11 + q00;
242    let p01_pred = p01 + dt * p11 + q10;
243    let s = p00_pred + measurement_variance;
244    let position_gain = p00_pred / s;
245    let rate_gain = p01_pred / s;
246    Ok(ScalarKalmanGains {
247        position_gain,
248        rate_gain,
249    })
250}
251
252/// Scalar normalized innovation `ν / sqrt(S)`.
253pub fn normalized_innovation(
254    innovation: f64,
255    innovation_variance: f64,
256) -> Result<f64, PrimitiveError> {
257    validate_finite(innovation, "innovation")?;
258    validate_finite_positive(innovation_variance, "innovation_variance")?;
259    Ok(innovation / innovation_variance.sqrt())
260}
261
262/// Scalar normalized innovation squared statistic.
263pub fn nis_statistic(innovation: f64, innovation_variance: f64) -> Result<f64, PrimitiveError> {
264    let normalized = normalized_innovation(innovation, innovation_variance)?;
265    Ok(normalized * normalized)
266}
267
268/// Bar-Shalom chi-square expectation `E[NIS] = m`.
269pub fn nis_expected_value(dof: usize) -> Result<f64, PrimitiveError> {
270    if dof == 0 {
271        return Err(invalid_input("dof", "must be positive"));
272    }
273    Ok(dof as f64)
274}
275
276/// Chi-square gate threshold for the requested confidence and DOF.
277pub fn nis_gate_threshold(dof: usize, confidence: f64) -> Result<f64, PrimitiveError> {
278    if dof == 0 {
279        return Err(invalid_input("dof", "must be positive"));
280    }
281    validate_probability(confidence, "confidence")?;
282    quality::chi2_inv(confidence, dof).map_err(|_| invalid_input("confidence", "no chi2 inverse"))
283}
284
285/// Test a measurement residual against a chi-square gate.
286pub fn nis_gate_test(
287    innovation: f64,
288    innovation_variance: f64,
289    dof: usize,
290    confidence: f64,
291) -> Result<NisGate, PrimitiveError> {
292    let nis = nis_statistic(innovation, innovation_variance)?;
293    let threshold = nis_gate_threshold(dof, confidence)?;
294    Ok(NisGate {
295        nis,
296        threshold,
297        in_gate: nis <= threshold,
298        dof,
299    })
300}
301
302/// EWMA update with generic gain `alpha`.
303pub fn ewma_update(previous: f64, sample: f64, alpha: f64) -> Result<f64, PrimitiveError> {
304    validate_finite(previous, "previous")?;
305    validate_finite(sample, "sample")?;
306    validate_fraction_or_one(alpha, "alpha")?;
307    Ok(previous + alpha * (sample - previous))
308}
309
310/// EWMA update with power-of-two denominator, `alpha = 1 / 2^shift`.
311///
312/// Integer-friendly form:
313/// `(1 - alpha) y[n-1] + alpha x[n]`
314/// -> `((2^shift - 1) y[n-1] + x[n]) / 2^shift`.
315pub fn ewma_update_power_of_two(
316    previous: f64,
317    sample: f64,
318    shift: u32,
319) -> Result<f64, PrimitiveError> {
320    validate_finite(previous, "previous")?;
321    validate_finite(sample, "sample")?;
322    if shift > 52 {
323        return Err(invalid_input("shift", "must be <= 52"));
324    }
325    let denominator = 1u64 << shift;
326    let denominator = denominator as f64;
327    let gain = 1.0 / denominator;
328    if (denominator - 1.0).abs() < f64::EPSILON {
329        Ok(sample)
330    } else {
331        Ok(previous + gain * (sample - previous))
332    }
333}
334
335/// Median absolute deviation spread estimate with Gaussian consistency scaling.
336pub fn mad_spread(values: &[f64], scale_floor: f64) -> Result<f64, PrimitiveError> {
337    validate_finite_nonnegative(scale_floor, "scale_floor")?;
338    let median_all = median(values)?;
339    let mut deviations = values
340        .iter()
341        .map(|value| (value - median_all).abs())
342        .collect::<Vec<_>>();
343    if deviations.is_empty() {
344        return Err(invalid_input("values", "must not be empty"));
345    }
346    deviations.sort_by(|a, b| a.total_cmp(b));
347    let n = deviations.len();
348    let mad = if n % 2 == 1 {
349        deviations[n / 2]
350    } else {
351        (deviations[n / 2 - 1] + deviations[n / 2]) * 0.5
352    };
353    let scaled = MAD_GAUSSIAN_CONSISTENCY * mad;
354    if scaled > scale_floor {
355        Ok(scaled)
356    } else {
357        Ok(scale_floor)
358    }
359}
360
361/// CA-CFAR multiplier from target false-alarm probability.
362///
363/// For exponential noise samples and a search window `N`:
364/// `Pfa = (1 + T/N)^(-N)` where `T` is the threshold multiplier on mean noise.
365pub fn cfar_ca_multiplier_from_pfa(
366    searched_cells: usize,
367    false_alarm_probability: f64,
368) -> Result<f64, PrimitiveError> {
369    if searched_cells == 0 {
370        return Err(invalid_input("searched_cells", "must be positive"));
371    }
372    validate_probability(false_alarm_probability, "false_alarm_probability")?;
373    let n = searched_cells as f64;
374    let inv = 1.0 / false_alarm_probability.powf(1.0 / n);
375    Ok(n * (inv - 1.0))
376}
377
378/// CA-CFAR inverse map from threshold multiplier to false-alarm probability.
379pub fn cfar_ca_pfa_from_multiplier(
380    searched_cells: usize,
381    multiplier: f64,
382) -> Result<f64, PrimitiveError> {
383    if searched_cells == 0 {
384        return Err(invalid_input("searched_cells", "must be positive"));
385    }
386    validate_finite_positive(multiplier, "multiplier")?;
387    let n = searched_cells as f64;
388    let one_plus = 1.0 + multiplier / n;
389    if !one_plus.is_finite() {
390        return Err(invalid_input("multiplier", "not finite"));
391    }
392    Ok(one_plus.powf(-n))
393}
394
395/// CA-CFAR absolute threshold from noise level, search window, and false-alarm target.
396pub fn cfar_ca_threshold(
397    searched_cells: usize,
398    false_alarm_probability: f64,
399    noise_level: f64,
400) -> Result<f64, PrimitiveError> {
401    validate_finite_positive(noise_level, "noise_level")?;
402    let multiplier = cfar_ca_multiplier_from_pfa(searched_cells, false_alarm_probability)?;
403    Ok(noise_level * multiplier)
404}
405
406/// CA-CFAR inverse map from absolute threshold to false-alarm probability.
407pub fn cfar_ca_false_alarm_probability(
408    searched_cells: usize,
409    threshold: f64,
410    noise_level: f64,
411) -> Result<f64, PrimitiveError> {
412    validate_finite_positive(noise_level, "noise_level")?;
413    validate_finite_positive(threshold, "threshold")?;
414    let multiplier = threshold / noise_level;
415    cfar_ca_pfa_from_multiplier(searched_cells, multiplier)
416}
417
418fn alpha_beta_steady_state_alpha(tracking_index: f64) -> Result<f64, PrimitiveError> {
419    let mut roots = Vec::new();
420    let start = f64::from_bits(1);
421    let end = 1.0 - start;
422    let steps = 16_384usize;
423
424    let mut previous_alpha = start;
425    let mut previous_value = alpha_beta_equation(tracking_index, previous_alpha);
426    if previous_value == 0.0 {
427        return Ok(previous_alpha);
428    }
429
430    for idx in 1..=steps {
431        let current_alpha = start + (end - start) * idx as f64 / steps as f64;
432        let current_value = alpha_beta_equation(tracking_index, current_alpha);
433        if current_value == 0.0 {
434            roots.push(current_alpha);
435        }
436        if previous_value * current_value < 0.0 {
437            let root = bisect_alpha_root(tracking_index, previous_alpha, current_alpha);
438            roots.push(root);
439        }
440        previous_alpha = current_alpha;
441        previous_value = current_value;
442    }
443
444    let alpha = roots.last().copied().ok_or_else(|| {
445        invalid_input(
446            "tracking_index",
447            "no valid steady-state alpha root in (0,1)",
448        )
449    })?;
450    Ok(alpha)
451}
452
453fn alpha_beta_equation(tracking_index: f64, alpha: f64) -> f64 {
454    let one_minus_alpha = 1.0 - alpha;
455    let spread = (tracking_index * one_minus_alpha).sqrt();
456    (2.0 - alpha) * spread - (alpha * alpha + tracking_index * one_minus_alpha / 6.0)
457}
458
459fn bisect_alpha_root(tracking_index: f64, mut left: f64, mut right: f64) -> f64 {
460    let mut f_left = alpha_beta_equation(tracking_index, left);
461    let mut f_right = alpha_beta_equation(tracking_index, right);
462    for _ in 0..80 {
463        let mid = 0.5 * (left + right);
464        let f_mid = alpha_beta_equation(tracking_index, mid);
465        if f_left * f_mid <= 0.0 {
466            right = mid;
467            f_right = f_mid;
468        } else {
469            left = mid;
470            f_left = f_mid;
471        }
472        if f_left == 0.0 || f_right == 0.0 {
473            return 0.5 * (left + right);
474        }
475    }
476    0.5 * (left + right)
477}
478
479fn median(values: &[f64]) -> Result<f64, PrimitiveError> {
480    if values.is_empty() {
481        return Err(invalid_input("values", "must not be empty"));
482    }
483    for value in values {
484        validate_finite(*value, "values")?;
485    }
486    let mut sorted = values.to_vec();
487    sorted.sort_by(|a, b| a.total_cmp(b));
488    let n = sorted.len();
489    Ok(if n % 2 == 1 {
490        sorted[n / 2]
491    } else {
492        (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
493    })
494}
495
496fn validate_finite(value: f64, field: &'static str) -> Result<(), PrimitiveError> {
497    if value.is_finite() {
498        Ok(())
499    } else {
500        Err(invalid_input(field, "not finite"))
501    }
502}
503
504fn validate_finite_positive(value: f64, field: &'static str) -> Result<(), PrimitiveError> {
505    validate_finite(value, field)?;
506    if value > 0.0 {
507        Ok(())
508    } else {
509        Err(invalid_input(field, "must be positive"))
510    }
511}
512
513fn validate_finite_nonnegative(value: f64, field: &'static str) -> Result<(), PrimitiveError> {
514    validate_finite(value, field)?;
515    if value >= 0.0 {
516        Ok(())
517    } else {
518        Err(invalid_input(field, "must be non-negative"))
519    }
520}
521
522fn validate_fraction_or_one(value: f64, field: &'static str) -> Result<(), PrimitiveError> {
523    validate_finite(value, field)?;
524    if (0.0..=1.0).contains(&value) {
525        Ok(())
526    } else {
527        Err(invalid_input(field, "must be in [0,1]"))
528    }
529}
530
531fn validate_probability(value: f64, field: &'static str) -> Result<(), PrimitiveError> {
532    validate_finite(value, field)?;
533    if (0.0..1.0).contains(&value) {
534        Ok(())
535    } else {
536        Err(invalid_input(field, "must be in (0,1)"))
537    }
538}
539
540const fn invalid_input(field: &'static str, reason: &'static str) -> PrimitiveError {
541    PrimitiveError::InvalidInput { field, reason }
542}
543
544#[cfg(test)]
545mod tests {
546    //! Test oracles in the order: alpha-beta and scalar Kalman equivalence, gating/NIS,
547    //! MAD/EWMA behavior, and CA-CFAR threshold inversion.
548    //!
549    //! - Bar-Shalom alpha-beta + Kalman: fixed-point and closed-form residual equation.
550    //! - Bar-Shalom NIS: chi-square thresholding and expectation.
551    //! - Richards CA-CFAR: exact analytic target false-alarm law.
552
553    use super::*;
554
555    const KALMAN_TOLERANCE: f64 = 1.0e-12;
556
557    #[test]
558    fn alpha_beta_steady_state_matches_reference_and_kalman() {
559        let gains = alpha_beta_steady_state_gains(4.0).expect("alpha-beta gains");
560        assert!((gains.alpha - 0.864_145_399_682_717_8).abs() < KALMAN_TOLERANCE);
561        assert!((gains.beta - 0.737_169_180_900_238_8).abs() < KALMAN_TOLERANCE);
562
563        let kalman =
564            kalman_cv_steady_state_gains(4.0, 1.0, 1.0).expect("kalman steady-state gains");
565        assert!((kalman.position_gain - gains.alpha).abs() < KALMAN_TOLERANCE);
566        assert!((kalman.rate_gain - gains.beta).abs() < KALMAN_TOLERANCE);
567        assert!((kalman.position_gain * 1.0 - gains.alpha).abs() < KALMAN_TOLERANCE);
568    }
569
570    #[test]
571    fn alpha_beta_step_response_settles_to_the_new_level() {
572        // Step response (Bar-Shalom): a stable alpha-beta filter at rest, fed a
573        // constant level, converges to that level with the rate estimate
574        // returning to zero and the innovation decaying to zero.
575        let gains = alpha_beta_steady_state_gains(4.0).expect("alpha-beta gains");
576        let step_level = 10.0;
577        let dt = 1.0;
578        let mut state = AlphaBetaState {
579            level: 0.0,
580            rate: 0.0,
581        };
582        let mut last_innovation = f64::INFINITY;
583        for _ in 0..300 {
584            let step = alpha_beta_filter_step(state, step_level, dt, gains).expect("step");
585            state = step.updated;
586            last_innovation = step.innovation;
587        }
588        assert!(
589            (state.level - step_level).abs() < 1e-9,
590            "level did not settle: {}",
591            state.level
592        );
593        assert!(
594            state.rate.abs() < 1e-9,
595            "rate did not settle: {}",
596            state.rate
597        );
598        assert!(
599            last_innovation.abs() < 1e-9,
600            "innovation did not decay: {}",
601            last_innovation
602        );
603    }
604
605    #[test]
606    fn alpha_beta_predict_and_update_are_dt_aware() {
607        let state = AlphaBetaState {
608            level: 5.0,
609            rate: 2.0,
610        };
611        let gains = AlphaBetaGains {
612            alpha: 0.6,
613            beta: 0.8,
614        };
615        let step = alpha_beta_filter_step(state, 8.0, 2.0, gains).expect("step");
616
617        assert_eq!(step.predicted.level, 9.0);
618        assert_eq!(step.predicted.rate, 2.0);
619        assert_eq!(step.innovation, -1.0);
620        assert_eq!(step.updated.level, 8.4);
621        assert_eq!(step.updated.rate, 1.6);
622    }
623
624    #[test]
625    fn nis_gate_threshold_and_expectation() {
626        let gate = nis_gate_test(1.0, 1.0, 1, 0.95).expect("nis gate");
627        assert!((gate.threshold - 3.841_458_820_694_124).abs() < 1.0e-12);
628        assert_eq!(gate.dof, 1);
629        assert!(gate.in_gate);
630        assert_eq!(nis_expected_value(3).expect("dof"), 3.0);
631    }
632
633    #[test]
634    fn mad_gives_expected_gaussian_scaling_and_ewma_power_of_two_matches_alpha_form() {
635        const Q75: f64 = 0.674_489_750_196_081_7;
636        let sigma_est =
637            mad_spread(&[-2.0 * Q75, -Q75, 0.0, Q75, 2.0 * Q75], 1.0e-12).expect("mad spread");
638        assert!((sigma_est - 1.0).abs() < 1.0e-12);
639        assert!((mad_spread(&[0.0, 0.0, 0.0], 1.2).expect("floored mad") - 1.2).abs() < 1.0e-12);
640
641        let previous = 16.0;
642        let sample = 2.0;
643        let alpha = 1.0 / 16.0;
644        let ewma_alpha = ewma_update(previous, sample, alpha).expect("ewma alpha");
645        let ewma_pow2 = ewma_update_power_of_two(previous, sample, 4).expect("ewma pow2");
646        let ewma_int = ((16.0_f64 - 1.0) * previous + sample) / 16.0;
647        assert!((ewma_alpha - ewma_pow2).abs() < 1.0e-15);
648        assert!((ewma_pow2 - ewma_int).abs() < 1.0e-15);
649        assert!((ewma_alpha - 15.125).abs() < 1.0e-12);
650    }
651
652    #[test]
653    fn cfar_threshold_and_probability_roundtrip() {
654        let noise = 5.0;
655        let pfa = 1.0e-3;
656        let searched_cells = 4usize;
657        let multiplier = cfar_ca_multiplier_from_pfa(searched_cells, pfa).expect("cfar multiplier");
658        let threshold = cfar_ca_threshold(searched_cells, pfa, noise).expect("cfar threshold");
659        let pfa_back = cfar_ca_false_alarm_probability(searched_cells, threshold, noise)
660            .expect("cfar pfa back");
661        assert!((multiplier - 18.493_653_007_613_965).abs() < 1.0e-12);
662        assert_eq!(threshold, noise * multiplier);
663        assert!((pfa_back - pfa).abs() < 1.0e-12);
664    }
665}