Skip to main content

sidereon_core/
broadcast_comparison.rs

1//! Broadcast-ephemeris accuracy: compare a broadcast navigation product against
2//! a precise SP3 product over a window (the orbit/clock pieces of the
3//! signal-in-space range error, SISRE).
4//!
5//! For each satellite at each epoch this differences the broadcast-evaluated ECEF
6//! position and clock against the precise SP3 values, decomposes the position
7//! error into radial / along-track / cross-track (RAC) components built from the
8//! precise state, and summarizes the differences as RMS and maximum statistics per
9//! satellite and overall. Only epochs where **both** sources return a valid state
10//! contribute; an epoch missing from either product is skipped, never
11//! extrapolated.
12//!
13//! The caller supplies the per-epoch evaluation keys ([`EpochInputs`]): the
14//! continuous J2000 second the broadcast product is evaluated at, and the SP3
15//! split Julian dates for the epoch and its `+/-` velocity-finite-difference
16//! neighbours. The SP3 velocity is a centered finite difference of the precise
17//! position (one-sided at a window edge), since SP3 interpolation exposes position
18//! only. Time-scale and calendar handling stay at the (Elixir) interface boundary;
19//! this module owns the evaluation orchestration and all of the difference algebra.
20
21use crate::astro::math::vec3::{cross3, dot3, norm3, scale3, sub3, unit3};
22use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
23use crate::constants::C_M_S;
24use crate::ephemeris::{BroadcastEphemeris, Sp3};
25use crate::error::{Error, Result};
26use crate::observables::ObservableEphemerisSource;
27use crate::GnssSatelliteId;
28
29/// The per-epoch evaluation keys for one sample, marshaled by the interface.
30///
31/// `broadcast_t_j2000_s` is the continuous second-of-J2000 the broadcast product
32/// is queried at; the three split Julian dates query the precise product at the
33/// epoch and at the velocity finite-difference neighbours (`epoch +/- half`),
34/// each in the precise product's own header time scale.
35#[derive(Debug, Clone, Copy)]
36pub struct EpochInputs {
37    /// Broadcast query instant, continuous seconds since J2000 (GPST-aligned).
38    pub broadcast_t_j2000_s: f64,
39    /// Precise query at the epoch (split Julian date).
40    pub precise: JulianDateSplit,
41    /// Precise query at `epoch + half` for the centered velocity difference.
42    pub precise_plus: JulianDateSplit,
43    /// Precise query at `epoch - half` for the centered velocity difference.
44    pub precise_minus: JulianDateSplit,
45}
46
47/// Orbit and clock difference statistics for one satellite (or the overall set).
48///
49/// All values are meters except `count` (the number of compared epochs). The
50/// float fields are `None` when no compared epoch populated them (an empty set,
51/// or no clocked epoch). `orbit_3d_*` are the Euclidean position-difference
52/// magnitudes; `radial_*`/`along_*`/`cross_*` summarize the signed RAC components
53/// of the position difference (`broadcast - precise`). `clock_*` are the raw
54/// satellite-clock differences scaled to meters; `clock_datum_removed_*` are the
55/// same after the per-epoch common reference-clock offset (the median over all
56/// satellites at the epoch) is removed.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct CompareStats {
59    /// Number of compared epochs contributing to the statistics.
60    pub count: usize,
61    /// RMS of the 3D position-difference magnitude, meters.
62    pub orbit_3d_rms_m: Option<f64>,
63    /// Maximum 3D position-difference magnitude, meters.
64    pub orbit_3d_max_m: Option<f64>,
65    /// RMS of the radial position-difference component, meters.
66    pub radial_rms_m: Option<f64>,
67    /// Maximum absolute radial component, meters.
68    pub radial_max_m: Option<f64>,
69    /// RMS of the along-track position-difference component, meters.
70    pub along_rms_m: Option<f64>,
71    /// Maximum absolute along-track component, meters.
72    pub along_max_m: Option<f64>,
73    /// RMS of the cross-track position-difference component, meters.
74    pub cross_rms_m: Option<f64>,
75    /// Maximum absolute cross-track component, meters.
76    pub cross_max_m: Option<f64>,
77    /// RMS of the raw satellite-clock difference, meters.
78    pub clock_rms_m: Option<f64>,
79    /// Maximum absolute raw satellite-clock difference, meters.
80    pub clock_max_m: Option<f64>,
81    /// RMS of the datum-removed (true SIS) clock difference, meters.
82    pub clock_datum_removed_rms_m: Option<f64>,
83    /// Maximum absolute datum-removed clock difference, meters.
84    pub clock_datum_removed_max_m: Option<f64>,
85}
86
87impl CompareStats {
88    /// The statistics for an empty set of compared epochs.
89    fn empty() -> Self {
90        Self {
91            count: 0,
92            orbit_3d_rms_m: None,
93            orbit_3d_max_m: None,
94            radial_rms_m: None,
95            radial_max_m: None,
96            along_rms_m: None,
97            along_max_m: None,
98            cross_rms_m: None,
99            cross_max_m: None,
100            clock_rms_m: None,
101            clock_max_m: None,
102            clock_datum_removed_rms_m: None,
103            clock_datum_removed_max_m: None,
104        }
105    }
106}
107
108/// The result of a broadcast-vs-precise comparison.
109///
110/// `per_satellite` pairs each satellite with its statistics (in the input
111/// satellite order); `overall` aggregates every compared epoch across all
112/// satellites; `missing` lists `(satellite, count)` for satellites with skipped
113/// epochs, sorted by satellite token then count.
114#[derive(Debug, Clone, PartialEq)]
115pub struct CompareReport {
116    /// Per-satellite statistics, in the input satellite order.
117    pub per_satellite: Vec<(GnssSatelliteId, CompareStats)>,
118    /// Statistics over every compared epoch across all satellites.
119    pub overall: CompareStats,
120    /// Satellites with one or more skipped epochs and their skip counts.
121    pub missing: Vec<(GnssSatelliteId, usize)>,
122}
123
124/// One compared satellite-epoch difference. `clock_residual_m` is filled by the
125/// datum-removal pass before aggregation.
126#[derive(Debug, Clone, Copy)]
127struct Diff {
128    epoch_index: usize,
129    orbit_3d: f64,
130    radial: f64,
131    along: f64,
132    cross: f64,
133    clock_m: Option<f64>,
134    clock_residual_m: Option<f64>,
135}
136
137/// One satellite's compared differences plus the count of skipped epochs.
138struct SatelliteDiffs {
139    satellite: GnssSatelliteId,
140    diffs: Vec<Diff>,
141    missing: usize,
142}
143
144/// Compare a broadcast product against a precise SP3 product over `epochs`.
145///
146/// `velocity_half_s` is the finite-difference half-step in seconds (the caller's
147/// `round(step_s / 2)`), used both to form the neighbour queries and to scale the
148/// difference into a velocity.
149pub fn compare(
150    broadcast: &BroadcastEphemeris,
151    precise: &Sp3,
152    satellites: &[GnssSatelliteId],
153    epochs: &[EpochInputs],
154    velocity_half_s: f64,
155) -> Result<CompareReport> {
156    validate_compare_inputs(epochs, velocity_half_s)?;
157
158    let scale = precise.header.time_scale;
159
160    // Per-satellite compared diffs (in epoch order) plus the skipped-epoch count.
161    let mut per_sat: Vec<SatelliteDiffs> = Vec::with_capacity(satellites.len());
162    for &sat in satellites {
163        let mut diffs = Vec::new();
164        let mut missing = 0usize;
165        for (idx, ep) in epochs.iter().enumerate() {
166            match diff_at(broadcast, precise, scale, sat, idx, ep, velocity_half_s) {
167                Some(diff) => diffs.push(diff),
168                None => missing += 1,
169            }
170        }
171        per_sat.push(SatelliteDiffs {
172            satellite: sat,
173            diffs,
174            missing,
175        });
176    }
177
178    // Flatten in satellite-major, epoch order (the order the statistics fold in).
179    let mut all: Vec<Diff> = Vec::new();
180    for sat in &per_sat {
181        all.extend(sat.diffs.iter().copied());
182    }
183
184    // The raw clock difference carries a per-epoch common reference-clock offset
185    // between the two products' datums; estimate it (median over all satellites
186    // at the epoch) and remove it so each diff also carries the true clock error.
187    let datum = clock_datum_by_epoch(&all, epochs.len());
188
189    let overall = aggregate(&enrich(&all, &datum));
190    let per_satellite = per_sat
191        .iter()
192        .map(|sat| (sat.satellite, aggregate(&enrich(&sat.diffs, &datum))))
193        .collect();
194
195    let mut missing: Vec<(GnssSatelliteId, usize)> = per_sat
196        .iter()
197        .filter(|sat| sat.missing > 0)
198        .map(|sat| (sat.satellite, sat.missing))
199        .collect();
200    missing.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string()).then(a.1.cmp(&b.1)));
201
202    Ok(CompareReport {
203        per_satellite,
204        overall,
205        missing,
206    })
207}
208
209fn validate_compare_inputs(epochs: &[EpochInputs], velocity_half_s: f64) -> Result<()> {
210    validate_finite(velocity_half_s, "velocity_half_s")?;
211    if velocity_half_s <= 0.0 {
212        return Err(invalid_input("velocity_half_s", "not positive"));
213    }
214    for (index, epoch) in epochs.iter().enumerate() {
215        validate_finite(epoch.broadcast_t_j2000_s, "epochs.broadcast_t_j2000_s")?;
216        validate_split(epoch.precise, index, "precise")?;
217        validate_split(epoch.precise_plus, index, "precise_plus")?;
218        validate_split(epoch.precise_minus, index, "precise_minus")?;
219    }
220    Ok(())
221}
222
223fn validate_split(split: JulianDateSplit, _index: usize, field: &'static str) -> Result<()> {
224    validate_finite(split.jd_whole, field)?;
225    validate_finite(split.fraction, field)?;
226    if !(-1.0..=1.0).contains(&split.fraction) {
227        return Err(invalid_input(field, "fraction out of range"));
228    }
229    Ok(())
230}
231
232fn validate_finite(value: f64, field: &'static str) -> Result<()> {
233    if value.is_finite() {
234        Ok(())
235    } else {
236        Err(invalid_input(field, "not finite"))
237    }
238}
239
240fn invalid_input(field: &'static str, reason: &'static str) -> Error {
241    Error::InvalidInput(format!("{field} {reason}"))
242}
243
244/// A single epoch's broadcast-minus-precise difference decomposed into RAC and a
245/// clock difference, or `None` when either product lacks a valid position (or the
246/// precise velocity finite difference is unavailable).
247fn diff_at(
248    broadcast: &BroadcastEphemeris,
249    precise: &Sp3,
250    scale: TimeScale,
251    sat: GnssSatelliteId,
252    epoch_index: usize,
253    ep: &EpochInputs,
254    velocity_half_s: f64,
255) -> Option<Diff> {
256    let (b_pos, b_clock) = broadcast_state(broadcast, sat, ep.broadcast_t_j2000_s)?;
257    let (p_pos, p_clock) = precise_state(precise, scale, sat, ep.precise)?;
258    let vel = precise_velocity(precise, scale, sat, ep, p_pos, velocity_half_s)?;
259
260    let d = sub3(b_pos, p_pos);
261    let (radial, along, cross) = project_rac(d, p_pos, vel);
262
263    let clock_m = match (b_clock, p_clock) {
264        (Some(bc), Some(pc)) => Some((bc - pc) * C_M_S),
265        _ => None,
266    };
267
268    Some(Diff {
269        epoch_index,
270        orbit_3d: norm3(d),
271        radial,
272        along,
273        cross,
274        clock_m,
275        clock_residual_m: None,
276    })
277}
278
279/// Broadcast ECEF position and clock at the query second, or `None` on a miss.
280fn broadcast_state(
281    broadcast: &BroadcastEphemeris,
282    sat: GnssSatelliteId,
283    t_j2000_s: f64,
284) -> Option<([f64; 3], Option<f64>)> {
285    let state = broadcast.observable_state_at_j2000_s(sat, t_j2000_s).ok()?;
286    Some((state.position_ecef_m, state.clock_s))
287}
288
289/// Precise ECEF position and clock at the split-Julian-date epoch, or `None`.
290fn precise_state(
291    precise: &Sp3,
292    scale: TimeScale,
293    sat: GnssSatelliteId,
294    split: JulianDateSplit,
295) -> Option<([f64; 3], Option<f64>)> {
296    let state = precise
297        .position(sat, Instant::from_julian_date(scale, split))
298        .ok()?;
299    Some((state.position.as_array(), state.clock_s))
300}
301
302/// Precise ECEF position only at the split-Julian-date epoch, or `None`.
303fn precise_position(
304    precise: &Sp3,
305    scale: TimeScale,
306    sat: GnssSatelliteId,
307    split: JulianDateSplit,
308) -> Option<[f64; 3]> {
309    precise_state(precise, scale, sat, split).map(|(pos, _clock)| pos)
310}
311
312/// Centered finite-difference velocity of the precise position, falling back to a
313/// one-sided difference when a neighbour epoch is outside the SP3 span. `r0` is the
314/// precise position already evaluated at the epoch (reused for the one-sided case).
315fn precise_velocity(
316    precise: &Sp3,
317    scale: TimeScale,
318    sat: GnssSatelliteId,
319    ep: &EpochInputs,
320    r0: [f64; 3],
321    half_s: f64,
322) -> Option<[f64; 3]> {
323    let rp = precise_position(precise, scale, sat, ep.precise_plus);
324    let rm = precise_position(precise, scale, sat, ep.precise_minus);
325    finite_difference_velocity(rp, rm, r0, half_s)
326}
327
328/// Combine the neighbour positions into a velocity: a centered difference when
329/// both neighbours are available, a one-sided difference (using the epoch position
330/// `r0`) when only one is, and `None` when neither is. Pure arithmetic, split out
331/// from the evaluation so the operation order can be pinned independently.
332fn finite_difference_velocity(
333    rp: Option<[f64; 3]>,
334    rm: Option<[f64; 3]>,
335    r0: [f64; 3],
336    half_s: f64,
337) -> Option<[f64; 3]> {
338    match (rp, rm) {
339        (Some(rp), Some(rm)) => Some(scale3(sub3(rp, rm), 1.0 / (2.0 * half_s))),
340        (Some(rp), None) => Some(scale3(sub3(rp, r0), 1.0 / half_s)),
341        (None, Some(rm)) => Some(scale3(sub3(r0, rm), 1.0 / half_s)),
342        (None, None) => None,
343    }
344}
345
346/// Project a difference vector onto the radial/along-track/cross-track triad of
347/// the orbit defined by position `r` and velocity `v`. Radial is along `r`,
348/// cross-track along the angular momentum `r x v`, along-track completes the
349/// right-handed set.
350fn project_rac(d: [f64; 3], r: [f64; 3], v: [f64; 3]) -> (f64, f64, f64) {
351    let radial_hat = unit3(r).unwrap_or([0.0, 0.0, 0.0]);
352    let cross_hat = unit3(cross3(r, v)).unwrap_or([0.0, 0.0, 0.0]);
353    let along_hat = cross3(cross_hat, radial_hat);
354    (dot3(d, radial_hat), dot3(d, along_hat), dot3(d, cross_hat))
355}
356
357/// Per-epoch common reference-clock offset (meters): the median over all
358/// satellites at the epoch of the raw clock difference. `None` for an epoch with
359/// no clocked satellite.
360fn clock_datum_by_epoch(all: &[Diff], n_epochs: usize) -> Vec<Option<f64>> {
361    let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); n_epochs];
362    for diff in all {
363        if let Some(clock) = diff.clock_m {
364            buckets[diff.epoch_index].push(clock);
365        }
366    }
367    buckets.iter().map(|clocks| median(clocks)).collect()
368}
369
370/// Attach the datum-removed clock residual to each diff: raw clock difference
371/// minus the epoch's common offset, when both are present.
372fn enrich(diffs: &[Diff], datum: &[Option<f64>]) -> Vec<Diff> {
373    diffs
374        .iter()
375        .map(|diff| {
376            let clock_residual_m = match (diff.clock_m, datum[diff.epoch_index]) {
377                (Some(clock), Some(d)) => Some(clock - d),
378                _ => None,
379            };
380            Diff {
381                clock_residual_m,
382                ..*diff
383            }
384        })
385        .collect()
386}
387
388/// Summarize a set of compared diffs into RMS/max statistics.
389fn aggregate(diffs: &[Diff]) -> CompareStats {
390    if diffs.is_empty() {
391        return CompareStats::empty();
392    }
393
394    let orbit: Vec<f64> = diffs.iter().map(|d| d.orbit_3d).collect();
395    let radial: Vec<f64> = diffs.iter().map(|d| d.radial).collect();
396    let along: Vec<f64> = diffs.iter().map(|d| d.along).collect();
397    let cross: Vec<f64> = diffs.iter().map(|d| d.cross).collect();
398    let clocks: Vec<f64> = diffs.iter().filter_map(|d| d.clock_m).collect();
399    let clock_resids: Vec<f64> = diffs.iter().filter_map(|d| d.clock_residual_m).collect();
400
401    CompareStats {
402        count: diffs.len(),
403        orbit_3d_rms_m: Some(rms(&orbit)),
404        orbit_3d_max_m: Some(max_abs(&orbit)),
405        radial_rms_m: Some(rms(&radial)),
406        radial_max_m: Some(max_abs(&radial)),
407        along_rms_m: Some(rms(&along)),
408        along_max_m: Some(max_abs(&along)),
409        cross_rms_m: Some(rms(&cross)),
410        cross_max_m: Some(max_abs(&cross)),
411        clock_rms_m: rms_or_none(&clocks),
412        clock_max_m: max_abs_or_none(&clocks),
413        clock_datum_removed_rms_m: rms_or_none(&clock_resids),
414        clock_datum_removed_max_m: max_abs_or_none(&clock_resids),
415    }
416}
417
418/// Root-mean-square over a non-empty slice, folding left from `0.0`.
419fn rms(values: &[f64]) -> f64 {
420    let sum_sq = values.iter().fold(0.0, |acc, &x| acc + x * x);
421    (sum_sq / values.len() as f64).sqrt()
422}
423
424fn rms_or_none(values: &[f64]) -> Option<f64> {
425    if values.is_empty() {
426        None
427    } else {
428        Some(rms(values))
429    }
430}
431
432/// Maximum absolute value over a non-empty slice.
433fn max_abs(values: &[f64]) -> f64 {
434    values
435        .iter()
436        .map(|v| v.abs())
437        .reduce(f64::max)
438        .expect("max_abs requires a non-empty slice")
439}
440
441fn max_abs_or_none(values: &[f64]) -> Option<f64> {
442    if values.is_empty() {
443        None
444    } else {
445        Some(max_abs(values))
446    }
447}
448
449/// Median of a slice (`None` if empty); even counts average the two middles.
450fn median(values: &[f64]) -> Option<f64> {
451    if values.is_empty() {
452        return None;
453    }
454    let mut sorted = values.to_vec();
455    sorted.sort_by(f64::total_cmp);
456    let n = sorted.len();
457    let mid = n / 2;
458    if n % 2 == 1 {
459        Some(sorted[mid])
460    } else {
461        Some((sorted[mid - 1] + sorted[mid]) / 2.0)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn cell(epoch_index: usize, orbit: f64, r: f64, a: f64, c: f64, clock: Option<f64>) -> Diff {
470        Diff {
471            epoch_index,
472            orbit_3d: orbit,
473            radial: r,
474            along: a,
475            cross: c,
476            clock_m: clock,
477            clock_residual_m: None,
478        }
479    }
480
481    #[test]
482    fn median_odd_and_even_match_reference() {
483        assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
484        assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
485        assert_eq!(median(&[]), None);
486    }
487
488    #[test]
489    fn rms_folds_left_from_zero() {
490        let values = [1.0, 2.0, 2.0];
491        let expected = ((1.0_f64 + 4.0 + 4.0) / 3.0).sqrt();
492        assert_eq!(rms(&values).to_bits(), expected.to_bits());
493        assert_eq!(rms_or_none(&[]), None);
494        assert_eq!(max_abs(&[-3.0, 2.0, -1.0]), 3.0);
495    }
496
497    // The RAC projection is pure arithmetic (no transcendental, no eval), so its
498    // bits are frozen here as an operation-order regression pin.
499    #[test]
500    fn rac_projection_has_frozen_bits() {
501        let r = [7.0e6, 1.0e6, 2.0e6];
502        let v = [-500.0, 7000.0, 1000.0];
503        let d = [3.0, -4.0, 5.0];
504        let (radial, along, cross) = project_rac(d, r, v);
505
506        assert_eq!(radial.to_bits(), 0x400d64d51e0db1c6);
507        assert_eq!(along.to_bits(), 0xc00eed09ea935852);
508        assert_eq!(cross.to_bits(), 0x40129246dff98f29);
509
510        // Orthonormal rotation preserves the norm.
511        let quad = (radial * radial + along * along + cross * cross).sqrt();
512        assert!((quad - norm3(d)).abs() < 1.0e-9);
513    }
514
515    #[test]
516    fn finite_difference_velocity_has_frozen_bits() {
517        let rp = [1.0e3, 2.0e3, 3.0e3];
518        let rm = [-1.0e3, 1.0e3, 2.5e3];
519        let r0 = [100.0, 1600.0, 2700.0];
520        let half = 450.0;
521
522        let centered = finite_difference_velocity(Some(rp), Some(rm), r0, half).unwrap();
523        assert_eq!(centered[0].to_bits(), 0x4001c71c71c71c72);
524        assert_eq!(centered[1].to_bits(), 0x3ff1c71c71c71c72);
525        assert_eq!(centered[2].to_bits(), 0x3fe1c71c71c71c72);
526
527        let one_sided = finite_difference_velocity(Some(rp), None, r0, half).unwrap();
528        assert_eq!(one_sided[0].to_bits(), 0x4000000000000000);
529        assert_eq!(one_sided[1].to_bits(), 0x3fec71c71c71c71c);
530        assert_eq!(one_sided[2].to_bits(), 0x3fe5555555555555);
531
532        assert_eq!(finite_difference_velocity(None, None, r0, half), None);
533    }
534
535    // The full aggregation (satellite-major RMS fold, even/odd median, per-epoch
536    // clock-datum removal across satellites) frozen as a 0-ULP regression. Pure
537    // arithmetic on synthetic per-cell differences, independent of the ephemeris
538    // evaluation, so it is bit-stable across optimization levels.
539    #[test]
540    fn aggregation_and_datum_have_frozen_bits() {
541        let g01 = [
542            cell(0, 1.0, 0.6, 0.8, 0.0, Some(10.0)),
543            cell(1, 2.0, 1.2, 1.6, 0.0, Some(12.0)),
544            cell(2, 3.0, 1.8, 2.4, 0.0, None),
545        ];
546        let g02 = [
547            cell(0, 4.0, 2.4, 3.2, 0.0, Some(20.0)),
548            cell(1, 5.0, 3.0, 4.0, 0.0, Some(8.0)),
549        ];
550
551        let mut all = Vec::new();
552        all.extend(g01.iter().copied());
553        all.extend(g02.iter().copied());
554        let datum = clock_datum_by_epoch(&all, 3);
555        // Even-median datum per epoch: e0 = median(10,20) = 15, e1 = median(12,8) = 10.
556        assert_eq!(datum, vec![Some(15.0), Some(10.0), None]);
557
558        let overall = aggregate(&enrich(&all, &datum));
559        assert_eq!(overall.count, 5);
560        assert_eq!(
561            overall.orbit_3d_rms_m.unwrap().to_bits(),
562            0x400a887293fd6f34
563        );
564        assert_eq!(
565            overall.orbit_3d_max_m.unwrap().to_bits(),
566            0x4014000000000000
567        );
568        assert_eq!(overall.clock_rms_m.unwrap().to_bits(), 0x402a9bb78af6cabc);
569        assert_eq!(
570            overall.clock_datum_removed_rms_m.unwrap().to_bits(),
571            0x400e768d399dc470
572        );
573        assert_eq!(
574            overall.clock_datum_removed_max_m.unwrap().to_bits(),
575            0x4014000000000000
576        );
577
578        let g01_stats = aggregate(&enrich(&g01, &datum));
579        assert_eq!(g01_stats.count, 3);
580        assert_eq!(
581            g01_stats.orbit_3d_rms_m.unwrap().to_bits(),
582            0x4001482f86c40c43
583        );
584        // Only two of G01's cells carry a clock; the third is dropped from clock stats.
585        assert_eq!(g01_stats.clock_rms_m.unwrap().to_bits(), 0x402617398f2aaa48);
586        assert_eq!(
587            g01_stats.clock_datum_removed_rms_m.unwrap().to_bits(),
588            0x400e768d399dc470
589        );
590    }
591}