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, PartialEq)]
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
209/// Seconds in one Julian day, the scale between a window's second-of-J2000 axis
210/// and the split-Julian-date day fraction.
211const SECONDS_PER_DAY: f64 = 86_400.0;
212
213/// A regularly sampled comparison window, the window-form input to
214/// [`compare_window`].
215///
216/// It mirrors how the geometry series functions take a window (an inclusive
217/// `(t0, t1)` second-of-J2000 span plus a step), extended with the second time
218/// axis this comparison needs: the broadcast product is queried on the
219/// continuous J2000 second axis (`broadcast_window_j2000_s`), while the precise
220/// product is queried by split Julian date in its own header time scale. The
221/// caller supplies the precise split Julian date for the window start
222/// (`precise_start`); the driver advances it in lockstep with the broadcast
223/// axis, so the per-epoch broadcast-to-precise time-scale offset stays fixed at
224/// the value baked into the two anchors. Time-scale and calendar handling thus
225/// stay at the interface boundary (the caller picks the two start anchors); the
226/// driver only builds the regular per-epoch grid and the velocity neighbours.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct CompareWindow {
229    /// Inclusive broadcast query window, continuous seconds since J2000
230    /// (`(t0, t1)`); `t0` is the instant of the first epoch.
231    pub broadcast_window_j2000_s: (f64, f64),
232    /// Precise query for the window start `t0`, split Julian date in the precise
233    /// product's header time scale.
234    pub precise_start: JulianDateSplit,
235    /// Sampling step between consecutive epochs, seconds.
236    pub step_s: f64,
237    /// Velocity finite-difference half step, seconds (the precise `+/- half`
238    /// neighbour queries); also the velocity scale passed to [`compare`].
239    pub velocity_half_s: f64,
240}
241
242/// Build the per-epoch evaluation keys for a [`CompareWindow`] without running the
243/// comparison.
244///
245/// Exposed so a caller can inspect or cache the grid; [`compare_window`] builds
246/// the same grid and delegates to [`compare`]. The sampling matches the geometry
247/// series convention: epochs land at `t0, t0 + step, ...` up to and including the
248/// window end `t1`, with a final sample snapped to `t1` when the last stepped
249/// sample falls short. An empty grid is returned when `t0 > t1`.
250pub fn compare_window_epochs(window: &CompareWindow) -> Result<Vec<EpochInputs>> {
251    validate_window(window)?;
252
253    let (t0, _t1) = window.broadcast_window_j2000_s;
254    let times = sample_times(window.broadcast_window_j2000_s, window.step_s);
255    let mut epochs = Vec::with_capacity(times.len());
256    for t in times {
257        let dt = t - t0;
258        epochs.push(EpochInputs {
259            broadcast_t_j2000_s: t,
260            precise: advance_split(window.precise_start, dt),
261            precise_plus: advance_split(window.precise_start, dt + window.velocity_half_s),
262            precise_minus: advance_split(window.precise_start, dt - window.velocity_half_s),
263        });
264    }
265    Ok(epochs)
266}
267
268/// Compare a broadcast product against a precise SP3 product over a sampled
269/// window.
270///
271/// Builds the per-epoch grid from `window` (see [`compare_window_epochs`]) and
272/// delegates to [`compare`] with the window's `velocity_half_s`. This is the
273/// window-form sibling of [`compare`] for callers that hold a regular sampling
274/// window rather than precomputed per-epoch keys.
275pub fn compare_window(
276    broadcast: &BroadcastEphemeris,
277    precise: &Sp3,
278    satellites: &[GnssSatelliteId],
279    window: &CompareWindow,
280) -> Result<CompareReport> {
281    let epochs = compare_window_epochs(window)?;
282    compare(
283        broadcast,
284        precise,
285        satellites,
286        &epochs,
287        window.velocity_half_s,
288    )
289}
290
291/// Advance a split Julian date by `delta_s` seconds, carrying whole days into the
292/// integer part so the residual fraction stays within one day.
293fn advance_split(start: JulianDateSplit, delta_s: f64) -> JulianDateSplit {
294    let total_fraction = start.fraction + delta_s / SECONDS_PER_DAY;
295    let whole_days = total_fraction.trunc();
296    JulianDateSplit {
297        jd_whole: start.jd_whole + whole_days,
298        fraction: total_fraction - whole_days,
299    }
300}
301
302/// The broadcast query instants for an inclusive `(t0, t1)` window at `step_s`,
303/// mirroring the geometry series sampling: regular steps from `t0`, plus a final
304/// snap to `t1` when the last step falls short. Empty when `t0 > t1`.
305fn sample_times(window_j2000_s: (f64, f64), step_s: f64) -> Vec<f64> {
306    let (t0, t1) = window_j2000_s;
307    if t0 > t1 {
308        return Vec::new();
309    }
310    let mut out = Vec::new();
311    let mut step_index = 0usize;
312    loop {
313        let t = t0 + step_s * step_index as f64;
314        if t > t1 {
315            break;
316        }
317        out.push(t);
318        step_index += 1;
319    }
320    if let Some(&last) = out.last() {
321        if last < t1 {
322            out.push(t1);
323        }
324    }
325    out
326}
327
328fn validate_window(window: &CompareWindow) -> Result<()> {
329    let (t0, t1) = window.broadcast_window_j2000_s;
330    validate_finite(t0, "window.broadcast_window_j2000_s.0")?;
331    validate_finite(t1, "window.broadcast_window_j2000_s.1")?;
332    validate_finite(window.step_s, "window.step_s")?;
333    if window.step_s <= 0.0 {
334        return Err(invalid_input("window.step_s", "not positive"));
335    }
336    validate_finite(window.velocity_half_s, "window.velocity_half_s")?;
337    if window.velocity_half_s <= 0.0 {
338        return Err(invalid_input("window.velocity_half_s", "not positive"));
339    }
340    validate_finite(
341        window.precise_start.jd_whole,
342        "window.precise_start.jd_whole",
343    )?;
344    validate_finite(
345        window.precise_start.fraction,
346        "window.precise_start.fraction",
347    )?;
348    Ok(())
349}
350
351fn validate_compare_inputs(epochs: &[EpochInputs], velocity_half_s: f64) -> Result<()> {
352    validate_finite(velocity_half_s, "velocity_half_s")?;
353    if velocity_half_s <= 0.0 {
354        return Err(invalid_input("velocity_half_s", "not positive"));
355    }
356    for (index, epoch) in epochs.iter().enumerate() {
357        validate_finite(epoch.broadcast_t_j2000_s, "epochs.broadcast_t_j2000_s")?;
358        validate_split(epoch.precise, index, "precise")?;
359        validate_split(epoch.precise_plus, index, "precise_plus")?;
360        validate_split(epoch.precise_minus, index, "precise_minus")?;
361    }
362    Ok(())
363}
364
365fn validate_split(split: JulianDateSplit, _index: usize, field: &'static str) -> Result<()> {
366    validate_finite(split.jd_whole, field)?;
367    validate_finite(split.fraction, field)?;
368    if !(-1.0..=1.0).contains(&split.fraction) {
369        return Err(invalid_input(field, "fraction out of range"));
370    }
371    Ok(())
372}
373
374fn validate_finite(value: f64, field: &'static str) -> Result<()> {
375    if value.is_finite() {
376        Ok(())
377    } else {
378        Err(invalid_input(field, "not finite"))
379    }
380}
381
382fn invalid_input(field: &'static str, reason: &'static str) -> Error {
383    Error::InvalidInput(format!("{field} {reason}"))
384}
385
386/// A single epoch's broadcast-minus-precise difference decomposed into RAC and a
387/// clock difference, or `None` when either product lacks a valid position (or the
388/// precise velocity finite difference is unavailable).
389fn diff_at(
390    broadcast: &BroadcastEphemeris,
391    precise: &Sp3,
392    scale: TimeScale,
393    sat: GnssSatelliteId,
394    epoch_index: usize,
395    ep: &EpochInputs,
396    velocity_half_s: f64,
397) -> Option<Diff> {
398    let (b_pos, b_clock) = broadcast_state(broadcast, sat, ep.broadcast_t_j2000_s)?;
399    let (p_pos, p_clock) = precise_state(precise, scale, sat, ep.precise)?;
400    let vel = precise_velocity(precise, scale, sat, ep, p_pos, velocity_half_s)?;
401
402    let d = sub3(b_pos, p_pos);
403    let (radial, along, cross) = project_rac(d, p_pos, vel);
404
405    let clock_m = match (b_clock, p_clock) {
406        (Some(bc), Some(pc)) => Some((bc - pc) * C_M_S),
407        _ => None,
408    };
409
410    Some(Diff {
411        epoch_index,
412        orbit_3d: norm3(d),
413        radial,
414        along,
415        cross,
416        clock_m,
417        clock_residual_m: None,
418    })
419}
420
421/// Broadcast ECEF position and clock at the query second, or `None` on a miss.
422fn broadcast_state(
423    broadcast: &BroadcastEphemeris,
424    sat: GnssSatelliteId,
425    t_j2000_s: f64,
426) -> Option<([f64; 3], Option<f64>)> {
427    let state = broadcast.observable_state_at_j2000_s(sat, t_j2000_s).ok()?;
428    Some((state.position_ecef_m, state.clock_s))
429}
430
431/// Precise ECEF position and clock at the split-Julian-date epoch, or `None`.
432fn precise_state(
433    precise: &Sp3,
434    scale: TimeScale,
435    sat: GnssSatelliteId,
436    split: JulianDateSplit,
437) -> Option<([f64; 3], Option<f64>)> {
438    let state = precise
439        .position(sat, Instant::from_julian_date(scale, split))
440        .ok()?;
441    Some((state.position.as_array(), state.clock_s))
442}
443
444/// Precise ECEF position only at the split-Julian-date epoch, or `None`.
445fn precise_position(
446    precise: &Sp3,
447    scale: TimeScale,
448    sat: GnssSatelliteId,
449    split: JulianDateSplit,
450) -> Option<[f64; 3]> {
451    precise_state(precise, scale, sat, split).map(|(pos, _clock)| pos)
452}
453
454/// Centered finite-difference velocity of the precise position, falling back to a
455/// one-sided difference when a neighbour epoch is outside the SP3 span. `r0` is the
456/// precise position already evaluated at the epoch (reused for the one-sided case).
457fn precise_velocity(
458    precise: &Sp3,
459    scale: TimeScale,
460    sat: GnssSatelliteId,
461    ep: &EpochInputs,
462    r0: [f64; 3],
463    half_s: f64,
464) -> Option<[f64; 3]> {
465    let rp = precise_position(precise, scale, sat, ep.precise_plus);
466    let rm = precise_position(precise, scale, sat, ep.precise_minus);
467    finite_difference_velocity(rp, rm, r0, half_s)
468}
469
470/// Combine the neighbour positions into a velocity: a centered difference when
471/// both neighbours are available, a one-sided difference (using the epoch position
472/// `r0`) when only one is, and `None` when neither is. Pure arithmetic, split out
473/// from the evaluation so the operation order can be pinned independently.
474fn finite_difference_velocity(
475    rp: Option<[f64; 3]>,
476    rm: Option<[f64; 3]>,
477    r0: [f64; 3],
478    half_s: f64,
479) -> Option<[f64; 3]> {
480    match (rp, rm) {
481        (Some(rp), Some(rm)) => Some(scale3(sub3(rp, rm), 1.0 / (2.0 * half_s))),
482        (Some(rp), None) => Some(scale3(sub3(rp, r0), 1.0 / half_s)),
483        (None, Some(rm)) => Some(scale3(sub3(r0, rm), 1.0 / half_s)),
484        (None, None) => None,
485    }
486}
487
488/// Project a difference vector onto the radial/along-track/cross-track triad of
489/// the orbit defined by position `r` and velocity `v`. Radial is along `r`,
490/// cross-track along the angular momentum `r x v`, along-track completes the
491/// right-handed set.
492fn project_rac(d: [f64; 3], r: [f64; 3], v: [f64; 3]) -> (f64, f64, f64) {
493    let radial_hat = unit3(r).unwrap_or([0.0, 0.0, 0.0]);
494    let cross_hat = unit3(cross3(r, v)).unwrap_or([0.0, 0.0, 0.0]);
495    let along_hat = cross3(cross_hat, radial_hat);
496    (dot3(d, radial_hat), dot3(d, along_hat), dot3(d, cross_hat))
497}
498
499/// Per-epoch common reference-clock offset (meters): the median over all
500/// satellites at the epoch of the raw clock difference. `None` for an epoch with
501/// no clocked satellite.
502fn clock_datum_by_epoch(all: &[Diff], n_epochs: usize) -> Vec<Option<f64>> {
503    let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); n_epochs];
504    for diff in all {
505        if let Some(clock) = diff.clock_m {
506            buckets[diff.epoch_index].push(clock);
507        }
508    }
509    buckets.iter().map(|clocks| median(clocks)).collect()
510}
511
512/// Attach the datum-removed clock residual to each diff: raw clock difference
513/// minus the epoch's common offset, when both are present.
514fn enrich(diffs: &[Diff], datum: &[Option<f64>]) -> Vec<Diff> {
515    diffs
516        .iter()
517        .map(|diff| {
518            let clock_residual_m = match (diff.clock_m, datum[diff.epoch_index]) {
519                (Some(clock), Some(d)) => Some(clock - d),
520                _ => None,
521            };
522            Diff {
523                clock_residual_m,
524                ..*diff
525            }
526        })
527        .collect()
528}
529
530/// Summarize a set of compared diffs into RMS/max statistics.
531fn aggregate(diffs: &[Diff]) -> CompareStats {
532    if diffs.is_empty() {
533        return CompareStats::empty();
534    }
535
536    let orbit: Vec<f64> = diffs.iter().map(|d| d.orbit_3d).collect();
537    let radial: Vec<f64> = diffs.iter().map(|d| d.radial).collect();
538    let along: Vec<f64> = diffs.iter().map(|d| d.along).collect();
539    let cross: Vec<f64> = diffs.iter().map(|d| d.cross).collect();
540    let clocks: Vec<f64> = diffs.iter().filter_map(|d| d.clock_m).collect();
541    let clock_resids: Vec<f64> = diffs.iter().filter_map(|d| d.clock_residual_m).collect();
542
543    CompareStats {
544        count: diffs.len(),
545        orbit_3d_rms_m: Some(rms(&orbit)),
546        orbit_3d_max_m: Some(max_abs(&orbit)),
547        radial_rms_m: Some(rms(&radial)),
548        radial_max_m: Some(max_abs(&radial)),
549        along_rms_m: Some(rms(&along)),
550        along_max_m: Some(max_abs(&along)),
551        cross_rms_m: Some(rms(&cross)),
552        cross_max_m: Some(max_abs(&cross)),
553        clock_rms_m: rms_or_none(&clocks),
554        clock_max_m: max_abs_or_none(&clocks),
555        clock_datum_removed_rms_m: rms_or_none(&clock_resids),
556        clock_datum_removed_max_m: max_abs_or_none(&clock_resids),
557    }
558}
559
560/// Root-mean-square over a non-empty slice, folding left from `0.0`.
561fn rms(values: &[f64]) -> f64 {
562    let sum_sq = values.iter().fold(0.0, |acc, &x| acc + x * x);
563    (sum_sq / values.len() as f64).sqrt()
564}
565
566fn rms_or_none(values: &[f64]) -> Option<f64> {
567    if values.is_empty() {
568        None
569    } else {
570        Some(rms(values))
571    }
572}
573
574/// Maximum absolute value over a non-empty slice.
575fn max_abs(values: &[f64]) -> f64 {
576    values
577        .iter()
578        .map(|v| v.abs())
579        .reduce(f64::max)
580        .expect("max_abs requires a non-empty slice")
581}
582
583fn max_abs_or_none(values: &[f64]) -> Option<f64> {
584    if values.is_empty() {
585        None
586    } else {
587        Some(max_abs(values))
588    }
589}
590
591/// Median of a slice (`None` if empty); even counts average the two middles.
592fn median(values: &[f64]) -> Option<f64> {
593    if values.is_empty() {
594        return None;
595    }
596    let mut sorted = values.to_vec();
597    sorted.sort_by(f64::total_cmp);
598    let n = sorted.len();
599    let mid = n / 2;
600    if n % 2 == 1 {
601        Some(sorted[mid])
602    } else {
603        Some((sorted[mid - 1] + sorted[mid]) / 2.0)
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    fn cell(epoch_index: usize, orbit: f64, r: f64, a: f64, c: f64, clock: Option<f64>) -> Diff {
612        Diff {
613            epoch_index,
614            orbit_3d: orbit,
615            radial: r,
616            along: a,
617            cross: c,
618            clock_m: clock,
619            clock_residual_m: None,
620        }
621    }
622
623    #[test]
624    fn window_grid_matches_independent_construction() {
625        let window = CompareWindow {
626            broadcast_window_j2000_s: (1_000.0, 1_000.0 + 3.0 * 900.0),
627            precise_start: JulianDateSplit::new(2_451_545.0, 0.25).expect("valid split"),
628            step_s: 900.0,
629            velocity_half_s: 450.0,
630        };
631
632        let grid = compare_window_epochs(&window).expect("window grid");
633
634        // Independent reconstruction of the same per-epoch keys: epochs land at
635        // t0, t0 + step, ... up to t1, with precise queries advanced from the
636        // start anchor by the elapsed seconds (and +/- the velocity half step).
637        let (t0, _t1) = window.broadcast_window_j2000_s;
638        let advance = |seconds: f64| {
639            let total = window.precise_start.fraction + seconds / 86_400.0;
640            let days = total.trunc();
641            JulianDateSplit {
642                jd_whole: window.precise_start.jd_whole + days,
643                fraction: total - days,
644            }
645        };
646        let mut expected = Vec::new();
647        for index in 0..4 {
648            let dt = 900.0 * index as f64;
649            expected.push(EpochInputs {
650                broadcast_t_j2000_s: t0 + dt,
651                precise: advance(dt),
652                precise_plus: advance(dt + 450.0),
653                precise_minus: advance(dt - 450.0),
654            });
655        }
656
657        assert_eq!(grid, expected);
658    }
659
660    #[test]
661    fn window_grid_snaps_final_sample_to_window_end() {
662        // A step that does not divide the span exactly still includes t1 as a
663        // final snapped epoch, mirroring the geometry series sampling.
664        let window = CompareWindow {
665            broadcast_window_j2000_s: (0.0, 1_000.0),
666            precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
667            step_s: 400.0,
668            velocity_half_s: 200.0,
669        };
670        let grid = compare_window_epochs(&window).expect("window grid");
671        let times: Vec<f64> = grid.iter().map(|e| e.broadcast_t_j2000_s).collect();
672        assert_eq!(times, vec![0.0, 400.0, 800.0, 1_000.0]);
673    }
674
675    #[test]
676    fn window_grid_is_empty_when_start_after_end() {
677        let window = CompareWindow {
678            broadcast_window_j2000_s: (2_000.0, 1_000.0),
679            precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
680            step_s: 100.0,
681            velocity_half_s: 50.0,
682        };
683        assert!(compare_window_epochs(&window)
684            .expect("empty grid")
685            .is_empty());
686    }
687
688    #[test]
689    fn window_rejects_non_positive_step() {
690        let window = CompareWindow {
691            broadcast_window_j2000_s: (0.0, 1_000.0),
692            precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
693            step_s: 0.0,
694            velocity_half_s: 50.0,
695        };
696        assert!(matches!(
697            compare_window_epochs(&window),
698            Err(Error::InvalidInput(_))
699        ));
700    }
701
702    #[test]
703    fn median_odd_and_even_match_reference() {
704        assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
705        assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
706        assert_eq!(median(&[]), None);
707    }
708
709    #[test]
710    fn rms_folds_left_from_zero() {
711        let values = [1.0, 2.0, 2.0];
712        let expected = ((1.0_f64 + 4.0 + 4.0) / 3.0).sqrt();
713        assert_eq!(rms(&values).to_bits(), expected.to_bits());
714        assert_eq!(rms_or_none(&[]), None);
715        assert_eq!(max_abs(&[-3.0, 2.0, -1.0]), 3.0);
716    }
717
718    // The RAC projection is pure arithmetic (no transcendental, no eval), so its
719    // bits are frozen here as an operation-order regression pin.
720    #[test]
721    fn rac_projection_has_frozen_bits() {
722        let r = [7.0e6, 1.0e6, 2.0e6];
723        let v = [-500.0, 7000.0, 1000.0];
724        let d = [3.0, -4.0, 5.0];
725        let (radial, along, cross) = project_rac(d, r, v);
726
727        assert_eq!(radial.to_bits(), 0x400d64d51e0db1c6);
728        assert_eq!(along.to_bits(), 0xc00eed09ea935852);
729        assert_eq!(cross.to_bits(), 0x40129246dff98f29);
730
731        // Orthonormal rotation preserves the norm.
732        let quad = (radial * radial + along * along + cross * cross).sqrt();
733        assert!((quad - norm3(d)).abs() < 1.0e-9);
734    }
735
736    #[test]
737    fn finite_difference_velocity_has_frozen_bits() {
738        let rp = [1.0e3, 2.0e3, 3.0e3];
739        let rm = [-1.0e3, 1.0e3, 2.5e3];
740        let r0 = [100.0, 1600.0, 2700.0];
741        let half = 450.0;
742
743        let centered = finite_difference_velocity(Some(rp), Some(rm), r0, half).unwrap();
744        assert_eq!(centered[0].to_bits(), 0x4001c71c71c71c72);
745        assert_eq!(centered[1].to_bits(), 0x3ff1c71c71c71c72);
746        assert_eq!(centered[2].to_bits(), 0x3fe1c71c71c71c72);
747
748        let one_sided = finite_difference_velocity(Some(rp), None, r0, half).unwrap();
749        assert_eq!(one_sided[0].to_bits(), 0x4000000000000000);
750        assert_eq!(one_sided[1].to_bits(), 0x3fec71c71c71c71c);
751        assert_eq!(one_sided[2].to_bits(), 0x3fe5555555555555);
752
753        assert_eq!(finite_difference_velocity(None, None, r0, half), None);
754    }
755
756    // The full aggregation (satellite-major RMS fold, even/odd median, per-epoch
757    // clock-datum removal across satellites) frozen as a 0-ULP regression. Pure
758    // arithmetic on synthetic per-cell differences, independent of the ephemeris
759    // evaluation, so it is bit-stable across optimization levels.
760    #[test]
761    fn aggregation_and_datum_have_frozen_bits() {
762        let g01 = [
763            cell(0, 1.0, 0.6, 0.8, 0.0, Some(10.0)),
764            cell(1, 2.0, 1.2, 1.6, 0.0, Some(12.0)),
765            cell(2, 3.0, 1.8, 2.4, 0.0, None),
766        ];
767        let g02 = [
768            cell(0, 4.0, 2.4, 3.2, 0.0, Some(20.0)),
769            cell(1, 5.0, 3.0, 4.0, 0.0, Some(8.0)),
770        ];
771
772        let mut all = Vec::new();
773        all.extend(g01.iter().copied());
774        all.extend(g02.iter().copied());
775        let datum = clock_datum_by_epoch(&all, 3);
776        // Even-median datum per epoch: e0 = median(10,20) = 15, e1 = median(12,8) = 10.
777        assert_eq!(datum, vec![Some(15.0), Some(10.0), None]);
778
779        let overall = aggregate(&enrich(&all, &datum));
780        assert_eq!(overall.count, 5);
781        assert_eq!(
782            overall.orbit_3d_rms_m.unwrap().to_bits(),
783            0x400a887293fd6f34
784        );
785        assert_eq!(
786            overall.orbit_3d_max_m.unwrap().to_bits(),
787            0x4014000000000000
788        );
789        assert_eq!(overall.clock_rms_m.unwrap().to_bits(), 0x402a9bb78af6cabc);
790        assert_eq!(
791            overall.clock_datum_removed_rms_m.unwrap().to_bits(),
792            0x400e768d399dc470
793        );
794        assert_eq!(
795            overall.clock_datum_removed_max_m.unwrap().to_bits(),
796            0x4014000000000000
797        );
798
799        let g01_stats = aggregate(&enrich(&g01, &datum));
800        assert_eq!(g01_stats.count, 3);
801        assert_eq!(
802            g01_stats.orbit_3d_rms_m.unwrap().to_bits(),
803            0x4001482f86c40c43
804        );
805        // Only two of G01's cells carry a clock; the third is dropped from clock stats.
806        assert_eq!(g01_stats.clock_rms_m.unwrap().to_bits(), 0x402617398f2aaa48);
807        assert_eq!(
808            g01_stats.clock_datum_removed_rms_m.unwrap().to_bits(),
809            0x400e768d399dc470
810        );
811    }
812}