Skip to main content

sidereon_core/reduced_orbit/
mod.rs

1//! Compact mean-element orbit approximation for fast position prediction.
2//!
3//! This is a **fitted, approximate** model of a satellite's motion - a small
4//! set of mean elements that reproduce a position track over a window for
5//! caching, transport, and visibility prediction. It is **not** orbit
6//! determination: it discards short-period structure and is calibrated about the
7//! residual it leaves behind (`rms_m`, `max_m`, and a source-backed drift
8//! evaluation).
9//!
10//! Two models are available, selected at fit time via [`Model`]:
11//!
12//! - [`Model::CircularSecular`] (the default) - a circular orbit whose plane
13//!   precesses at a constant nodal rate. Best for near-circular orbits
14//!   (Galileo).
15//! - [`Model::EccentricSecular`] - adds a nonsingular eccentricity so the
16//!   radial `a·e` signal (~hundreds of km for GPS/BeiDou) is reproduced, while
17//!   degrading smoothly to the circular case as `e -> 0`.
18//!
19//! # Model: `circular_secular`
20//!
21//! A circular orbit (eccentricity fixed at zero) whose plane precesses at a
22//! constant nodal rate. The state is six mean elements plus an epoch:
23//!
24//! - semi-major axis `a`,
25//! - inclination `i`,
26//! - right ascension of the ascending node at epoch `raan0` and its rate
27//!   `raan_rate`,
28//! - argument of latitude at epoch `arg_lat0` and mean motion `n`.
29//!
30//! At an offset `dt = t - t0` the angles advance linearly,
31//!
32//! ```text
33//! u(t)    = arg_lat0 + n * dt
34//! raan(t) = raan0    + raan_rate * dt
35//! e       = 0
36//! ```
37//!
38//! and the inertial (GCRS) position is the in-plane circle rotated by the node
39//! and inclination:
40//!
41//! ```text
42//! r_orbit = a * [cos u, sin u, 0]
43//! r_gcrs  = Rz(raan) * Rx(i) * r_orbit
44//! ```
45//!
46//! # Model: `eccentric_secular`
47//!
48//! Adds eccentricity through a **nonsingular** `(h, k)` parameterization so
49//! low-eccentricity fits stay well-conditioned and reduce exactly to the
50//! circular model as `e -> 0`. The eight free elements are
51//!
52//! - `a`, `i`, `raan0`, `raan_rate` (as in the circular model),
53//! - `h = e·sin ω`, `k = e·cos ω` (eccentricity vector components),
54//! - `L0` - the mean argument of latitude at epoch (`ω + M0`),
55//! - `n` - mean motion.
56//!
57//! Derived: `e = sqrt(h² + k²)`, `ω = atan2(h, k)`. At an offset `dt` the model
58//! advances the mean argument of latitude linearly and solves Kepler's equation:
59//!
60//! ```text
61//! λ(t) = L0 + n·dt                     # mean argument of latitude
62//! M    = λ − ω                         # mean anomaly
63//! E − e·sin E = M                      # Kepler (Newton)
64//! ν    = atan2(sqrt(1−e²)·sin E, cos E − e)
65//! r    = a·(1 − e·cos E)
66//! u    = ω + ν                         # argument of latitude
67//! r_gcrs = Rz(raan) · Rx(i) · [r·cos u, r·sin u, 0]
68//! ```
69//!
70//! At `e = 0` this is identical to `circular_secular` with `arg_lat0 = L0`
71//! (`u -> λ`), which is why the parameterization is nonsingular: no separate
72//! `ω`/`M0` split is fitted, and conditioning stays good for near-circular
73//! orbits.
74//!
75//! # Nodal regression seed
76//!
77//! `raan_rate` is **fitted**, but it is seeded from the J2 secular nodal
78//! regression (Vallado, *Fundamentals of Astrodynamics and Applications*, the
79//! mean-element secular rate):
80//!
81//! ```text
82//! raan_rate_j2 = -1.5 * n * J2 * (Re / a)^2 * cos(i)
83//! ```
84//!
85//! Both the fitted value and the J2 seed are retained on [`Elements`]
86//! ([`Elements::raan_rate_rad_s`] and [`Elements::raan_rate_j2_rad_s`]); the
87//! model does not claim to be a pure J2 propagation, only J2-seeded.
88//!
89//! # Frames and units
90//!
91//! Internally the fit and evaluation work in **GCRS**, kilometers, seconds.
92//! Input samples are ITRF/IGS ECEF in **meters** (the SP3 convention); each is
93//! converted to GCRS via the core IAU transform before fitting. Position output
94//! is returned in **meters**, in ECEF by default or GCRS on request. ECEF
95//! velocity includes the Earth-rotation (transport) term.
96
97use crate::astro::constants::time::{SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE};
98use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
99use crate::astro::frames::transforms::{
100    gcrs_to_itrs_compute, gcrs_to_itrs_matrix, itrs_to_gcrs_compute, mat3_vec3_mul_unchecked,
101    teme_to_gcrs_compute, TemeStateKm,
102};
103use crate::astro::math::least_squares::{
104    self, solve_trf, LeastSquaresProblem, SolveOptions, Status,
105};
106use crate::astro::math::vec3::{cross3_ref as cross, norm3_ref as norm};
107use crate::astro::sgp4::{JulianDate, Satellite};
108use crate::astro::time::civil::{civil_from_julian_day_number, split_julian_date};
109use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
110use crate::astro::time::scales::{julian_day_number, TimeScales};
111use nalgebra::DVector;
112
113use crate::constants::{M_PER_KM, OMEGA_E_DOT_RAD_S};
114use crate::sp3::Sp3;
115use crate::tolerances::{
116    ECCENTRICITY_ZERO_EPS, REDUCED_ORBIT_KEPLER_STEP_EPS_RAD, REDUCED_ORBIT_SOLVER_TOL,
117    VECTOR_NORM_ZERO_EPS,
118};
119use crate::validate;
120use crate::GnssSatelliteId;
121
122mod time;
123use time::dt_seconds;
124pub use time::CalendarEpoch;
125
126/// Minimum number of samples the fitter accepts. The circular model solves five
127/// free elements and the eccentric model eight; each ECEF sample contributes
128/// three residuals, so four well-spread samples (twelve residuals) is the floor.
129/// Below this the geometry seed and refinement are unreliable.
130pub const MIN_SAMPLES: usize = 4;
131
132/// Which mean-element model the fit and evaluation use.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum Model {
135    /// Circular orbit, eccentricity fixed at zero. Best for near-circular
136    /// orbits; the default.
137    #[default]
138    CircularSecular,
139    /// Eccentric orbit via a nonsingular `(h, k)` parameterization. Reproduces
140    /// the radial `a·e` signal and degrades to the circular case as `e -> 0`.
141    EccentricSecular,
142}
143
144/// One fitting/drift truth sample: an epoch and an ECEF (ITRF) position in
145/// meters.
146#[derive(Debug, Clone, Copy)]
147pub struct EcefSample {
148    /// The sample epoch.
149    pub epoch: CalendarEpoch,
150    /// ECEF X in meters.
151    pub x_m: f64,
152    /// ECEF Y in meters.
153    pub y_m: f64,
154    /// ECEF Z in meters.
155    pub z_m: f64,
156}
157
158impl EcefSample {
159    /// Construct a sample from an epoch and ECEF meter components.
160    pub const fn new(epoch: CalendarEpoch, x_m: f64, y_m: f64, z_m: f64) -> Self {
161        Self {
162            epoch,
163            x_m,
164            y_m,
165            z_m,
166        }
167    }
168}
169
170/// The fitted mean elements plus the kept J2 seed.
171///
172/// Lengths are meters, angles radians, rates radians-or-meters per second. The
173/// `raan_rate` is the fitted value; `raan_rate_j2` is the J2 nodal-regression
174/// seed it started from (see the module documentation).
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct Elements {
177    /// Which model these elements belong to.
178    pub model: Model,
179    /// Reference epoch `t0`; all linear angle advances are measured from here.
180    pub epoch: CalendarEpoch,
181    /// Semi-major axis `a` in meters.
182    pub a_m: f64,
183    /// Eccentricity. `0.0` for the circular model; `sqrt(h² + k²)` for the
184    /// eccentric model.
185    pub e: f64,
186    /// Inclination `i` in radians.
187    pub i_rad: f64,
188    /// Right ascension of the ascending node at `t0`, radians.
189    pub raan_rad: f64,
190    /// Fitted nodal regression rate, radians per second.
191    pub raan_rate_rad_s: f64,
192    /// J2 nodal-regression seed for `raan_rate`, radians per second.
193    pub raan_rate_j2_rad_s: f64,
194    /// Argument of latitude at `t0`, radians. For the circular model this is
195    /// `arg_lat0`; for the eccentric model it is `L0 = ω + M0`, the mean
196    /// argument of latitude at epoch (equal to `arg_lat0` at `e = 0`).
197    pub arg_lat_rad: f64,
198    /// Mean motion `n`, radians per second.
199    pub mean_motion_rad_s: f64,
200    /// Eccentricity vector component `h = e·sin ω`. Zero for the circular model.
201    pub h: f64,
202    /// Eccentricity vector component `k = e·cos ω`. Zero for the circular model.
203    pub k: f64,
204    /// Argument of perigee `ω = atan2(h, k)`, radians. Zero for the circular
205    /// model (where it is undefined).
206    pub arg_perigee_rad: f64,
207}
208
209/// Residual statistics from a fit, in meters.
210#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct FitStats {
212    /// Root-mean-square GCRS position residual over the samples, meters.
213    pub rms_m: f64,
214    /// Maximum GCRS position residual over the samples, meters.
215    pub max_m: f64,
216    /// Number of samples used in the fit.
217    pub n_samples: usize,
218}
219
220/// A fitted model: elements plus the residual statistics of the fit.
221#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct ReducedOrbit {
223    /// The fitted mean elements.
224    pub elements: Elements,
225    /// Residual statistics of the fit.
226    pub stats: FitStats,
227}
228
229/// Which reference frame a position/velocity result is expressed in.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum Frame {
232    /// Inertial GCRS (ECI).
233    Gcrs,
234    /// Earth-fixed ITRF/IGS ECEF.
235    Ecef,
236}
237
238/// A per-epoch drift entry: the model-vs-truth position error at one epoch.
239#[derive(Debug, Clone, Copy, PartialEq)]
240pub struct DriftEntry {
241    /// The epoch evaluated.
242    pub epoch: CalendarEpoch,
243    /// Position error magnitude (model minus truth), meters.
244    pub error_m: f64,
245}
246
247/// The result of a source-backed drift evaluation.
248#[derive(Debug, Clone, PartialEq)]
249pub struct DriftReport {
250    /// Per-epoch errors, in input order.
251    pub per_epoch: Vec<DriftEntry>,
252    /// Maximum error over the horizon, meters.
253    pub max_m: f64,
254    /// Root-mean-square error over the horizon, meters.
255    pub rms_m: f64,
256    /// The first epoch at which the error crosses the requested threshold, or
257    /// `None` if it never does over the supplied horizon.
258    pub threshold_horizon: Option<CalendarEpoch>,
259    /// Index into [`DriftReport::per_epoch`] of the first entry whose error
260    /// crosses the threshold, or `None` if it never does. This is the same
261    /// crossing as [`DriftReport::threshold_horizon`] expressed as plain data, so
262    /// a binding can marshal the threshold (and look the epoch up in `per_epoch`)
263    /// without fabricating a placeholder epoch when there is no crossing.
264    pub threshold_index: Option<usize>,
265}
266
267/// Source used by source-backed reduced-orbit fit and drift drivers.
268#[derive(Debug, Clone, Copy)]
269pub enum ReducedOrbitSource<'a> {
270    /// Precise SP3 product plus the satellite to sample.
271    Sp3 {
272        product: &'a Sp3,
273        satellite: GnssSatelliteId,
274    },
275    /// Initialized SGP4 satellite sampled in UTC.
276    Sgp4 { satellite: &'a Satellite },
277}
278
279/// Sampling window and cadence for source-backed reduced-orbit drivers.
280#[derive(Debug, Clone, Copy, PartialEq)]
281pub struct ReducedOrbitSourceSampling {
282    pub t0: CalendarEpoch,
283    pub t1: CalendarEpoch,
284    pub cadence_s: f64,
285}
286
287impl ReducedOrbitSourceSampling {
288    pub const fn new(t0: CalendarEpoch, t1: CalendarEpoch, cadence_s: f64) -> Self {
289        Self { t0, t1, cadence_s }
290    }
291}
292
293/// Options for [`fit_reduced_orbit_source`].
294#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct ReducedOrbitSourceFitOptions {
296    pub sampling: ReducedOrbitSourceSampling,
297    pub model: Model,
298}
299
300/// Options for [`drift_reduced_orbit_source`].
301#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct ReducedOrbitSourceDriftOptions {
303    pub sampling: ReducedOrbitSourceSampling,
304    pub threshold_m: f64,
305}
306
307/// Options for [`fit_piecewise_reduced_orbit_source`].
308#[derive(Debug, Clone, Copy, PartialEq)]
309pub struct PiecewiseOrbitSourceFitOptions {
310    pub sampling: ReducedOrbitSourceSampling,
311    pub model: Model,
312    pub segment_s: f64,
313}
314
315/// A source-backed single-model fit and its sampling metadata.
316#[derive(Debug, Clone, PartialEq)]
317pub struct ReducedOrbitSourceFit {
318    pub orbit: ReducedOrbit,
319    pub requested_samples: usize,
320}
321
322/// A source-backed single-model drift report and its sampling metadata.
323#[derive(Debug, Clone, PartialEq)]
324pub struct ReducedOrbitSourceDrift {
325    pub report: DriftReport,
326    pub requested_samples: usize,
327}
328
329/// A source-backed piecewise fit and its sampling metadata.
330#[derive(Debug, Clone, PartialEq)]
331pub struct PiecewiseOrbitSourceFit {
332    pub orbit: PiecewiseOrbit,
333    pub requested_samples: usize,
334}
335
336/// Error returned by source-backed reduced-orbit drivers.
337#[derive(Debug, Clone)]
338pub enum ReducedOrbitSourceError {
339    InvalidWindow,
340    InvalidCadence,
341    InvalidSegment,
342    TooFewSamples { got: usize, required: usize },
343    Reduced(ReducedOrbitError),
344    Piecewise(PiecewiseOrbitError),
345}
346
347impl core::fmt::Display for ReducedOrbitSourceError {
348    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
349        match self {
350            Self::InvalidWindow => write!(f, "invalid reduced-orbit source window"),
351            Self::InvalidCadence => write!(f, "invalid reduced-orbit source cadence"),
352            Self::InvalidSegment => write!(f, "invalid reduced-orbit source segment"),
353            Self::TooFewSamples { got, required } => {
354                write!(f, "only {got} source samples; need at least {required}")
355            }
356            Self::Reduced(error) => write!(f, "{error}"),
357            Self::Piecewise(error) => write!(f, "{error:?}"),
358        }
359    }
360}
361
362impl std::error::Error for ReducedOrbitSourceError {}
363
364/// One fitted segment in a piecewise reduced-orbit model.
365#[derive(Debug, Clone, PartialEq)]
366pub struct PiecewiseSegment {
367    /// Inclusive segment start.
368    pub t0: CalendarEpoch,
369    /// Exclusive segment end, except for the final segment where it is inclusive.
370    pub t1: CalendarEpoch,
371    /// Fitted reduced-orbit model for this segment.
372    pub orbit: ReducedOrbit,
373}
374
375/// A long span represented by contiguous independently-fitted reduced-orbit segments.
376#[derive(Debug, Clone, PartialEq)]
377pub struct PiecewiseOrbit {
378    /// Model fitted in every segment.
379    pub model: Model,
380    /// Advertised coverage start.
381    pub t0: CalendarEpoch,
382    /// Advertised coverage end, inclusive for the final segment.
383    pub t1: CalendarEpoch,
384    /// Rounded segment length used to tile the requested window, seconds.
385    pub segment_s: i64,
386    /// Contiguous fitted segments.
387    pub segments: Vec<PiecewiseSegment>,
388}
389
390/// Errors from fitting or evaluating a reduced orbit.
391#[derive(Debug, Clone)]
392pub enum ReducedOrbitError {
393    /// Fewer samples were supplied than the fit requires.
394    TooFewSamples {
395        /// The number of samples supplied.
396        got: usize,
397        /// The minimum number required ([`MIN_SAMPLES`]).
398        required: usize,
399    },
400    /// The fit window is empty or inverted (`t1 <= t0`), or all samples share an
401    /// epoch so no rate can be resolved.
402    InvalidWindow,
403    /// The samples are collinear or coincident, so the orbital plane normal is
404    /// undefined and the seed cannot be built.
405    SingularPlaneFit,
406    /// The orbit is near-equatorial (`i ~ 0`): the ascending node, and hence
407    /// `raan`, is undefined.
408    RaanAmbiguous,
409    /// The least-squares refinement hit a rank-deficient Jacobian.
410    Singular(least_squares::SolveError),
411    /// The least-squares refinement did not reach a stopping tolerance within
412    /// the evaluation budget.
413    FitDidNotConverge,
414    /// A public evaluation input was non-finite or outside the model domain.
415    InvalidInput {
416        /// Input field name.
417        field: &'static str,
418        /// Human-readable validation failure.
419        reason: &'static str,
420    },
421}
422
423impl core::fmt::Display for ReducedOrbitError {
424    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
425        match self {
426            ReducedOrbitError::TooFewSamples { got, required } => {
427                write!(f, "only {got} samples; need at least {required}")
428            }
429            ReducedOrbitError::InvalidWindow => {
430                write!(f, "the fit window is empty, inverted, or has no time span")
431            }
432            ReducedOrbitError::SingularPlaneFit => {
433                write!(
434                    f,
435                    "samples are collinear or coincident; orbital plane undefined"
436                )
437            }
438            ReducedOrbitError::RaanAmbiguous => {
439                write!(f, "near-equatorial orbit; ascending node (raan) undefined")
440            }
441            ReducedOrbitError::Singular(e) => write!(f, "degenerate fit geometry: {e}"),
442            ReducedOrbitError::FitDidNotConverge => {
443                write!(f, "least-squares refinement did not converge")
444            }
445            ReducedOrbitError::InvalidInput { field, reason } => {
446                write!(f, "invalid reduced-orbit input {field}: {reason}")
447            }
448        }
449    }
450}
451
452impl std::error::Error for ReducedOrbitError {}
453
454impl From<least_squares::SolveError> for ReducedOrbitError {
455    fn from(e: least_squares::SolveError) -> Self {
456        ReducedOrbitError::Singular(e)
457    }
458}
459
460/// Errors from fitting or evaluating a piecewise reduced orbit.
461#[derive(Debug, Clone)]
462pub enum PiecewiseOrbitError {
463    /// Segment length is missing, non-positive, or rounds below one second.
464    InvalidSegment,
465    /// Query epoch is outside the piecewise model coverage.
466    OutOfRange,
467    /// A fit/drift operation did not have enough samples for the requested step.
468    TooFewSamples {
469        /// The number of usable samples supplied.
470        got: usize,
471        /// The minimum number required.
472        required: usize,
473    },
474    /// The underlying single-segment model returned an error.
475    Reduced(ReducedOrbitError),
476}
477
478impl From<ReducedOrbitError> for PiecewiseOrbitError {
479    fn from(e: ReducedOrbitError) -> Self {
480        PiecewiseOrbitError::Reduced(e)
481    }
482}
483
484/// Near-equatorial threshold (radians) below which `raan` is treated as
485/// undefined.
486const MIN_INCLINATION_RAD: f64 = 1.0e-3;
487
488/// Relative floor on the averaged plane-normal magnitude, scaled by `a_km^2`
489/// (the cross product of two position vectors scales as `r^2 ~ a^2`). A normal
490/// below this is treated as a vanishing cross product from collinear/coincident
491/// samples, i.e. a singular plane fit.
492const PLANE_NORMAL_SINGULAR_REL_EPS: f64 = 1.0e-9;
493
494// ---------------------------------------------------------------------------
495// Element evaluation (GCRS), kilometers internally.
496// ---------------------------------------------------------------------------
497
498/// Free parameters in fit order: `[a_km, i, raan0, raan_rate, arg_lat0, n]`.
499/// Eccentricity is held at zero and is not a free parameter.
500const N_PARAMS: usize = 6;
501
502/// Map a normalized fit vector back to physical parameters
503/// `[a_km, i, raan0, raan_rate, arg_lat0, n]`. `a` is carried in units of the
504/// seed semi-major axis and the two rates as the angle swept across the window
505/// (`rate * window`), so the solver sees comparably scaled columns.
506fn unscale_params(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS] {
507    [
508        x[0] * a_scale,
509        x[1],
510        x[2],
511        x[3] / rate_scale,
512        x[4],
513        x[5] / rate_scale,
514    ]
515}
516
517/// Free parameters of the eccentric model, in fit order:
518/// `[a_km, i, raan0, raan_rate, h, k, L0, n]`.
519const N_PARAMS_ECC: usize = 8;
520
521/// Map a normalized eccentric fit vector back to physical parameters
522/// `[a_km, i, raan0, raan_rate, h, k, L0, n]`. The same normalization as the
523/// circular model: `a` in units of the seed axis, the two rates as swept angle.
524/// `h` and `k` are unscaled - at unit magnitude they perturb the position at the
525/// `~a` kilometre level, comparable to the angle columns.
526fn unscale_params_ecc(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS_ECC] {
527    [
528        x[0] * a_scale,
529        x[1],
530        x[2],
531        x[3] / rate_scale,
532        x[4],
533        x[5],
534        x[6],
535        x[7] / rate_scale,
536    ]
537}
538
539/// Solve Kepler's equation `E − e·sin E = M` for the eccentric anomaly `E`
540/// (radians) by Newton's method. `e < 1`. `M` is wrapped to `[0, 2π)` for
541/// conditioning; the GNSS eccentricities here (`e ~ 0.01-0.17`) converge in a
542/// few iterations.
543fn solve_kepler(m: f64, e: f64) -> f64 {
544    let two_pi = 2.0 * std::f64::consts::PI;
545    let m = m.rem_euclid(two_pi);
546    let mut ee = if e < 0.8 { m } else { std::f64::consts::PI };
547    for _ in 0..30 {
548        let f = ee - e * ee.sin() - m;
549        let fp = 1.0 - e * ee.cos();
550        let d = f / fp;
551        ee -= d;
552        if d.abs() < REDUCED_ORBIT_KEPLER_STEP_EPS_RAD {
553            break;
554        }
555    }
556    ee
557}
558
559/// Evaluate the GCRS position (kilometers) of the `eccentric_secular` model from
560/// a parameter vector `[a_km, i, raan0, raan_rate, h, k, L0, n]` at offset `dt`.
561fn eval_gcrs_km_ecc(p: &[f64], dt: f64) -> [f64; 3] {
562    let a = p[0];
563    let i = p[1];
564    let raan = p[2] + p[3] * dt;
565    let h = p[4];
566    let k = p[5];
567    let lambda = p[6] + p[7] * dt;
568
569    let e = (h * h + k * k).sqrt();
570    if e < ECCENTRICITY_ZERO_EPS {
571        // Circular fast path: u -> lambda, exactly the circular model.
572        return eval_gcrs_km(&[a, i, p[2], p[3], p[6], p[7]], dt);
573    }
574    let omega = h.atan2(k);
575    let mm = lambda - omega;
576    let big_e = solve_kepler(mm, e);
577    let (se, ce) = big_e.sin_cos();
578    let r = a * (1.0 - e * ce);
579    let nu = (((1.0 - e * e).sqrt()) * se).atan2(ce - e);
580    let u = omega + nu;
581
582    rotate_in_plane_km([r * u.cos(), r * u.sin()], i, raan)
583}
584
585/// Rotate an in-plane (node-aligned) 2-vector `[x, y]` km by `Rx(i)` then
586/// `Rz(raan)` into GCRS, matching the circular model's outer rotation.
587fn rotate_in_plane_km(xy: [f64; 2], i: f64, raan: f64) -> [f64; 3] {
588    let (si, ci) = i.sin_cos();
589    let (sr, cr) = raan.sin_cos();
590    let x1 = xy[0];
591    let y1 = xy[1] * ci;
592    let z1 = xy[1] * si;
593    [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
594}
595
596/// Analytic GCRS velocity (km/s) of the `eccentric_secular` model at offset `dt`.
597/// Derivative of [`eval_gcrs_km_ecc`] with respect to time (`ω`, `e` constant,
598/// `dM/dt = n`, `dRaan/dt = raan_rate`).
599fn eval_gcrs_velocity_km_s_ecc(p: &[f64], dt: f64) -> [f64; 3] {
600    let a = p[0];
601    let i = p[1];
602    let raan_rate = p[3];
603    let h = p[4];
604    let k = p[5];
605    let n = p[7];
606    let raan = p[2] + raan_rate * dt;
607    let lambda = p[6] + n * dt;
608
609    let e = (h * h + k * k).sqrt();
610    if e < ECCENTRICITY_ZERO_EPS {
611        return eval_gcrs_velocity_km_s(&[a, i, p[2], p[3], p[6], p[7]], dt);
612    }
613    let omega = h.atan2(k);
614    let mm = lambda - omega;
615    let big_e = solve_kepler(mm, e);
616    let (se, ce) = big_e.sin_cos();
617    // dE/dt from differentiating Kepler: (1 - e cos E) Edot = dM/dt = n.
618    let edot = n / (1.0 - e * ce);
619    let beta = (1.0 - e * e).sqrt();
620
621    // Perifocal position/velocity (x toward perigee).
622    let x_pf = a * (ce - e);
623    let y_pf = a * beta * se;
624    let xdot_pf = -a * se * edot;
625    let ydot_pf = a * beta * ce * edot;
626
627    // Rotate the perifocal frame into the node-aligned in-plane frame by ω.
628    let (so, co) = omega.sin_cos();
629    let x1 = co * x_pf - so * y_pf;
630    let y1 = so * x_pf + co * y_pf;
631    let dx1 = co * xdot_pf - so * ydot_pf;
632    let dy1 = so * xdot_pf + co * ydot_pf;
633
634    // Apply Rx(i) then Rz(raan) with the raan_rate coupling, exactly as the
635    // circular velocity does (only the in-plane inputs differ).
636    let (si, ci) = i.sin_cos();
637    let (sr, cr) = raan.sin_cos();
638    let y1i = y1 * ci;
639    let dy1i = dy1 * ci;
640
641    let vx = cr * dx1 - sr * dy1i + raan_rate * (-sr * x1 - cr * y1i);
642    let vy = sr * dx1 + cr * dy1i + raan_rate * (cr * x1 - sr * y1i);
643    let vz = dy1 * si;
644    [vx, vy, vz]
645}
646
647/// Evaluate the GCRS position (kilometers) of the `circular_secular` model from
648/// a parameter vector at offset `dt` seconds from epoch.
649fn eval_gcrs_km(p: &[f64], dt: f64) -> [f64; 3] {
650    let a = p[0];
651    let i = p[1];
652    let raan = p[2] + p[3] * dt;
653    let u = p[4] + p[5] * dt;
654
655    // In-plane circle.
656    let (su, cu) = u.sin_cos();
657    let xp = a * cu;
658    let yp = a * su;
659
660    // Rotate by inclination about x, then by raan about z.
661    let (si, ci) = i.sin_cos();
662    let (sr, cr) = raan.sin_cos();
663
664    // Rx(i) * [xp, yp, 0] = [xp, yp*ci, yp*si]
665    let x1 = xp;
666    let y1 = yp * ci;
667    let z1 = yp * si;
668
669    // Rz(raan) * [x1, y1, z1]
670    [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
671}
672
673/// Analytic GCRS velocity (km/s) of the model at offset `dt`. Derivative of
674/// [`eval_gcrs_km`] with respect to time.
675fn eval_gcrs_velocity_km_s(p: &[f64], dt: f64) -> [f64; 3] {
676    let a = p[0];
677    let i = p[1];
678    let raan_rate = p[3];
679    let n = p[5];
680    let raan = p[2] + raan_rate * dt;
681    let u = p[4] + n * dt;
682
683    let (su, cu) = u.sin_cos();
684    let (si, ci) = i.sin_cos();
685    let (sr, cr) = raan.sin_cos();
686
687    let xp = a * cu;
688    let yp = a * su;
689    // d/dt of in-plane position.
690    let dxp = -a * su * n;
691    let dyp = a * cu * n;
692
693    // Position in the inclined-but-not-yet-noded frame.
694    let x1 = xp;
695    let y1 = yp * ci;
696    // Its time derivative (i constant).
697    let dx1 = dxp;
698    let dy1 = dyp * ci;
699
700    // r_gcrs = Rz(raan) * [x1, y1, z1]; differentiate including dRaan/dt.
701    // d/dt(cr) = -sr*raan_rate, d/dt(sr) = cr*raan_rate.
702    let vx = cr * dx1 - sr * dy1 + raan_rate * (-sr * x1 - cr * y1);
703    let vy = sr * dx1 + cr * dy1 + raan_rate * (cr * x1 - sr * y1);
704    let vz = dyp * si;
705    [vx, vy, vz]
706}
707
708// ---------------------------------------------------------------------------
709// Seed extraction.
710// ---------------------------------------------------------------------------
711
712struct GcrsSample {
713    dt: f64,
714    r_km: [f64; 3],
715}
716
717/// Build the seed parameter vector from GCRS samples.
718fn seed_params(samples: &[GcrsSample]) -> Result<[f64; N_PARAMS], ReducedOrbitError> {
719    // Mean radius -> a.
720    let a_km = samples.iter().map(|s| norm(&s.r_km)).sum::<f64>() / samples.len() as f64;
721    if !a_km.is_finite() || a_km <= 0.0 {
722        return Err(ReducedOrbitError::SingularPlaneFit);
723    }
724
725    // Averaged plane normal from consecutive cross products.
726    let mut h = [0.0_f64; 3];
727    for w in samples.windows(2) {
728        let c = cross(&w[0].r_km, &w[1].r_km);
729        h[0] += c[0];
730        h[1] += c[1];
731        h[2] += c[2];
732    }
733    let hn = norm(&h);
734    if !hn.is_finite() || hn <= a_km * a_km * PLANE_NORMAL_SINGULAR_REL_EPS {
735        // Cross products vanish: collinear/coincident samples.
736        return Err(ReducedOrbitError::SingularPlaneFit);
737    }
738    let hhat = [h[0] / hn, h[1] / hn, h[2] / hn];
739
740    // Inclination from the normal's z component.
741    let i = hhat[2].clamp(-1.0, 1.0).acos();
742    if i < MIN_INCLINATION_RAD || (std::f64::consts::PI - i) < MIN_INCLINATION_RAD {
743        return Err(ReducedOrbitError::RaanAmbiguous);
744    }
745
746    // RAAN from the node direction n = z_hat x h_hat (points to ascending node).
747    let node = [-hhat[1], hhat[0], 0.0];
748    let node_n = norm(&node);
749    if node_n <= VECTOR_NORM_ZERO_EPS {
750        return Err(ReducedOrbitError::RaanAmbiguous);
751    }
752    let raan0 = node[1].atan2(node[0]);
753    let nhat = [node[0] / node_n, node[1] / node_n, 0.0];
754
755    // Argument of latitude of the first sample: angle from the node, measured in
756    // the orbit plane (positive toward h x n direction of motion).
757    let r0 = &samples[0].r_km;
758    let cos_u = (r0[0] * nhat[0] + r0[1] * nhat[1] + r0[2] * nhat[2]) / norm(r0);
759    // In-plane "perpendicular to node" axis: h_hat x n_hat.
760    let p_axis = cross(&hhat, &nhat);
761    let sin_u = (r0[0] * p_axis[0] + r0[1] * p_axis[1] + r0[2] * p_axis[2]) / norm(r0);
762    let arg_lat0 = sin_u.atan2(cos_u);
763
764    // Mean motion from the Keplerian relation at the fitted a (km, MU in km^3/s^2).
765    let n = (MU_EARTH / (a_km * a_km * a_km)).sqrt();
766
767    // J2 nodal regression seed.
768    let raan_rate = raan_rate_j2(n, i, a_km);
769
770    Ok([a_km, i, raan0, raan_rate, arg_lat0, n])
771}
772
773/// J2 secular nodal-regression rate (Vallado), radians per second.
774/// `a` in kilometers (matching `RE_EARTH`).
775fn raan_rate_j2(n: f64, i: f64, a_km: f64) -> f64 {
776    let re_over_a = RE_EARTH / a_km;
777    -1.5 * n * J2_EARTH * re_over_a * re_over_a * i.cos()
778}
779
780// ---------------------------------------------------------------------------
781// Fit.
782// ---------------------------------------------------------------------------
783
784/// Fit the default `circular_secular` model to ECEF samples, with all epochs
785/// interpreted in `scale` (e.g. an SP3 product's GPST).
786///
787/// `samples` are `(epoch, ECEF meters)`; they are ordered by time, the earliest
788/// becomes the model epoch `t0`, each is converted ITRS->GCRS at the correct
789/// Earth orientation, and the five free elements (`a`, `i`, `raan0`, `raan_rate`,
790/// `arg_lat0`, `n`) are refined to minimize stacked GCRS position residuals.
791///
792/// Equivalent to [`fit_with_model`] with [`Model::CircularSecular`].
793pub fn fit(samples: &[EcefSample], scale: TimeScale) -> Result<ReducedOrbit, ReducedOrbitError> {
794    fit_with_model(samples, scale, Model::CircularSecular)
795}
796
797/// Fit a chosen [`Model`] to ECEF samples interpreted in `scale`.
798///
799/// The seed (mean axis, plane normal, node, mean motion, J2 nodal rate) and the
800/// normalized Levenberg-Marquardt refinement are shared by both models; the
801/// eccentric model adds the `h`, `k` eccentricity-vector columns (seeded at
802/// zero) and solves Kepler's equation per residual.
803pub fn fit_with_model(
804    samples: &[EcefSample],
805    scale: TimeScale,
806    model: Model,
807) -> Result<ReducedOrbit, ReducedOrbitError> {
808    match model {
809        Model::CircularSecular => fit_circular(samples, scale),
810        Model::EccentricSecular => fit_eccentric(samples, scale),
811    }
812}
813
814fn fit_circular(
815    samples: &[EcefSample],
816    scale: TimeScale,
817) -> Result<ReducedOrbit, ReducedOrbitError> {
818    if samples.len() < MIN_SAMPLES {
819        return Err(ReducedOrbitError::TooFewSamples {
820            got: samples.len(),
821            required: MIN_SAMPLES,
822        });
823    }
824    validate_fit_epochs(samples, scale)?;
825
826    // Order by absolute time so the seed, t0, and consecutive-pair plane fit do
827    // not depend on the caller's sample order.
828    let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
829        .iter()
830        .map(|s| (s.epoch.time_scales(scale), s))
831        .collect();
832    ordered.sort_by(|a, b| {
833        (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
834    });
835
836    let t0_cal = ordered[0].1.epoch;
837    let t0_ts = ordered[0].0;
838
839    // Convert every ECEF sample to GCRS km at its own epoch.
840    let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
841    for (ts, s) in &ordered {
842        let (x, y, z) =
843            itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
844                .expect("valid frame transform");
845        let dt = dt_seconds(&t0_ts, ts);
846        gcrs.push(GcrsSample {
847            dt,
848            r_km: [x, y, z],
849        });
850    }
851
852    // Window must span time (also rejects a non-finite span).
853    let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
854        - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
855    if !dt_span.is_finite() || dt_span <= 0.0 {
856        return Err(ReducedOrbitError::InvalidWindow);
857    }
858
859    let seed = seed_params(&gcrs)?;
860
861    // The physical parameters span ~10 orders of magnitude (a ~ 2.7e4 km down to
862    // the nodal rate ~ 1e-8 rad/s), and the core solver damps with a uniform
863    // mu*I and no per-variable scaling. Left unscaled, the nodal rate is
864    // effectively invisible to the solver and stays at its seed. Fit instead in a
865    // normalized space where every parameter perturbs the position at the ~a
866    // kilometre level: a is measured in units of the seed semi-major axis, and
867    // the two rates are carried as the total angle they sweep across the window.
868    let a_scale = seed[0];
869    let rate_scale = dt_span; // raan_rate and n fit as (rate * window) = swept angle
870    let seed_scaled = [
871        seed[0] / a_scale,
872        seed[1],
873        seed[2],
874        seed[3] * rate_scale,
875        seed[4],
876        seed[5] * rate_scale,
877    ];
878
879    // Stacked GCRS position residuals (km), one xyz block per sample.
880    let m = 3 * gcrs.len();
881    let residual = {
882        let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
883        move |x: &DVector<f64>| -> DVector<f64> {
884            let xs = x.as_slice();
885            let p = unscale_params(xs, a_scale, rate_scale);
886            let mut r = Vec::with_capacity(m);
887            for (dt, obs) in &gcrs_ref {
888                let model = eval_gcrs_km(&p, *dt);
889                r.push(model[0] - obs[0]);
890                r.push(model[1] - obs[1]);
891                r.push(model[2] - obs[2]);
892            }
893            DVector::from_vec(r)
894        }
895    };
896
897    let x0 = DVector::from_row_slice(&seed_scaled);
898    let problem = LeastSquaresProblem::new(residual, x0);
899    let opts = SolveOptions {
900        gtol: REDUCED_ORBIT_SOLVER_TOL,
901        ftol: REDUCED_ORBIT_SOLVER_TOL,
902        xtol: REDUCED_ORBIT_SOLVER_TOL,
903        max_nfev: 400,
904    };
905    let report = solve_trf(&problem, &opts)?;
906
907    let converged = matches!(
908        report.status,
909        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
910    );
911    if !converged {
912        return Err(ReducedOrbitError::FitDidNotConverge);
913    }
914
915    let p = unscale_params(report.x.as_slice(), a_scale, rate_scale);
916    // Residual stats in meters.
917    let res = &report.residual;
918    let n_samp = gcrs.len();
919    let mut sumsq = 0.0;
920    let mut maxsq = 0.0_f64;
921    for k in 0..n_samp {
922        let dx = res[3 * k] * M_PER_KM;
923        let dy = res[3 * k + 1] * M_PER_KM;
924        let dz = res[3 * k + 2] * M_PER_KM;
925        let e2 = dx * dx + dy * dy + dz * dz;
926        sumsq += e2;
927        if e2 > maxsq {
928            maxsq = e2;
929        }
930    }
931    let rms_m = (sumsq / n_samp as f64).sqrt();
932    let max_m = maxsq.sqrt();
933
934    let elements = Elements {
935        model: Model::CircularSecular,
936        epoch: t0_cal,
937        a_m: p[0] * M_PER_KM,
938        e: 0.0,
939        i_rad: p[1],
940        raan_rad: p[2],
941        raan_rate_rad_s: p[3],
942        raan_rate_j2_rad_s: raan_rate_j2(p[5], p[1], p[0]),
943        arg_lat_rad: p[4],
944        mean_motion_rad_s: p[5],
945        h: 0.0,
946        k: 0.0,
947        arg_perigee_rad: 0.0,
948    };
949
950    Ok(ReducedOrbit {
951        elements,
952        stats: FitStats {
953            rms_m,
954            max_m,
955            n_samples: n_samp,
956        },
957    })
958}
959
960/// Convert ordered ECEF samples to GCRS-km samples and the model epoch.
961fn to_gcrs_samples(
962    samples: &[EcefSample],
963    scale: TimeScale,
964) -> Result<(CalendarEpoch, Vec<GcrsSample>, f64), ReducedOrbitError> {
965    if samples.len() < MIN_SAMPLES {
966        return Err(ReducedOrbitError::TooFewSamples {
967            got: samples.len(),
968            required: MIN_SAMPLES,
969        });
970    }
971    validate_fit_epochs(samples, scale)?;
972
973    let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
974        .iter()
975        .map(|s| (s.epoch.time_scales(scale), s))
976        .collect();
977    ordered.sort_by(|a, b| {
978        (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
979    });
980
981    let t0_cal = ordered[0].1.epoch;
982    let t0_ts = ordered[0].0;
983
984    let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
985    for (ts, s) in &ordered {
986        let (x, y, z) =
987            itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
988                .expect("valid frame transform");
989        let dt = dt_seconds(&t0_ts, ts);
990        gcrs.push(GcrsSample {
991            dt,
992            r_km: [x, y, z],
993        });
994    }
995
996    let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
997        - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
998    if !dt_span.is_finite() || dt_span <= 0.0 {
999        return Err(ReducedOrbitError::InvalidWindow);
1000    }
1001
1002    Ok((t0_cal, gcrs, dt_span))
1003}
1004
1005/// Fit the `eccentric_secular` model. Reuses the circular seed and the same
1006/// normalized LM, adding the `h`, `k` columns (seeded at zero) and solving
1007/// Kepler's equation per residual.
1008fn fit_eccentric(
1009    samples: &[EcefSample],
1010    scale: TimeScale,
1011) -> Result<ReducedOrbit, ReducedOrbitError> {
1012    let (t0_cal, gcrs, dt_span) = to_gcrs_samples(samples, scale)?;
1013
1014    // The circular seed supplies a, i, raan0, raan_rate, arg_lat0 (=L0), n.
1015    let seed_c = seed_params(&gcrs)?;
1016    let a_scale = seed_c[0];
1017    let rate_scale = dt_span;
1018
1019    // Seed e = 0 (h = k = 0); L0 from the first sample's argument of latitude.
1020    // Normalized seed: a in seed-axis units, rates as swept angle, h/k unscaled.
1021    let seed_scaled = [
1022        1.0,                    // a / a_scale
1023        seed_c[1],              // i
1024        seed_c[2],              // raan0
1025        seed_c[3] * rate_scale, // raan_rate (swept angle)
1026        0.0,                    // h
1027        0.0,                    // k
1028        seed_c[4],              // L0 = arg_lat0
1029        seed_c[5] * rate_scale, // n (swept angle)
1030    ];
1031
1032    let m = 3 * gcrs.len();
1033    let residual = {
1034        let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
1035        move |x: &DVector<f64>| -> DVector<f64> {
1036            let xs = x.as_slice();
1037            let p = unscale_params_ecc(xs, a_scale, rate_scale);
1038            let mut r = Vec::with_capacity(m);
1039            for (dt, obs) in &gcrs_ref {
1040                let model = eval_gcrs_km_ecc(&p, *dt);
1041                r.push(model[0] - obs[0]);
1042                r.push(model[1] - obs[1]);
1043                r.push(model[2] - obs[2]);
1044            }
1045            DVector::from_vec(r)
1046        }
1047    };
1048
1049    let x0 = DVector::from_row_slice(&seed_scaled);
1050    let problem = LeastSquaresProblem::new(residual, x0);
1051    let opts = SolveOptions {
1052        gtol: REDUCED_ORBIT_SOLVER_TOL,
1053        ftol: REDUCED_ORBIT_SOLVER_TOL,
1054        xtol: REDUCED_ORBIT_SOLVER_TOL,
1055        max_nfev: 400,
1056    };
1057    let report = solve_trf(&problem, &opts)?;
1058
1059    let converged = matches!(
1060        report.status,
1061        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
1062    );
1063    if !converged {
1064        return Err(ReducedOrbitError::FitDidNotConverge);
1065    }
1066
1067    let p = unscale_params_ecc(report.x.as_slice(), a_scale, rate_scale);
1068    let res = &report.residual;
1069    let n_samp = gcrs.len();
1070    let mut sumsq = 0.0;
1071    let mut maxsq = 0.0_f64;
1072    for k in 0..n_samp {
1073        let dx = res[3 * k] * M_PER_KM;
1074        let dy = res[3 * k + 1] * M_PER_KM;
1075        let dz = res[3 * k + 2] * M_PER_KM;
1076        let e2 = dx * dx + dy * dy + dz * dz;
1077        sumsq += e2;
1078        if e2 > maxsq {
1079            maxsq = e2;
1080        }
1081    }
1082    let rms_m = (sumsq / n_samp as f64).sqrt();
1083    let max_m = maxsq.sqrt();
1084
1085    let h = p[4];
1086    let k = p[5];
1087    let e = (h * h + k * k).sqrt();
1088    let arg_perigee_rad = if e < ECCENTRICITY_ZERO_EPS {
1089        0.0
1090    } else {
1091        h.atan2(k)
1092    };
1093
1094    let elements = Elements {
1095        model: Model::EccentricSecular,
1096        epoch: t0_cal,
1097        a_m: p[0] * M_PER_KM,
1098        e,
1099        i_rad: p[1],
1100        raan_rad: p[2],
1101        raan_rate_rad_s: p[3],
1102        raan_rate_j2_rad_s: raan_rate_j2(p[7], p[1], p[0]),
1103        arg_lat_rad: p[6],
1104        mean_motion_rad_s: p[7],
1105        h,
1106        k,
1107        arg_perigee_rad,
1108    };
1109
1110    Ok(ReducedOrbit {
1111        elements,
1112        stats: FitStats {
1113            rms_m,
1114            max_m,
1115            n_samples: n_samp,
1116        },
1117    })
1118}
1119
1120// ---------------------------------------------------------------------------
1121// Evaluation.
1122// ---------------------------------------------------------------------------
1123
1124fn params_from_elements(e: &Elements) -> [f64; N_PARAMS] {
1125    [
1126        e.a_m / M_PER_KM,
1127        e.i_rad,
1128        e.raan_rad,
1129        e.raan_rate_rad_s,
1130        e.arg_lat_rad,
1131        e.mean_motion_rad_s,
1132    ]
1133}
1134
1135fn params_from_elements_ecc(e: &Elements) -> [f64; N_PARAMS_ECC] {
1136    [
1137        e.a_m / M_PER_KM,
1138        e.i_rad,
1139        e.raan_rad,
1140        e.raan_rate_rad_s,
1141        e.h,
1142        e.k,
1143        e.arg_lat_rad,
1144        e.mean_motion_rad_s,
1145    ]
1146}
1147
1148/// GCRS position (km) of `elements` at offset `dt`, dispatching on the model.
1149fn eval_position_km(elements: &Elements, dt: f64) -> [f64; 3] {
1150    match elements.model {
1151        Model::CircularSecular => eval_gcrs_km(&params_from_elements(elements), dt),
1152        Model::EccentricSecular => eval_gcrs_km_ecc(&params_from_elements_ecc(elements), dt),
1153    }
1154}
1155
1156/// GCRS velocity (km/s) of `elements` at offset `dt`, dispatching on the model.
1157fn eval_velocity_km_s(elements: &Elements, dt: f64) -> [f64; 3] {
1158    match elements.model {
1159        Model::CircularSecular => eval_gcrs_velocity_km_s(&params_from_elements(elements), dt),
1160        Model::EccentricSecular => {
1161            eval_gcrs_velocity_km_s_ecc(&params_from_elements_ecc(elements), dt)
1162        }
1163    }
1164}
1165
1166/// Evaluate the model position at `epoch` (interpreted in `scale`) in the
1167/// requested frame, meters.
1168pub fn position(
1169    elements: &Elements,
1170    epoch: CalendarEpoch,
1171    scale: TimeScale,
1172    frame: Frame,
1173) -> Result<[f64; 3], ReducedOrbitError> {
1174    validate_elements_for_evaluation(elements, scale)?;
1175    validate_calendar_epoch(epoch, scale, "epoch")?;
1176    let t0_ts = elements.epoch.time_scales(scale);
1177    let ts = epoch.time_scales(scale);
1178    let dt = dt_seconds(&t0_ts, &ts);
1179    validate_finite(dt, "dt_s")?;
1180    let r_gcrs_km = eval_position_km(elements, dt);
1181    let r = match frame {
1182        Frame::Gcrs => [
1183            r_gcrs_km[0] * M_PER_KM,
1184            r_gcrs_km[1] * M_PER_KM,
1185            r_gcrs_km[2] * M_PER_KM,
1186        ],
1187        Frame::Ecef => {
1188            let mat = gcrs_to_itrs_matrix(&ts)
1189                .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1190            let r = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1191            [r[0] * M_PER_KM, r[1] * M_PER_KM, r[2] * M_PER_KM]
1192        }
1193    };
1194    validate_vec3(r, "position_m")?;
1195    Ok(r)
1196}
1197
1198/// Evaluate the model position and velocity at `epoch` in the requested frame.
1199/// Returns `(position_m, velocity_m_s)`. ECEF velocity includes the
1200/// Earth-rotation transport term.
1201pub fn position_velocity(
1202    elements: &Elements,
1203    epoch: CalendarEpoch,
1204    scale: TimeScale,
1205    frame: Frame,
1206) -> Result<([f64; 3], [f64; 3]), ReducedOrbitError> {
1207    validate_elements_for_evaluation(elements, scale)?;
1208    validate_calendar_epoch(epoch, scale, "epoch")?;
1209    let t0_ts = elements.epoch.time_scales(scale);
1210    let ts = epoch.time_scales(scale);
1211    let dt = dt_seconds(&t0_ts, &ts);
1212    validate_finite(dt, "dt_s")?;
1213    let r_gcrs_km = eval_position_km(elements, dt);
1214    let v_gcrs_km_s = eval_velocity_km_s(elements, dt);
1215
1216    let (r, v) = match frame {
1217        Frame::Gcrs => {
1218            let r = [
1219                r_gcrs_km[0] * M_PER_KM,
1220                r_gcrs_km[1] * M_PER_KM,
1221                r_gcrs_km[2] * M_PER_KM,
1222            ];
1223            let v = [
1224                v_gcrs_km_s[0] * M_PER_KM,
1225                v_gcrs_km_s[1] * M_PER_KM,
1226                v_gcrs_km_s[2] * M_PER_KM,
1227            ];
1228            (r, v)
1229        }
1230        Frame::Ecef => {
1231            let mat = gcrs_to_itrs_matrix(&ts)
1232                .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1233            let r_itrs_km = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1234            let v_rot_km_s = mat3_vec3_mul_unchecked(&mat, &v_gcrs_km_s);
1235            // Transport term: v_itrs = R v_gcrs - omega x r_itrs.
1236            let vx = v_rot_km_s[0] + OMEGA_E_DOT_RAD_S * r_itrs_km[1];
1237            let vy = v_rot_km_s[1] - OMEGA_E_DOT_RAD_S * r_itrs_km[0];
1238            let vz = v_rot_km_s[2];
1239            let r = [
1240                r_itrs_km[0] * M_PER_KM,
1241                r_itrs_km[1] * M_PER_KM,
1242                r_itrs_km[2] * M_PER_KM,
1243            ];
1244            let v = [vx * M_PER_KM, vy * M_PER_KM, vz * M_PER_KM];
1245            (r, v)
1246        }
1247    };
1248    validate_vec3(r, "position_m")?;
1249    validate_vec3(v, "velocity_m_s")?;
1250    Ok((r, v))
1251}
1252
1253fn validate_elements_for_evaluation(
1254    elements: &Elements,
1255    scale: TimeScale,
1256) -> Result<(), ReducedOrbitError> {
1257    validate_calendar_epoch(elements.epoch, scale, "elements.epoch")?;
1258    validate_positive(elements.a_m, "elements.a_m")?;
1259    validate_finite(elements.e, "elements.e")?;
1260    if !(0.0..1.0).contains(&elements.e) {
1261        return Err(invalid_input("elements.e", "must be in [0, 1)"));
1262    }
1263    validate_finite(elements.i_rad, "elements.i_rad")?;
1264    if !(0.0..=std::f64::consts::PI).contains(&elements.i_rad) {
1265        return Err(invalid_input("elements.i_rad", "must be in [0, pi]"));
1266    }
1267    validate_finite(elements.raan_rad, "elements.raan_rad")?;
1268    validate_finite(elements.raan_rate_rad_s, "elements.raan_rate_rad_s")?;
1269    validate_finite(elements.raan_rate_j2_rad_s, "elements.raan_rate_j2_rad_s")?;
1270    validate_finite(elements.arg_lat_rad, "elements.arg_lat_rad")?;
1271    validate_positive(elements.mean_motion_rad_s, "elements.mean_motion_rad_s")?;
1272    validate_finite(elements.h, "elements.h")?;
1273    validate_finite(elements.k, "elements.k")?;
1274    validate_finite(elements.arg_perigee_rad, "elements.arg_perigee_rad")?;
1275    if elements.model == Model::EccentricSecular {
1276        let derived_e = (elements.h * elements.h + elements.k * elements.k).sqrt();
1277        validate_finite(derived_e, "elements.h_k")?;
1278        if derived_e >= 1.0 {
1279            return Err(invalid_input("elements.h_k", "eccentricity must be < 1"));
1280        }
1281    }
1282    Ok(())
1283}
1284
1285fn validate_calendar_epoch(
1286    epoch: CalendarEpoch,
1287    scale: TimeScale,
1288    field: &'static str,
1289) -> Result<(), ReducedOrbitError> {
1290    let second_policy = match scale {
1291        TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
1292        // GLONASST is UTC(SU)-based, but a civil GLONASST leap-second (:60) label
1293        // is not a supported civil input here: no time-system label parses to
1294        // GLONASST (RINEX/SP3 "GLO" is UTC), and GLONASST is reached numerically
1295        // via `timescale_offset_at_s`. Treat it as Continuous so a stray :60
1296        // GLONASST label is rejected rather than silently shifted by the -3h
1297        // conversion into the wrong second; ordinary GLONASST civil times still
1298        // convert via `scale_calendar_to_utc`.
1299        TimeScale::Glonasst
1300        | TimeScale::Tai
1301        | TimeScale::Tt
1302        | TimeScale::Tcg
1303        | TimeScale::Tdb
1304        | TimeScale::Tcb
1305        | TimeScale::Gpst
1306        | TimeScale::Gst
1307        | TimeScale::Bdt
1308        | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
1309    };
1310    validate::civil_datetime_with_second_policy(
1311        i64::from(epoch.year),
1312        i64::from(epoch.month),
1313        i64::from(epoch.day),
1314        i64::from(epoch.hour),
1315        i64::from(epoch.minute),
1316        epoch.second,
1317        second_policy,
1318    )
1319    .map(|_| ())
1320    .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1321}
1322
1323fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1324    for sample in samples {
1325        validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1326        validate_finite(sample.x_m, "sample.x_m")?;
1327        validate_finite(sample.y_m, "sample.y_m")?;
1328        validate_finite(sample.z_m, "sample.z_m")?;
1329    }
1330    Ok(())
1331}
1332
1333fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1334    validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1335    validate_finite(sample.x_m, "truth.x_m")?;
1336    validate_finite(sample.y_m, "truth.y_m")?;
1337    validate_finite(sample.z_m, "truth.z_m")
1338}
1339
1340fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1341    for component in value {
1342        validate_finite(component, field)?;
1343    }
1344    Ok(())
1345}
1346
1347fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1348    if value.is_finite() {
1349        Ok(())
1350    } else {
1351        Err(invalid_input(field, "must be finite"))
1352    }
1353}
1354
1355fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1356    validate_finite(value, field)?;
1357    if value > 0.0 {
1358        Ok(())
1359    } else {
1360        Err(invalid_input(field, "must be positive"))
1361    }
1362}
1363
1364fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1365    ReducedOrbitError::InvalidInput { field, reason }
1366}
1367
1368// ---------------------------------------------------------------------------
1369// Drift.
1370// ---------------------------------------------------------------------------
1371
1372/// Evaluate the model against truth ECEF samples and report the per-epoch error,
1373/// its statistics, and the first epoch the error crosses `threshold_m`.
1374///
1375/// The error is computed in ECEF meters (model ECEF minus truth ECEF). This is
1376/// source-backed: the caller supplies the truth `(epoch, ECEF)` samples; the
1377/// function does not compare the model against itself.
1378pub fn drift(
1379    elements: &Elements,
1380    truth: &[EcefSample],
1381    scale: TimeScale,
1382    threshold_m: f64,
1383) -> Result<DriftReport, ReducedOrbitError> {
1384    validate_elements_for_evaluation(elements, scale)?;
1385    validate_finite(threshold_m, "threshold_m")?;
1386    let mut per_epoch = Vec::with_capacity(truth.len());
1387    let mut sumsq = 0.0;
1388    let mut max_m = 0.0_f64;
1389    let mut threshold_horizon = None;
1390    let mut threshold_index = None;
1391
1392    for s in truth {
1393        validate_truth_sample(s, scale)?;
1394        let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1395        let dx = model[0] - s.x_m;
1396        let dy = model[1] - s.y_m;
1397        let dz = model[2] - s.z_m;
1398        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1399        validate_finite(err, "drift.error_m")?;
1400        sumsq += err * err;
1401        validate_finite(sumsq, "drift.sumsq")?;
1402        if err > max_m {
1403            max_m = err;
1404        }
1405        if threshold_horizon.is_none() && err > threshold_m {
1406            threshold_horizon = Some(s.epoch);
1407            threshold_index = Some(per_epoch.len());
1408        }
1409        per_epoch.push(DriftEntry {
1410            epoch: s.epoch,
1411            error_m: err,
1412        });
1413    }
1414
1415    let rms_m = if per_epoch.is_empty() {
1416        0.0
1417    } else {
1418        (sumsq / per_epoch.len() as f64).sqrt()
1419    };
1420    validate_finite(max_m, "drift.max_m")?;
1421    validate_finite(rms_m, "drift.rms_m")?;
1422
1423    Ok(DriftReport {
1424        per_epoch,
1425        max_m,
1426        rms_m,
1427        threshold_horizon,
1428        threshold_index,
1429    })
1430}
1431
1432/// Sample an SP3 or SGP4 source and fit a reduced orbit.
1433pub fn fit_reduced_orbit_source(
1434    source: ReducedOrbitSource<'_>,
1435    options: ReducedOrbitSourceFitOptions,
1436) -> Result<ReducedOrbitSourceFit, ReducedOrbitSourceError> {
1437    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1438    let orbit = fit_with_model(&sampled.samples, sampled.scale, options.model)
1439        .map_err(ReducedOrbitSourceError::Reduced)?;
1440    Ok(ReducedOrbitSourceFit {
1441        orbit,
1442        requested_samples: sampled.requested,
1443    })
1444}
1445
1446/// Sample an SP3 or SGP4 source and evaluate drift against those truth samples.
1447pub fn drift_reduced_orbit_source(
1448    elements: &Elements,
1449    source: ReducedOrbitSource<'_>,
1450    options: ReducedOrbitSourceDriftOptions,
1451) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1452    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1453    if sampled.samples.is_empty() {
1454        return Err(ReducedOrbitSourceError::TooFewSamples {
1455            got: 0,
1456            required: 1,
1457        });
1458    }
1459    let report = drift(
1460        elements,
1461        &sampled.samples,
1462        sampled.scale,
1463        options.threshold_m,
1464    )
1465    .map_err(ReducedOrbitSourceError::Reduced)?;
1466    Ok(ReducedOrbitSourceDrift {
1467        report,
1468        requested_samples: sampled.requested,
1469    })
1470}
1471
1472/// Sample an SP3 or SGP4 source and fit a piecewise reduced orbit.
1473pub fn fit_piecewise_reduced_orbit_source(
1474    source: ReducedOrbitSource<'_>,
1475    options: PiecewiseOrbitSourceFitOptions,
1476) -> Result<PiecewiseOrbitSourceFit, ReducedOrbitSourceError> {
1477    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1478    let segment_s = rounded_segment_s(options.segment_s)?;
1479    let orbit = fit_piecewise(
1480        &sampled.samples,
1481        sampled.scale,
1482        options.model,
1483        options.sampling.t0,
1484        options.sampling.t1,
1485        segment_s,
1486    )
1487    .map_err(ReducedOrbitSourceError::Piecewise)?;
1488    Ok(PiecewiseOrbitSourceFit {
1489        orbit,
1490        requested_samples: sampled.requested,
1491    })
1492}
1493
1494/// Sample an SP3 or SGP4 source and evaluate a piecewise model's drift.
1495pub fn drift_piecewise_reduced_orbit_source(
1496    piecewise: &PiecewiseOrbit,
1497    source: ReducedOrbitSource<'_>,
1498    options: ReducedOrbitSourceDriftOptions,
1499) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1500    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1501    if sampled.samples.is_empty() {
1502        return Err(ReducedOrbitSourceError::TooFewSamples {
1503            got: 0,
1504            required: 1,
1505        });
1506    }
1507    let report = piecewise_drift(
1508        piecewise,
1509        &sampled.samples,
1510        sampled.scale,
1511        options.threshold_m,
1512    )
1513    .map_err(ReducedOrbitSourceError::Piecewise)?;
1514    Ok(ReducedOrbitSourceDrift {
1515        report,
1516        requested_samples: sampled.requested,
1517    })
1518}
1519
1520#[derive(Debug)]
1521struct SourceSamples {
1522    samples: Vec<EcefSample>,
1523    requested: usize,
1524    scale: TimeScale,
1525}
1526
1527fn sample_reduced_orbit_source(
1528    source: ReducedOrbitSource<'_>,
1529    sampling: ReducedOrbitSourceSampling,
1530) -> Result<SourceSamples, ReducedOrbitSourceError> {
1531    let scale = match source {
1532        ReducedOrbitSource::Sp3 { product, .. } => product.header.time_scale,
1533        ReducedOrbitSource::Sgp4 { .. } => TimeScale::Utc,
1534    };
1535    let steps = source_steps(sampling, scale)?;
1536    let samples = match source {
1537        ReducedOrbitSource::Sp3 { product, satellite } => steps
1538            .iter()
1539            .filter_map(|epoch| sample_sp3_epoch(product, satellite, *epoch))
1540            .collect(),
1541        ReducedOrbitSource::Sgp4 { satellite } => steps
1542            .iter()
1543            .filter_map(|epoch| sample_sgp4_epoch(satellite, *epoch))
1544            .collect(),
1545    };
1546    Ok(SourceSamples {
1547        samples,
1548        requested: steps.len(),
1549        scale,
1550    })
1551}
1552
1553fn source_steps(
1554    sampling: ReducedOrbitSourceSampling,
1555    scale: TimeScale,
1556) -> Result<Vec<CalendarEpoch>, ReducedOrbitSourceError> {
1557    validate_calendar_epoch(sampling.t0, scale, "window.start")
1558        .map_err(ReducedOrbitSourceError::Reduced)?;
1559    validate_calendar_epoch(sampling.t1, scale, "window.end")
1560        .map_err(ReducedOrbitSourceError::Reduced)?;
1561    if !sampling.cadence_s.is_finite() || sampling.cadence_s <= 0.0 {
1562        return Err(ReducedOrbitSourceError::InvalidCadence);
1563    }
1564    let span_s = calendar_seconds(sampling.t1) - calendar_seconds(sampling.t0);
1565    if !span_s.is_finite() || span_s <= 0.0 {
1566        return Err(ReducedOrbitSourceError::InvalidWindow);
1567    }
1568    let count = (span_s / sampling.cadence_s).trunc();
1569    if !count.is_finite() || count < 0.0 {
1570        return Err(ReducedOrbitSourceError::InvalidWindow);
1571    }
1572    let start_s = calendar_seconds(sampling.t0);
1573    Ok((0..=count as usize)
1574        .map(|k| calendar_from_seconds(start_s + (k as f64 * sampling.cadence_s).round()))
1575        .collect())
1576}
1577
1578fn sample_sp3_epoch(
1579    product: &Sp3,
1580    satellite: GnssSatelliteId,
1581    epoch: CalendarEpoch,
1582) -> Option<EcefSample> {
1583    let instant = instant_from_calendar(epoch, product.header.time_scale).ok()?;
1584    let state = product.position(satellite, instant).ok()?;
1585    Some(EcefSample::new(
1586        epoch,
1587        state.position.x_m,
1588        state.position.y_m,
1589        state.position.z_m,
1590    ))
1591}
1592
1593fn sample_sgp4_epoch(satellite: &Satellite, epoch: CalendarEpoch) -> Option<EcefSample> {
1594    let prediction = satellite
1595        .propagate_jd(julian_date_from_calendar(epoch))
1596        .ok()?;
1597    let ts = epoch.time_scales(TimeScale::Utc);
1598    let (gcrs, _) = teme_to_gcrs_compute(
1599        &TemeStateKm {
1600            position_km: prediction.position,
1601            velocity_km_s: prediction.velocity,
1602        },
1603        &ts,
1604        false,
1605    )
1606    .ok()?;
1607    let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).ok()?;
1608    Some(EcefSample::new(
1609        epoch,
1610        x_km * M_PER_KM,
1611        y_km * M_PER_KM,
1612        z_km * M_PER_KM,
1613    ))
1614}
1615
1616fn instant_from_calendar(
1617    epoch: CalendarEpoch,
1618    scale: TimeScale,
1619) -> Result<Instant, ReducedOrbitSourceError> {
1620    let (jd_whole, fraction) = split_julian_date(
1621        epoch.year,
1622        epoch.month,
1623        epoch.day,
1624        epoch.hour,
1625        epoch.minute,
1626        epoch.second,
1627    );
1628    let split = JulianDateSplit::new(jd_whole, fraction)
1629        .map_err(|_| ReducedOrbitSourceError::InvalidWindow)?;
1630    Ok(Instant::from_julian_date(scale, split))
1631}
1632
1633fn julian_date_from_calendar(epoch: CalendarEpoch) -> JulianDate {
1634    let (jd_whole, fraction) = split_julian_date(
1635        epoch.year,
1636        epoch.month,
1637        epoch.day,
1638        epoch.hour,
1639        epoch.minute,
1640        epoch.second,
1641    );
1642    JulianDate(jd_whole, fraction)
1643}
1644
1645fn rounded_segment_s(segment_s: f64) -> Result<i64, ReducedOrbitSourceError> {
1646    if !segment_s.is_finite() || segment_s <= 0.0 {
1647        return Err(ReducedOrbitSourceError::InvalidSegment);
1648    }
1649    let rounded = segment_s.round();
1650    if rounded < 1.0 || rounded > i64::MAX as f64 {
1651        return Err(ReducedOrbitSourceError::InvalidSegment);
1652    }
1653    Ok(rounded as i64)
1654}
1655
1656// ---------------------------------------------------------------------------
1657// Piecewise fit/evaluation.
1658// ---------------------------------------------------------------------------
1659
1660// The fit/segmentation time axis is a monotonic continuous-seconds mapping on a
1661// JD-origin (`jdn * 86400 + sod`) axis. This must stay bit-identical: the large
1662// absolute magnitude sets the ULP at which sub-second epochs round near segment
1663// boundaries, so a different origin (e.g. J2000 seconds) would shift sample
1664// inclusion. The forward/inverse pair below is that exact legacy arithmetic.
1665fn calendar_seconds(t: CalendarEpoch) -> f64 {
1666    julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1667        + (t.hour as f64) * SECONDS_PER_HOUR
1668        + (t.minute as f64) * SECONDS_PER_MINUTE
1669        + t.second
1670}
1671
1672fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1673    let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1674    let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1675    if sod < 0.0 {
1676        jdn -= 1;
1677        sod += SECONDS_PER_DAY;
1678    }
1679    if sod >= SECONDS_PER_DAY {
1680        jdn += 1;
1681        sod -= SECONDS_PER_DAY;
1682    }
1683
1684    let (year, month, day) = civil_from_julian_day_number(jdn);
1685    let hour = (sod / SECONDS_PER_HOUR).floor() as i32;
1686    let rem = sod - hour as f64 * SECONDS_PER_HOUR;
1687    let minute = (rem / SECONDS_PER_MINUTE).floor() as i32;
1688    let second = rem - minute as f64 * SECONDS_PER_MINUTE;
1689
1690    CalendarEpoch::new(year as i32, month as i32, day as i32, hour, minute, second)
1691}
1692
1693fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1694    calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1695}
1696
1697fn segment_bounds(
1698    t0: CalendarEpoch,
1699    t1: CalendarEpoch,
1700    segment_s: i64,
1701) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1702    if segment_s < 1 {
1703        return Err(PiecewiseOrbitError::InvalidSegment);
1704    }
1705    if calendar_seconds(t1) <= calendar_seconds(t0) {
1706        return Err(PiecewiseOrbitError::Reduced(
1707            ReducedOrbitError::InvalidWindow,
1708        ));
1709    }
1710
1711    let mut bounds = Vec::new();
1712    let mut seg_t0 = t0;
1713    let end_s = calendar_seconds(t1);
1714    while calendar_seconds(seg_t0) < end_s {
1715        let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1716        if calendar_seconds(seg_t1) > end_s {
1717            seg_t1 = t1;
1718        }
1719        bounds.push((seg_t0, seg_t1));
1720        seg_t0 = seg_t1;
1721    }
1722    Ok(bounds)
1723}
1724
1725fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1726    matches!(
1727        e,
1728        ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1729    )
1730}
1731
1732fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1733    let s = calendar_seconds(sample);
1734    s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1735}
1736
1737/// Fit contiguous reduced-orbit segments over `[t0, t1]`.
1738///
1739/// `segment_s` is the positive, already-rounded segment length in whole
1740/// seconds. The final short segment may be dropped if it has too few samples;
1741/// interior under-covered segments surface an error because they would create a
1742/// coverage gap. Sample epochs are partitioned by civil calendar seconds, while
1743/// each segment fit still interprets those epochs in `scale` for frame
1744/// transforms.
1745pub fn fit_piecewise(
1746    samples: &[EcefSample],
1747    scale: TimeScale,
1748    model: Model,
1749    t0: CalendarEpoch,
1750    t1: CalendarEpoch,
1751    segment_s: i64,
1752) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1753    let bounds = segment_bounds(t0, t1, segment_s)?;
1754    let last_index = bounds.len().saturating_sub(1);
1755    let mut segments = Vec::new();
1756
1757    for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1758        let subset: Vec<EcefSample> = samples
1759            .iter()
1760            .copied()
1761            .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1762            .collect();
1763
1764        match fit_with_model(&subset, scale, model) {
1765            Ok(orbit) => segments.push(PiecewiseSegment {
1766                t0: seg_t0,
1767                t1: seg_t1,
1768                orbit,
1769            }),
1770            Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1771            Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1772        }
1773    }
1774
1775    let Some(last) = segments.last() else {
1776        return Err(PiecewiseOrbitError::TooFewSamples {
1777            got: 0,
1778            required: MIN_SAMPLES,
1779        });
1780    };
1781
1782    Ok(PiecewiseOrbit {
1783        model,
1784        t0,
1785        t1: last.t1,
1786        segment_s,
1787        segments,
1788    })
1789}
1790
1791/// Return the segment covering `epoch`.
1792///
1793/// Interior boundaries resolve to the later segment; the exact end of the final
1794/// segment resolves to that final segment.
1795pub fn select_piecewise_segment(
1796    piecewise: &PiecewiseOrbit,
1797    epoch: CalendarEpoch,
1798) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1799    validate_piecewise_segments(piecewise)?;
1800    let e = calendar_seconds(epoch);
1801    if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1802        return Err(PiecewiseOrbitError::OutOfRange);
1803    }
1804
1805    let Some((last, rest)) = piecewise.segments.split_last() else {
1806        return Err(PiecewiseOrbitError::OutOfRange);
1807    };
1808
1809    for seg in rest {
1810        if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1811            return Ok(seg);
1812        }
1813    }
1814
1815    if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1816        Ok(last)
1817    } else {
1818        Err(PiecewiseOrbitError::OutOfRange)
1819    }
1820}
1821
1822fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1823    if piecewise.segment_s < 1 {
1824        return Err(PiecewiseOrbitError::InvalidSegment);
1825    }
1826    validate::require_strictly_increasing(
1827        piecewise
1828            .segments
1829            .iter()
1830            .map(|segment| calendar_seconds(segment.t0)),
1831        "piecewise.segments.t0",
1832    )
1833    .map_err(map_piecewise_order_error)?;
1834
1835    for segment in &piecewise.segments {
1836        validate::require_strictly_increasing(
1837            [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1838            "piecewise.segment bounds",
1839        )
1840        .map_err(map_piecewise_order_error)?;
1841    }
1842    Ok(())
1843}
1844
1845fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1846    let reason = match error {
1847        validate::FieldError::NonFinite { .. } => "must be finite",
1848        validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1849        _ => error.reason(),
1850    };
1851    PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1852}
1853
1854/// Evaluate a piecewise reduced orbit at `epoch`.
1855pub fn piecewise_position(
1856    piecewise: &PiecewiseOrbit,
1857    epoch: CalendarEpoch,
1858    scale: TimeScale,
1859    frame: Frame,
1860) -> Result<[f64; 3], PiecewiseOrbitError> {
1861    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1862    let seg = select_piecewise_segment(piecewise, epoch)?;
1863    position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1864}
1865
1866/// Evaluate piecewise reduced-orbit position and velocity at `epoch`.
1867pub fn piecewise_position_velocity(
1868    piecewise: &PiecewiseOrbit,
1869    epoch: CalendarEpoch,
1870    scale: TimeScale,
1871    frame: Frame,
1872) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1873    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1874    let seg = select_piecewise_segment(piecewise, epoch)?;
1875    position_velocity(&seg.orbit.elements, epoch, scale, frame)
1876        .map_err(PiecewiseOrbitError::Reduced)
1877}
1878
1879/// Evaluate a piecewise model against truth ECEF samples.
1880///
1881/// Truth samples outside the model span are skipped, matching the source-backed
1882/// single-model drift behavior when product coverage clips a requested horizon.
1883pub fn piecewise_drift(
1884    piecewise: &PiecewiseOrbit,
1885    truth: &[EcefSample],
1886    scale: TimeScale,
1887    threshold_m: f64,
1888) -> Result<DriftReport, PiecewiseOrbitError> {
1889    validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1890    if truth.is_empty() {
1891        return Ok(DriftReport {
1892            per_epoch: Vec::new(),
1893            max_m: 0.0,
1894            rms_m: 0.0,
1895            threshold_horizon: None,
1896            threshold_index: None,
1897        });
1898    }
1899
1900    let mut per_epoch = Vec::with_capacity(truth.len());
1901    let mut sumsq = 0.0;
1902    let mut max_m = 0.0_f64;
1903    let mut threshold_horizon = None;
1904    let mut threshold_index = None;
1905
1906    for s in truth {
1907        validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1908        let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1909            continue;
1910        };
1911        let dx = model[0] - s.x_m;
1912        let dy = model[1] - s.y_m;
1913        let dz = model[2] - s.z_m;
1914        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1915        sumsq += err * err;
1916        if err > max_m {
1917            max_m = err;
1918        }
1919        if threshold_horizon.is_none() && err > threshold_m {
1920            threshold_horizon = Some(s.epoch);
1921            threshold_index = Some(per_epoch.len());
1922        }
1923        per_epoch.push(DriftEntry {
1924            epoch: s.epoch,
1925            error_m: err,
1926        });
1927    }
1928
1929    if per_epoch.is_empty() {
1930        return Err(PiecewiseOrbitError::TooFewSamples {
1931            got: 0,
1932            required: 1,
1933        });
1934    }
1935
1936    let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1937    Ok(DriftReport {
1938        per_epoch,
1939        max_m,
1940        rms_m,
1941        threshold_horizon,
1942        threshold_index,
1943    })
1944}
1945
1946#[cfg(all(test, sidereon_repo_tests))]
1947mod tests;