Skip to main content

dsfb_rf/
physics.rs

1//! Physics-of-failure mapping and semiotic horizon characterization.
2//!
3//! ## Semiotic Horizon
4//!
5//! The "semiotic horizon" defines the operating envelope within which
6//! DSFB's structural grammar produces reliable, actionable output.
7//! Outside this envelope — below the SNR floor, at extreme drift rates,
8//! or under non-stationary calibration conditions — grammar states are
9//! unreliable. Mapping this boundary explicitly is the single most
10//! credibility-building artifact for reviewers and SBIR operators.
11//!
12//! The semiotic horizon is defined in (SNR, α) space:
13//! - SNR: signal-to-noise ratio in dB
14//! - α: drift rate (residual norm units per observation)
15//!
16//! At each (SNR, α) point, the engine either:
17//! - Detects the drift → "Zone of Success" (grammar state transitions correctly)
18//! - Fails to detect → "Zone of Failure" (grammar remains Admissible)
19//!
20//! The horizon is the boundary between these zones.
21//!
22//! ## Physics-of-Failure Mapping
23//!
24//! Maps grammar states to physical mechanisms using established RF models:
25//!
26//! | Grammar State                     | Physical Mechanism          | Model Reference        |
27//! |-----------------------------------|-----------------------------|------------------------|
28//! | Boundary[SustainedOutwardDrift]    | PA thermal drift            | Arrhenius model        |
29//! | Boundary[SustainedOutwardDrift]    | LO aging                    | Allan variance         |
30//! | Boundary[AbruptSlewViolation]      | PIM onset                   | Passive intermod model |
31//! | Boundary[RecurrentBoundaryGrazing] | FHSS periodic interference  | Hop rate analysis      |
32//! | Violation                          | Jamming / intentional EMI   | J/S ratio model        |
33//! | Boundary[SustainedOutwardDrift]    | Phase noise degradation     | Leeson's model         |
34//!
35//! These mappings are **candidate hypotheses**, not attributions.
36//! Physical attribution requires domain-specific calibration data
37//! that is not available from public datasets (RadioML, ORACLE).
38//!
39//! ## Design
40//!
41//! - `no_std`, `no_alloc`, zero `unsafe`
42//! - Fixed-capacity data tables for semiotic horizon grid
43//! - Physics mapping is a static lookup (zero runtime cost)
44
45use crate::grammar::ReasonCode;
46
47// ── Semiotic Horizon ───────────────────────────────────────────────────────
48
49/// A single point in the semiotic horizon grid.
50///
51/// Records whether the DSFB grammar correctly detects a structural
52/// drift at a given (SNR, drift_rate) operating point.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct HorizonPoint {
55    /// SNR in dB.
56    pub snr_db: f32,
57    /// Drift rate α (residual norm units per observation).
58    pub drift_rate: f32,
59    /// Whether the grammar correctly entered Boundary/Violation within
60    /// the detection window (true = success, false = missed).
61    pub detected: bool,
62    /// Number of observations to first detection (0 if not detected).
63    pub detection_latency: u32,
64}
65
66/// Fixed-capacity semiotic horizon grid.
67///
68/// Stores detection results across a sweep of (SNR, α) operating points.
69/// Used to generate the "Horizon of Failure" heatmap artifact.
70pub struct SemioticHorizon<const N: usize> {
71    /// Grid points.
72    points: [HorizonPoint; N],
73    /// Number of populated points.
74    count: usize,
75}
76
77impl<const N: usize> SemioticHorizon<N> {
78    /// Create an empty horizon grid.
79    pub const fn new() -> Self {
80        Self {
81            points: [HorizonPoint {
82                snr_db: 0.0,
83                drift_rate: 0.0,
84                detected: false,
85                detection_latency: 0,
86            }; N],
87            count: 0,
88        }
89    }
90
91    /// Record a detection result at (snr_db, drift_rate).
92    pub fn record(&mut self, snr_db: f32, drift_rate: f32, detected: bool, latency: u32) -> bool {
93        if self.count >= N { return false; }
94        self.points[self.count] = HorizonPoint {
95            snr_db,
96            drift_rate,
97            detected,
98            detection_latency: latency,
99        };
100        self.count += 1;
101        true
102    }
103
104    /// Number of recorded points.
105    pub fn len(&self) -> usize { self.count }
106
107    /// Whether the grid is empty.
108    pub fn is_empty(&self) -> bool { self.count == 0 }
109
110    /// Iterator over recorded points.
111    pub fn points(&self) -> &[HorizonPoint] {
112        &self.points[..self.count]
113    }
114
115    /// Detection rate across all recorded points.
116    pub fn detection_rate(&self) -> f32 {
117        if self.count == 0 { return 0.0; }
118        let detected = self.points[..self.count].iter().filter(|p| p.detected).count();
119        detected as f32 / self.count as f32
120    }
121
122    /// Mean detection latency for detected points.
123    pub fn mean_detection_latency(&self) -> f32 {
124        let detected: &[HorizonPoint] = &self.points[..self.count];
125        let (sum, count) = detected.iter()
126            .filter(|p| p.detected && p.detection_latency > 0)
127            .fold((0u64, 0u32), |(s, c), p| (s + p.detection_latency as u64, c + 1));
128        if count == 0 { return 0.0; }
129        sum as f32 / count as f32
130    }
131}
132
133impl<const N: usize> Default for SemioticHorizon<N> {
134    fn default() -> Self { Self::new() }
135}
136
137// ── Physics-of-Failure Mapping ─────────────────────────────────────────────
138
139/// A candidate physical mechanism that may explain a grammar state.
140///
141/// These are **hypotheses**, not attributions. Physical attribution
142/// requires deployment-specific calibration data.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum PhysicalMechanism {
145    /// Power amplifier thermal drift (Arrhenius model).
146    /// Signature: persistent positive ṙ over 100–10,000 symbol periods.
147    PaThermalDrift,
148    /// Local oscillator aging (Allan variance model).
149    /// Signature: slow monotone frequency offset growth.
150    LoAging,
151    /// Passive intermodulation (PIM) onset.
152    /// Signature: abrupt slew in specific intermod frequency bands.
153    PimOnset,
154    /// Phase noise degradation (Leeson's model).
155    /// Signature: oscillatory ṙ with growing amplitude.
156    PhaseNoiseDegradation,
157    /// Intentional jamming (J/S ratio model).
158    /// Signature: abrupt, sustained high-norm residual.
159    IntentionalJamming,
160    /// Adjacent-channel interference (ACLR violation).
161    /// Signature: spectral mask approach from neighboring channel.
162    AdjacentChannelInterference,
163    /// Frequency-hopping spread-spectrum transition.
164    /// Signature: abrupt slew with rapid recovery to new baseline.
165    FhssTransition,
166    /// Antenna coupling transient.
167    /// Signature: brief abrupt slew correlated with antenna switching.
168    AntennaCouplingTransient,
169    /// Unknown mechanism — endoductive regime.
170    Unknown,
171}
172
173/// Map a grammar reason code to candidate physical mechanisms.
174///
175/// Returns the top candidate mechanisms, ordered by structural likelihood.
176/// This is a static lookup with zero runtime allocation.
177///
178/// ## Non-Attribution Policy
179///
180/// These are candidate hypotheses only. No physical attribution is made
181/// from public datasets. Field-validated attribution requires deployment-
182/// specific calibration data from the target platform.
183pub fn candidate_mechanisms(reason: ReasonCode) -> &'static [PhysicalMechanism] {
184    match reason {
185        ReasonCode::SustainedOutwardDrift => &[
186            PhysicalMechanism::PaThermalDrift,
187            PhysicalMechanism::LoAging,
188            PhysicalMechanism::AdjacentChannelInterference,
189        ],
190        ReasonCode::AbruptSlewViolation => &[
191            PhysicalMechanism::IntentionalJamming,
192            PhysicalMechanism::PimOnset,
193            PhysicalMechanism::AntennaCouplingTransient,
194        ],
195        ReasonCode::RecurrentBoundaryGrazing => &[
196            PhysicalMechanism::FhssTransition,
197            PhysicalMechanism::AdjacentChannelInterference,
198        ],
199        ReasonCode::EnvelopeViolation => &[
200            PhysicalMechanism::IntentionalJamming,
201            PhysicalMechanism::PaThermalDrift,
202        ],
203    }
204}
205
206/// Map a reason code to the primary physical model reference.
207///
208/// Returns a human-readable model name for documentation and audit trails.
209pub fn model_reference(mechanism: PhysicalMechanism) -> &'static str {
210    match mechanism {
211        PhysicalMechanism::PaThermalDrift => "Arrhenius thermal acceleration model",
212        PhysicalMechanism::LoAging => "Allan variance / frequency stability model",
213        PhysicalMechanism::PimOnset => "Passive intermodulation model (3rd/5th order)",
214        PhysicalMechanism::PhaseNoiseDegradation => "Leeson's phase noise model",
215        PhysicalMechanism::IntentionalJamming => "J/S ratio and effective radiated power model",
216        PhysicalMechanism::AdjacentChannelInterference => "3GPP TS 36.141 §6.3 ACLR model",
217        PhysicalMechanism::FhssTransition => "Hop rate and dwell time analysis",
218        PhysicalMechanism::AntennaCouplingTransient => "Coupling coefficient and VSWR model",
219        PhysicalMechanism::Unknown => "Endoductive regime — no prior model",
220    }
221}
222
223// ── Physics Model Trait ────────────────────────────────────────────────────
224//
225// Pluggable physics models that translate a measurable platform parameter
226// (temperature, observation time-base, etc.) into a predicted drift rate.
227//
228// The predicted drift rate can be compared against the DSFB-observed drift to
229// confirm or falsify a physics-of-failure hypothesis.
230//
231// References:
232//   Kayali, S. (1999) "Physics of Failure as an Underlying Principle to
233//       NASA's Reliability Assessment Method," JPL Publication 96-25, Rev. A.
234//       NASA/Goddard. (GaAs PHEMT E_a = 1.6 eV; GaN HEMT E_a = 2.1 eV)
235//   Allan, D.W. (1966) "Statistics of atomic frequency standards,"
236//       Proc. IEEE 54(2):221–230. doi:10.1109/PROC.1966.4634.
237//   IEEE Std 1193-2003, "Guide for Measurement of Environmental Sensitivities
238//       of Standard Frequency Generators."
239
240/// A pluggable physics-of-failure model that maps an observable platform
241/// parameter to a predicted RF drift rate.
242///
243/// Implementors provide the equation-of-state for a specific physical
244/// degradation mechanism so that DSFB engine observations can be falsified
245/// against first-principles models rather than purely statistical thresholds.
246pub trait PhysicsModel {
247    /// Predict the residual drift rate for the given platform parameter.
248    ///
249    /// `param` semantics are model-specific:
250    /// - `ArrheniusModel`: junction temperature in °C
251    /// - `AllanVarianceModel`: averaging time τ in seconds
252    fn predict_drift_rate(&self, param: f32) -> f32;
253
254    /// Short human-readable label for this model instance.
255    fn label(&self) -> &'static str;
256
257    /// Primary literature reference for the model.
258    fn reference(&self) -> &'static str;
259
260    /// The DSFB `ReasonCode` this model most directly corresponds to.
261    fn maps_to_reason(&self) -> ReasonCode;
262}
263
264/// Arrhenius thermal-acceleration model for semiconductor PA degradation.
265///
266/// k(T) = α₀ · exp(−E_a / (k_B · T))
267///
268/// where T is absolute temperature [K], k_B = 8.617×10⁻⁵ eV/K, and
269/// E_a is the activation energy in eV.
270///
271/// ## Pre-defined Constants
272/// - `GAAS_PHEMT`: E_a = 1.6 eV (GaAs pHEMT operating at 125°C)
273/// - `GAN_HEMT`:   E_a = 2.1 eV (GaN HEMT operating at 150°C)
274#[derive(Debug, Clone, Copy)]
275pub struct ArrheniusModel {
276    /// Pre-exponential drift-rate factor (unitless multiplier).
277    pub alpha_0: f32,
278    /// Activation energy in eV.
279    pub e_a_ev: f32,
280    /// Human-readable identifier.
281    pub label_str: &'static str,
282}
283
284impl ArrheniusModel {
285    /// GaAs pHEMT: E_a = 1.6 eV (Kayali 1999 JPL-96-25).
286    pub const GAAS_PHEMT: Self = Self {
287        alpha_0: 1.0,
288        e_a_ev: 1.6,
289        label_str: "GaAs_pHEMT_Ea=1.6eV",
290    };
291
292    /// GaN HEMT: E_a = 2.1 eV (Kayali 1999 JPL-96-25, Table 3).
293    pub const GAN_HEMT: Self = Self {
294        alpha_0: 1.0,
295        e_a_ev: 2.1,
296        label_str: "GaN_HEMT_Ea=2.1eV",
297    };
298}
299
300impl PhysicsModel for ArrheniusModel {
301    /// Temperature in Celsius → predicted drift rate (normalised, unitless).
302    fn predict_drift_rate(&self, temperature_celsius: f32) -> f32 {
303        let t_k = temperature_celsius + 273.15_f32;
304        // k_B = 8.617_333×10⁻⁵ eV/K
305        let kb = 8.617_333e-5_f32;
306        self.alpha_0 * exp_approx(-self.e_a_ev / (kb * t_k))
307    }
308
309    fn label(&self) -> &'static str { self.label_str }
310
311    fn reference(&self) -> &'static str {
312        "Kayali 1999 JPL-96-25 Arrhenius thermal acceleration model"
313    }
314
315    fn maps_to_reason(&self) -> ReasonCode { ReasonCode::SustainedOutwardDrift }
316}
317
318/// Allan variance frequency-stability model for oscillator aging.
319///
320/// σ_y²(τ) = h₀/(2τ) + h₋₁·2·ln2 + h₋₂·(2π²/3)·τ
321///
322/// ## Pre-defined Constants
323/// - `OCXO_CLASS_A`: Ultra-stable oven-controlled XO (h₀=1e-20, h₋₁=1e-22, h₋₂=1e-28)
324/// - `TCXO_GRADE_B`: Temperature-compensated XO (h₀=1e-18, h₋₁=1e-20, h₋₂=1e-26)
325#[derive(Debug, Clone, Copy)]
326pub struct AllanVarianceModel {
327    /// White phase noise coefficient h₀.
328    pub h_white: f32,
329    /// Flicker phase noise coefficient h₋₁.
330    pub h_flicker: f32,
331    /// Random walk FM noise coefficient h₋₂.
332    pub h_rw: f32,
333    /// Human-readable identifier.
334    pub label_str: &'static str,
335}
336
337impl AllanVarianceModel {
338    /// Ultra-stable OCXO Class A oscillator (normalised residual-norm units).
339    ///
340    /// h-coefficients scaled so σ_y(τ=1) ≈ 2.2×10⁻⁵ (detectable in f32).
341    pub const OCXO_CLASS_A: Self = Self {
342        h_white: 1e-9,
343        h_flicker: 1e-11,
344        h_rw: 1e-17,
345        label_str: "OCXO_Class_A",
346    };
347
348    /// GPS-grade TCXO Grade B oscillator (normalised residual-norm units).
349    ///
350    /// h-coefficients scaled so σ_y(τ=1) ≈ 2.2×10⁻⁴ (100× worse than OCXO).
351    pub const TCXO_GRADE_B: Self = Self {
352        h_white: 1e-7,
353        h_flicker: 1e-9,
354        h_rw: 1e-15,
355        label_str: "TCXO_Grade_B",
356    };
357}
358
359impl PhysicsModel for AllanVarianceModel {
360    /// Averaging time τ [s] → Allan deviation σ_y(τ) = √AVAR(τ).
361    fn predict_drift_rate(&self, tau: f32) -> f32 {
362        if tau <= 0.0 { return 0.0; }
363        // σ_y²(τ) = h₀/(2τ) + h₋₁·2ln2 + h₋₂·(2π²/3)·τ
364        let avar = self.h_white / (2.0 * tau)
365            + self.h_flicker * 2.0 * 0.693_147_f32  // 2 ln2
366            + self.h_rw * (2.0 * 9.869_604_f32 / 3.0) * tau; // 2π²/3 · τ
367        crate::math::sqrt_f32(avar.max(0.0))
368    }
369
370    fn label(&self) -> &'static str { self.label_str }
371
372    fn reference(&self) -> &'static str {
373        "Allan 1966 Proc. IEEE 54(2):221-230; IEEE Std 1193-2003"
374    }
375
376    fn maps_to_reason(&self) -> ReasonCode { ReasonCode::SustainedOutwardDrift }
377}
378
379/// Result of comparing an observed drift against a physics model prediction.
380#[derive(Debug, Clone, Copy)]
381pub struct PhysicsConsistencyResult {
382    /// Predicted drift rate from the model.
383    pub predicted_drift: f32,
384    /// Observed drift from the DSFB engine.
385    pub observed_drift: f32,
386    /// Relative deviation: |observed − predicted| / predicted.  
387    /// f32::MAX if predicted ≈ 0.
388    pub deviation_ratio: f32,
389    /// Whether observed drift is within the specified tolerance of predicted.
390    pub is_consistent: bool,
391    /// The DSFB reason code the model maps to.
392    pub reason: ReasonCode,
393}
394
395/// Compare an observed RF drift rate against a physics-model prediction.
396///
397/// - `model`:           Any `PhysicsModel` implementor (Arrhenius, Allan, etc.).
398/// - `observed_drift`:  Drift rate observed by the DSFB engine.
399/// - `platform_param`:  Parameter to feed the model (temperature °C, τ, …).
400/// - `tolerance`:       Acceptable relative deviation (e.g. 0.20 = ±20%).
401pub fn evaluate_physics_consistency(
402    model: &dyn PhysicsModel,
403    observed_drift: f32,
404    platform_param: f32,
405    tolerance: f32,
406) -> PhysicsConsistencyResult {
407    let predicted = model.predict_drift_rate(platform_param);
408    let deviation_ratio = if predicted > 1e-38 {
409        (observed_drift - predicted).abs() / predicted
410    } else {
411        f32::MAX
412    };
413    let is_consistent = deviation_ratio <= tolerance.abs();
414    PhysicsConsistencyResult {
415        predicted_drift: predicted,
416        observed_drift,
417        deviation_ratio,
418        is_consistent,
419        reason: model.maps_to_reason(),
420    }
421}
422
423// ── Private math helpers (no libm) ─────────────────────────────────────────
424
425/// exp(x) approximation without libm using exp(x) = 2^(x · log₂e).
426///
427/// Accurate to < 0.05% for |x| ≤ 40.
428fn exp_approx(x: f32) -> f32 {
429    // log₂(e) = 1/ln2 ≈ 1.442695
430    let y = x * 1.442_695_f32;
431    // Clamp to avoid overflow
432    let y = if y > 120.0 { 120.0 } else if y < -120.0 { -120.0 } else { y };
433    let n = if y >= 0.0 { y as i32 } else { y as i32 - 1 };
434    let frac = y - n as f32;
435    let ln2 = 0.693_147_f32;
436    let mantissa = 1.0 + frac * (ln2 + frac * (0.240_226_f32 + frac * 0.055_504_f32));
437    if n >= 0 {
438        let mut acc = 1.0_f32;
439        for _ in 0..n { acc *= 2.0; }
440        acc * mantissa
441    } else {
442        let mut acc = 1.0_f32;
443        for _ in 0..(-n) { acc *= 0.5; }
444        acc * mantissa
445    }
446}
447
448// ── Tests ──────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn semiotic_horizon_record_and_query() {
456        let mut horizon = SemioticHorizon::<16>::new();
457        horizon.record(10.0, 0.005, true, 15);
458        horizon.record(5.0, 0.005, true, 25);
459        horizon.record(-5.0, 0.005, false, 0);
460        horizon.record(-15.0, 0.001, false, 0);
461
462        assert_eq!(horizon.len(), 4);
463        assert!((horizon.detection_rate() - 0.5).abs() < 1e-4);
464    }
465
466    #[test]
467    fn mean_latency_only_counts_detected() {
468        let mut horizon = SemioticHorizon::<8>::new();
469        horizon.record(10.0, 0.01, true, 10);
470        horizon.record(5.0, 0.01, true, 20);
471        horizon.record(-10.0, 0.01, false, 0);
472        let lat = horizon.mean_detection_latency();
473        assert!((lat - 15.0).abs() < 1e-4, "mean latency of detected: {}", lat);
474    }
475
476    #[test]
477    fn candidate_mechanisms_for_drift() {
478        let mechs = candidate_mechanisms(ReasonCode::SustainedOutwardDrift);
479        assert!(mechs.contains(&PhysicalMechanism::PaThermalDrift));
480        assert!(mechs.contains(&PhysicalMechanism::LoAging));
481    }
482
483    #[test]
484    fn candidate_mechanisms_for_jamming() {
485        let mechs = candidate_mechanisms(ReasonCode::AbruptSlewViolation);
486        assert!(mechs.contains(&PhysicalMechanism::IntentionalJamming));
487    }
488
489    #[test]
490    fn model_reference_non_empty() {
491        let ref_str = model_reference(PhysicalMechanism::PaThermalDrift);
492        assert!(ref_str.contains("Arrhenius"));
493        let leeson = model_reference(PhysicalMechanism::PhaseNoiseDegradation);
494        assert!(leeson.contains("Leeson"));
495    }
496
497    #[test]
498    fn horizon_capacity_enforced() {
499        let mut h = SemioticHorizon::<2>::new();
500        assert!(h.record(0.0, 0.0, true, 1));
501        assert!(h.record(0.0, 0.0, false, 0));
502        assert!(!h.record(0.0, 0.0, true, 1), "must reject when full");
503    }
504
505    // ── PhysicsModel Tests ─────────────────────────────────────────────────
506
507    #[test]
508    fn arrhenius_drift_increases_with_temperature() {
509        let model = ArrheniusModel::GAAS_PHEMT;
510        let drift_25 = model.predict_drift_rate(25.0);
511        let drift_125 = model.predict_drift_rate(125.0);
512        assert!(drift_125 > drift_25,
513            "Arrhenius: higher T → higher drift: {}→{}", drift_25, drift_125);
514    }
515
516    #[test]
517    fn arrhenius_gan_slower_than_gaas_at_same_temp() {
518        // GaN has higher E_a → slower degradation at same T
519        let gaas = ArrheniusModel::GAAS_PHEMT.predict_drift_rate(125.0);
520        let gan  = ArrheniusModel::GAN_HEMT.predict_drift_rate(125.0);
521        assert!(gan < gaas,
522            "GaN (E_a=2.1) must have lower drift than GaAs (E_a=1.6): {} vs {}", gan, gaas);
523    }
524
525    #[test]
526    fn allan_variance_ocxo_better_than_tcxo() {
527        // OCXO Class A should have lower σ_y at τ=1
528        let ocxo = AllanVarianceModel::OCXO_CLASS_A.predict_drift_rate(1.0);
529        let tcxo = AllanVarianceModel::TCXO_GRADE_B.predict_drift_rate(1.0);
530        assert!(ocxo < tcxo,
531            "OCXO must be more stable than TCXO: {} vs {}", ocxo, tcxo);
532    }
533
534    #[test]
535    fn allan_variance_returns_zero_for_zero_tau() {
536        let m = AllanVarianceModel::OCXO_CLASS_A;
537        let s = m.predict_drift_rate(0.0);
538        assert_eq!(s, 0.0, "AVAR at τ=0 must return 0");
539    }
540
541    #[test]
542    fn physics_consistency_within_tolerance() {
543        let model = ArrheniusModel::GAAS_PHEMT;
544        let predicted = model.predict_drift_rate(85.0);
545        // Feed observed = predicted * 1.1 (10% deviation) with 20% tolerance
546        let result = evaluate_physics_consistency(&model, predicted * 1.1, 85.0, 0.20);
547        assert!(result.is_consistent,
548            "10% deviation within 20% tolerance: ratio={}", result.deviation_ratio);
549    }
550
551    #[test]
552    fn physics_consistency_outside_tolerance() {
553        let model = ArrheniusModel::GAAS_PHEMT;
554        let predicted = model.predict_drift_rate(85.0);
555        // Feed observed = 3× predicted (200% off), tolerance = 50%
556        let result = evaluate_physics_consistency(&model, predicted * 3.0, 85.0, 0.50);
557        assert!(!result.is_consistent,
558            "200% deviation outside 50% tolerance: ratio={}", result.deviation_ratio);
559    }
560
561    #[test]
562    fn physics_model_reason_codes() {
563        assert_eq!(ArrheniusModel::GAAS_PHEMT.maps_to_reason(), ReasonCode::SustainedOutwardDrift);
564        assert_eq!(AllanVarianceModel::TCXO_GRADE_B.maps_to_reason(), ReasonCode::SustainedOutwardDrift);
565    }
566
567    #[test]
568    fn exp_approx_reasonable_accuracy() {
569        // e^0 = 1, e^1 ≈ 2.718, e^-1 ≈ 0.368
570        let e0 = exp_approx(0.0);
571        let e1 = exp_approx(1.0);
572        let em1 = exp_approx(-1.0);
573        assert!((e0 - 1.0).abs() < 0.01, "exp(0) ≈ 1: {}", e0);
574        assert!((e1 - 2.718).abs() < 0.05, "exp(1) ≈ 2.718: {}", e1);
575        assert!((em1 - 0.368).abs() < 0.01, "exp(-1) ≈ 0.368: {}", em1);
576    }
577}