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