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::Tdb
1303        | TimeScale::Gpst
1304        | TimeScale::Gst
1305        | TimeScale::Bdt
1306        | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
1307    };
1308    validate::civil_datetime_with_second_policy(
1309        i64::from(epoch.year),
1310        i64::from(epoch.month),
1311        i64::from(epoch.day),
1312        i64::from(epoch.hour),
1313        i64::from(epoch.minute),
1314        epoch.second,
1315        second_policy,
1316    )
1317    .map(|_| ())
1318    .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1319}
1320
1321fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1322    for sample in samples {
1323        validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1324        validate_finite(sample.x_m, "sample.x_m")?;
1325        validate_finite(sample.y_m, "sample.y_m")?;
1326        validate_finite(sample.z_m, "sample.z_m")?;
1327    }
1328    Ok(())
1329}
1330
1331fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1332    validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1333    validate_finite(sample.x_m, "truth.x_m")?;
1334    validate_finite(sample.y_m, "truth.y_m")?;
1335    validate_finite(sample.z_m, "truth.z_m")
1336}
1337
1338fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1339    for component in value {
1340        validate_finite(component, field)?;
1341    }
1342    Ok(())
1343}
1344
1345fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1346    if value.is_finite() {
1347        Ok(())
1348    } else {
1349        Err(invalid_input(field, "must be finite"))
1350    }
1351}
1352
1353fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1354    validate_finite(value, field)?;
1355    if value > 0.0 {
1356        Ok(())
1357    } else {
1358        Err(invalid_input(field, "must be positive"))
1359    }
1360}
1361
1362fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1363    ReducedOrbitError::InvalidInput { field, reason }
1364}
1365
1366// ---------------------------------------------------------------------------
1367// Drift.
1368// ---------------------------------------------------------------------------
1369
1370/// Evaluate the model against truth ECEF samples and report the per-epoch error,
1371/// its statistics, and the first epoch the error crosses `threshold_m`.
1372///
1373/// The error is computed in ECEF meters (model ECEF minus truth ECEF). This is
1374/// source-backed: the caller supplies the truth `(epoch, ECEF)` samples; the
1375/// function does not compare the model against itself.
1376pub fn drift(
1377    elements: &Elements,
1378    truth: &[EcefSample],
1379    scale: TimeScale,
1380    threshold_m: f64,
1381) -> Result<DriftReport, ReducedOrbitError> {
1382    validate_elements_for_evaluation(elements, scale)?;
1383    validate_finite(threshold_m, "threshold_m")?;
1384    let mut per_epoch = Vec::with_capacity(truth.len());
1385    let mut sumsq = 0.0;
1386    let mut max_m = 0.0_f64;
1387    let mut threshold_horizon = None;
1388    let mut threshold_index = None;
1389
1390    for s in truth {
1391        validate_truth_sample(s, scale)?;
1392        let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1393        let dx = model[0] - s.x_m;
1394        let dy = model[1] - s.y_m;
1395        let dz = model[2] - s.z_m;
1396        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1397        validate_finite(err, "drift.error_m")?;
1398        sumsq += err * err;
1399        validate_finite(sumsq, "drift.sumsq")?;
1400        if err > max_m {
1401            max_m = err;
1402        }
1403        if threshold_horizon.is_none() && err > threshold_m {
1404            threshold_horizon = Some(s.epoch);
1405            threshold_index = Some(per_epoch.len());
1406        }
1407        per_epoch.push(DriftEntry {
1408            epoch: s.epoch,
1409            error_m: err,
1410        });
1411    }
1412
1413    let rms_m = if per_epoch.is_empty() {
1414        0.0
1415    } else {
1416        (sumsq / per_epoch.len() as f64).sqrt()
1417    };
1418    validate_finite(max_m, "drift.max_m")?;
1419    validate_finite(rms_m, "drift.rms_m")?;
1420
1421    Ok(DriftReport {
1422        per_epoch,
1423        max_m,
1424        rms_m,
1425        threshold_horizon,
1426        threshold_index,
1427    })
1428}
1429
1430/// Sample an SP3 or SGP4 source and fit a reduced orbit.
1431pub fn fit_reduced_orbit_source(
1432    source: ReducedOrbitSource<'_>,
1433    options: ReducedOrbitSourceFitOptions,
1434) -> Result<ReducedOrbitSourceFit, ReducedOrbitSourceError> {
1435    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1436    let orbit = fit_with_model(&sampled.samples, sampled.scale, options.model)
1437        .map_err(ReducedOrbitSourceError::Reduced)?;
1438    Ok(ReducedOrbitSourceFit {
1439        orbit,
1440        requested_samples: sampled.requested,
1441    })
1442}
1443
1444/// Sample an SP3 or SGP4 source and evaluate drift against those truth samples.
1445pub fn drift_reduced_orbit_source(
1446    elements: &Elements,
1447    source: ReducedOrbitSource<'_>,
1448    options: ReducedOrbitSourceDriftOptions,
1449) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1450    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1451    if sampled.samples.is_empty() {
1452        return Err(ReducedOrbitSourceError::TooFewSamples {
1453            got: 0,
1454            required: 1,
1455        });
1456    }
1457    let report = drift(
1458        elements,
1459        &sampled.samples,
1460        sampled.scale,
1461        options.threshold_m,
1462    )
1463    .map_err(ReducedOrbitSourceError::Reduced)?;
1464    Ok(ReducedOrbitSourceDrift {
1465        report,
1466        requested_samples: sampled.requested,
1467    })
1468}
1469
1470/// Sample an SP3 or SGP4 source and fit a piecewise reduced orbit.
1471pub fn fit_piecewise_reduced_orbit_source(
1472    source: ReducedOrbitSource<'_>,
1473    options: PiecewiseOrbitSourceFitOptions,
1474) -> Result<PiecewiseOrbitSourceFit, ReducedOrbitSourceError> {
1475    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1476    let segment_s = rounded_segment_s(options.segment_s)?;
1477    let orbit = fit_piecewise(
1478        &sampled.samples,
1479        sampled.scale,
1480        options.model,
1481        options.sampling.t0,
1482        options.sampling.t1,
1483        segment_s,
1484    )
1485    .map_err(ReducedOrbitSourceError::Piecewise)?;
1486    Ok(PiecewiseOrbitSourceFit {
1487        orbit,
1488        requested_samples: sampled.requested,
1489    })
1490}
1491
1492/// Sample an SP3 or SGP4 source and evaluate a piecewise model's drift.
1493pub fn drift_piecewise_reduced_orbit_source(
1494    piecewise: &PiecewiseOrbit,
1495    source: ReducedOrbitSource<'_>,
1496    options: ReducedOrbitSourceDriftOptions,
1497) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1498    let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1499    if sampled.samples.is_empty() {
1500        return Err(ReducedOrbitSourceError::TooFewSamples {
1501            got: 0,
1502            required: 1,
1503        });
1504    }
1505    let report = piecewise_drift(
1506        piecewise,
1507        &sampled.samples,
1508        sampled.scale,
1509        options.threshold_m,
1510    )
1511    .map_err(ReducedOrbitSourceError::Piecewise)?;
1512    Ok(ReducedOrbitSourceDrift {
1513        report,
1514        requested_samples: sampled.requested,
1515    })
1516}
1517
1518#[derive(Debug)]
1519struct SourceSamples {
1520    samples: Vec<EcefSample>,
1521    requested: usize,
1522    scale: TimeScale,
1523}
1524
1525fn sample_reduced_orbit_source(
1526    source: ReducedOrbitSource<'_>,
1527    sampling: ReducedOrbitSourceSampling,
1528) -> Result<SourceSamples, ReducedOrbitSourceError> {
1529    let scale = match source {
1530        ReducedOrbitSource::Sp3 { product, .. } => product.header.time_scale,
1531        ReducedOrbitSource::Sgp4 { .. } => TimeScale::Utc,
1532    };
1533    let steps = source_steps(sampling, scale)?;
1534    let samples = match source {
1535        ReducedOrbitSource::Sp3 { product, satellite } => steps
1536            .iter()
1537            .filter_map(|epoch| sample_sp3_epoch(product, satellite, *epoch))
1538            .collect(),
1539        ReducedOrbitSource::Sgp4 { satellite } => steps
1540            .iter()
1541            .filter_map(|epoch| sample_sgp4_epoch(satellite, *epoch))
1542            .collect(),
1543    };
1544    Ok(SourceSamples {
1545        samples,
1546        requested: steps.len(),
1547        scale,
1548    })
1549}
1550
1551fn source_steps(
1552    sampling: ReducedOrbitSourceSampling,
1553    scale: TimeScale,
1554) -> Result<Vec<CalendarEpoch>, ReducedOrbitSourceError> {
1555    validate_calendar_epoch(sampling.t0, scale, "window.start")
1556        .map_err(ReducedOrbitSourceError::Reduced)?;
1557    validate_calendar_epoch(sampling.t1, scale, "window.end")
1558        .map_err(ReducedOrbitSourceError::Reduced)?;
1559    if !sampling.cadence_s.is_finite() || sampling.cadence_s <= 0.0 {
1560        return Err(ReducedOrbitSourceError::InvalidCadence);
1561    }
1562    let span_s = calendar_seconds(sampling.t1) - calendar_seconds(sampling.t0);
1563    if !span_s.is_finite() || span_s <= 0.0 {
1564        return Err(ReducedOrbitSourceError::InvalidWindow);
1565    }
1566    let count = (span_s / sampling.cadence_s).trunc();
1567    if !count.is_finite() || count < 0.0 {
1568        return Err(ReducedOrbitSourceError::InvalidWindow);
1569    }
1570    let start_s = calendar_seconds(sampling.t0);
1571    Ok((0..=count as usize)
1572        .map(|k| calendar_from_seconds(start_s + (k as f64 * sampling.cadence_s).round()))
1573        .collect())
1574}
1575
1576fn sample_sp3_epoch(
1577    product: &Sp3,
1578    satellite: GnssSatelliteId,
1579    epoch: CalendarEpoch,
1580) -> Option<EcefSample> {
1581    let instant = instant_from_calendar(epoch, product.header.time_scale).ok()?;
1582    let state = product.position(satellite, instant).ok()?;
1583    Some(EcefSample::new(
1584        epoch,
1585        state.position.x_m,
1586        state.position.y_m,
1587        state.position.z_m,
1588    ))
1589}
1590
1591fn sample_sgp4_epoch(satellite: &Satellite, epoch: CalendarEpoch) -> Option<EcefSample> {
1592    let prediction = satellite
1593        .propagate_jd(julian_date_from_calendar(epoch))
1594        .ok()?;
1595    let ts = epoch.time_scales(TimeScale::Utc);
1596    let (gcrs, _) = teme_to_gcrs_compute(
1597        &TemeStateKm {
1598            position_km: prediction.position,
1599            velocity_km_s: prediction.velocity,
1600        },
1601        &ts,
1602        false,
1603    )
1604    .ok()?;
1605    let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).ok()?;
1606    Some(EcefSample::new(
1607        epoch,
1608        x_km * M_PER_KM,
1609        y_km * M_PER_KM,
1610        z_km * M_PER_KM,
1611    ))
1612}
1613
1614fn instant_from_calendar(
1615    epoch: CalendarEpoch,
1616    scale: TimeScale,
1617) -> Result<Instant, ReducedOrbitSourceError> {
1618    let (jd_whole, fraction) = split_julian_date(
1619        epoch.year,
1620        epoch.month,
1621        epoch.day,
1622        epoch.hour,
1623        epoch.minute,
1624        epoch.second,
1625    );
1626    let split = JulianDateSplit::new(jd_whole, fraction)
1627        .map_err(|_| ReducedOrbitSourceError::InvalidWindow)?;
1628    Ok(Instant::from_julian_date(scale, split))
1629}
1630
1631fn julian_date_from_calendar(epoch: CalendarEpoch) -> JulianDate {
1632    let (jd_whole, fraction) = split_julian_date(
1633        epoch.year,
1634        epoch.month,
1635        epoch.day,
1636        epoch.hour,
1637        epoch.minute,
1638        epoch.second,
1639    );
1640    JulianDate(jd_whole, fraction)
1641}
1642
1643fn rounded_segment_s(segment_s: f64) -> Result<i64, ReducedOrbitSourceError> {
1644    if !segment_s.is_finite() || segment_s <= 0.0 {
1645        return Err(ReducedOrbitSourceError::InvalidSegment);
1646    }
1647    let rounded = segment_s.round();
1648    if rounded < 1.0 || rounded > i64::MAX as f64 {
1649        return Err(ReducedOrbitSourceError::InvalidSegment);
1650    }
1651    Ok(rounded as i64)
1652}
1653
1654// ---------------------------------------------------------------------------
1655// Piecewise fit/evaluation.
1656// ---------------------------------------------------------------------------
1657
1658// The fit/segmentation time axis is a monotonic continuous-seconds mapping on a
1659// JD-origin (`jdn * 86400 + sod`) axis. This must stay bit-identical: the large
1660// absolute magnitude sets the ULP at which sub-second epochs round near segment
1661// boundaries, so a different origin (e.g. J2000 seconds) would shift sample
1662// inclusion. The forward/inverse pair below is that exact legacy arithmetic.
1663fn calendar_seconds(t: CalendarEpoch) -> f64 {
1664    julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1665        + (t.hour as f64) * SECONDS_PER_HOUR
1666        + (t.minute as f64) * SECONDS_PER_MINUTE
1667        + t.second
1668}
1669
1670fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1671    let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1672    let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1673    if sod < 0.0 {
1674        jdn -= 1;
1675        sod += SECONDS_PER_DAY;
1676    }
1677    if sod >= SECONDS_PER_DAY {
1678        jdn += 1;
1679        sod -= SECONDS_PER_DAY;
1680    }
1681
1682    let (year, month, day) = civil_from_julian_day_number(jdn);
1683    let hour = (sod / SECONDS_PER_HOUR).floor() as i32;
1684    let rem = sod - hour as f64 * SECONDS_PER_HOUR;
1685    let minute = (rem / SECONDS_PER_MINUTE).floor() as i32;
1686    let second = rem - minute as f64 * SECONDS_PER_MINUTE;
1687
1688    CalendarEpoch::new(year as i32, month as i32, day as i32, hour, minute, second)
1689}
1690
1691fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1692    calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1693}
1694
1695fn segment_bounds(
1696    t0: CalendarEpoch,
1697    t1: CalendarEpoch,
1698    segment_s: i64,
1699) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1700    if segment_s < 1 {
1701        return Err(PiecewiseOrbitError::InvalidSegment);
1702    }
1703    if calendar_seconds(t1) <= calendar_seconds(t0) {
1704        return Err(PiecewiseOrbitError::Reduced(
1705            ReducedOrbitError::InvalidWindow,
1706        ));
1707    }
1708
1709    let mut bounds = Vec::new();
1710    let mut seg_t0 = t0;
1711    let end_s = calendar_seconds(t1);
1712    while calendar_seconds(seg_t0) < end_s {
1713        let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1714        if calendar_seconds(seg_t1) > end_s {
1715            seg_t1 = t1;
1716        }
1717        bounds.push((seg_t0, seg_t1));
1718        seg_t0 = seg_t1;
1719    }
1720    Ok(bounds)
1721}
1722
1723fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1724    matches!(
1725        e,
1726        ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1727    )
1728}
1729
1730fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1731    let s = calendar_seconds(sample);
1732    s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1733}
1734
1735/// Fit contiguous reduced-orbit segments over `[t0, t1]`.
1736///
1737/// `segment_s` is the positive, already-rounded segment length in whole
1738/// seconds. The final short segment may be dropped if it has too few samples;
1739/// interior under-covered segments surface an error because they would create a
1740/// coverage gap. Sample epochs are partitioned by civil calendar seconds, while
1741/// each segment fit still interprets those epochs in `scale` for frame
1742/// transforms.
1743pub fn fit_piecewise(
1744    samples: &[EcefSample],
1745    scale: TimeScale,
1746    model: Model,
1747    t0: CalendarEpoch,
1748    t1: CalendarEpoch,
1749    segment_s: i64,
1750) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1751    let bounds = segment_bounds(t0, t1, segment_s)?;
1752    let last_index = bounds.len().saturating_sub(1);
1753    let mut segments = Vec::new();
1754
1755    for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1756        let subset: Vec<EcefSample> = samples
1757            .iter()
1758            .copied()
1759            .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1760            .collect();
1761
1762        match fit_with_model(&subset, scale, model) {
1763            Ok(orbit) => segments.push(PiecewiseSegment {
1764                t0: seg_t0,
1765                t1: seg_t1,
1766                orbit,
1767            }),
1768            Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1769            Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1770        }
1771    }
1772
1773    let Some(last) = segments.last() else {
1774        return Err(PiecewiseOrbitError::TooFewSamples {
1775            got: 0,
1776            required: MIN_SAMPLES,
1777        });
1778    };
1779
1780    Ok(PiecewiseOrbit {
1781        model,
1782        t0,
1783        t1: last.t1,
1784        segment_s,
1785        segments,
1786    })
1787}
1788
1789/// Return the segment covering `epoch`.
1790///
1791/// Interior boundaries resolve to the later segment; the exact end of the final
1792/// segment resolves to that final segment.
1793pub fn select_piecewise_segment(
1794    piecewise: &PiecewiseOrbit,
1795    epoch: CalendarEpoch,
1796) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1797    validate_piecewise_segments(piecewise)?;
1798    let e = calendar_seconds(epoch);
1799    if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1800        return Err(PiecewiseOrbitError::OutOfRange);
1801    }
1802
1803    let Some((last, rest)) = piecewise.segments.split_last() else {
1804        return Err(PiecewiseOrbitError::OutOfRange);
1805    };
1806
1807    for seg in rest {
1808        if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1809            return Ok(seg);
1810        }
1811    }
1812
1813    if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1814        Ok(last)
1815    } else {
1816        Err(PiecewiseOrbitError::OutOfRange)
1817    }
1818}
1819
1820fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1821    if piecewise.segment_s < 1 {
1822        return Err(PiecewiseOrbitError::InvalidSegment);
1823    }
1824    validate::require_strictly_increasing(
1825        piecewise
1826            .segments
1827            .iter()
1828            .map(|segment| calendar_seconds(segment.t0)),
1829        "piecewise.segments.t0",
1830    )
1831    .map_err(map_piecewise_order_error)?;
1832
1833    for segment in &piecewise.segments {
1834        validate::require_strictly_increasing(
1835            [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1836            "piecewise.segment bounds",
1837        )
1838        .map_err(map_piecewise_order_error)?;
1839    }
1840    Ok(())
1841}
1842
1843fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1844    let reason = match error {
1845        validate::FieldError::NonFinite { .. } => "must be finite",
1846        validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1847        _ => error.reason(),
1848    };
1849    PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1850}
1851
1852/// Evaluate a piecewise reduced orbit at `epoch`.
1853pub fn piecewise_position(
1854    piecewise: &PiecewiseOrbit,
1855    epoch: CalendarEpoch,
1856    scale: TimeScale,
1857    frame: Frame,
1858) -> Result<[f64; 3], PiecewiseOrbitError> {
1859    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1860    let seg = select_piecewise_segment(piecewise, epoch)?;
1861    position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1862}
1863
1864/// Evaluate piecewise reduced-orbit position and velocity at `epoch`.
1865pub fn piecewise_position_velocity(
1866    piecewise: &PiecewiseOrbit,
1867    epoch: CalendarEpoch,
1868    scale: TimeScale,
1869    frame: Frame,
1870) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1871    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1872    let seg = select_piecewise_segment(piecewise, epoch)?;
1873    position_velocity(&seg.orbit.elements, epoch, scale, frame)
1874        .map_err(PiecewiseOrbitError::Reduced)
1875}
1876
1877/// Evaluate a piecewise model against truth ECEF samples.
1878///
1879/// Truth samples outside the model span are skipped, matching the source-backed
1880/// single-model drift behavior when product coverage clips a requested horizon.
1881pub fn piecewise_drift(
1882    piecewise: &PiecewiseOrbit,
1883    truth: &[EcefSample],
1884    scale: TimeScale,
1885    threshold_m: f64,
1886) -> Result<DriftReport, PiecewiseOrbitError> {
1887    validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1888    if truth.is_empty() {
1889        return Ok(DriftReport {
1890            per_epoch: Vec::new(),
1891            max_m: 0.0,
1892            rms_m: 0.0,
1893            threshold_horizon: None,
1894            threshold_index: None,
1895        });
1896    }
1897
1898    let mut per_epoch = Vec::with_capacity(truth.len());
1899    let mut sumsq = 0.0;
1900    let mut max_m = 0.0_f64;
1901    let mut threshold_horizon = None;
1902    let mut threshold_index = None;
1903
1904    for s in truth {
1905        validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1906        let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1907            continue;
1908        };
1909        let dx = model[0] - s.x_m;
1910        let dy = model[1] - s.y_m;
1911        let dz = model[2] - s.z_m;
1912        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1913        sumsq += err * err;
1914        if err > max_m {
1915            max_m = err;
1916        }
1917        if threshold_horizon.is_none() && err > threshold_m {
1918            threshold_horizon = Some(s.epoch);
1919            threshold_index = Some(per_epoch.len());
1920        }
1921        per_epoch.push(DriftEntry {
1922            epoch: s.epoch,
1923            error_m: err,
1924        });
1925    }
1926
1927    if per_epoch.is_empty() {
1928        return Err(PiecewiseOrbitError::TooFewSamples {
1929            got: 0,
1930            required: 1,
1931        });
1932    }
1933
1934    let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1935    Ok(DriftReport {
1936        per_epoch,
1937        max_m,
1938        rms_m,
1939        threshold_horizon,
1940        threshold_index,
1941    })
1942}
1943
1944#[cfg(all(test, sidereon_repo_tests))]
1945mod tests;