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;
98use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
99use crate::astro::frames::transforms::{
100    gcrs_to_itrs_matrix, itrs_to_gcrs_compute, mat3_vec3_mul_unchecked,
101};
102use crate::astro::math::least_squares::{
103    self, solve_trf, LeastSquaresProblem, SolveOptions, Status,
104};
105use crate::astro::math::vec3::{cross3_ref as cross, norm3_ref as norm};
106use crate::astro::time::model::TimeScale;
107use crate::astro::time::scales::{julian_day_number, TimeScales};
108use nalgebra::DVector;
109
110use crate::constants::{M_PER_KM, OMEGA_E_DOT_RAD_S};
111use crate::tolerances::{
112    ECCENTRICITY_ZERO_EPS, REDUCED_ORBIT_KEPLER_STEP_EPS_RAD, REDUCED_ORBIT_SOLVER_TOL,
113    VECTOR_NORM_ZERO_EPS,
114};
115use crate::validate;
116
117mod time;
118use time::dt_seconds;
119pub use time::CalendarEpoch;
120
121/// Minimum number of samples the fitter accepts. The circular model solves five
122/// free elements and the eccentric model eight; each ECEF sample contributes
123/// three residuals, so four well-spread samples (twelve residuals) is the floor.
124/// Below this the geometry seed and refinement are unreliable.
125pub const MIN_SAMPLES: usize = 4;
126
127/// Which mean-element model the fit and evaluation use.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum Model {
130    /// Circular orbit, eccentricity fixed at zero. Best for near-circular
131    /// orbits; the default.
132    #[default]
133    CircularSecular,
134    /// Eccentric orbit via a nonsingular `(h, k)` parameterization. Reproduces
135    /// the radial `a·e` signal and degrades to the circular case as `e -> 0`.
136    EccentricSecular,
137}
138
139/// One fitting/drift truth sample: an epoch and an ECEF (ITRF) position in
140/// meters.
141#[derive(Debug, Clone, Copy)]
142pub struct EcefSample {
143    /// The sample epoch.
144    pub epoch: CalendarEpoch,
145    /// ECEF X in meters.
146    pub x_m: f64,
147    /// ECEF Y in meters.
148    pub y_m: f64,
149    /// ECEF Z in meters.
150    pub z_m: f64,
151}
152
153impl EcefSample {
154    /// Construct a sample from an epoch and ECEF meter components.
155    pub const fn new(epoch: CalendarEpoch, x_m: f64, y_m: f64, z_m: f64) -> Self {
156        Self {
157            epoch,
158            x_m,
159            y_m,
160            z_m,
161        }
162    }
163}
164
165/// The fitted mean elements plus the kept J2 seed.
166///
167/// Lengths are meters, angles radians, rates radians-or-meters per second. The
168/// `raan_rate` is the fitted value; `raan_rate_j2` is the J2 nodal-regression
169/// seed it started from (see the module documentation).
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct Elements {
172    /// Which model these elements belong to.
173    pub model: Model,
174    /// Reference epoch `t0`; all linear angle advances are measured from here.
175    pub epoch: CalendarEpoch,
176    /// Semi-major axis `a` in meters.
177    pub a_m: f64,
178    /// Eccentricity. `0.0` for the circular model; `sqrt(h² + k²)` for the
179    /// eccentric model.
180    pub e: f64,
181    /// Inclination `i` in radians.
182    pub i_rad: f64,
183    /// Right ascension of the ascending node at `t0`, radians.
184    pub raan_rad: f64,
185    /// Fitted nodal regression rate, radians per second.
186    pub raan_rate_rad_s: f64,
187    /// J2 nodal-regression seed for `raan_rate`, radians per second.
188    pub raan_rate_j2_rad_s: f64,
189    /// Argument of latitude at `t0`, radians. For the circular model this is
190    /// `arg_lat0`; for the eccentric model it is `L0 = ω + M0`, the mean
191    /// argument of latitude at epoch (equal to `arg_lat0` at `e = 0`).
192    pub arg_lat_rad: f64,
193    /// Mean motion `n`, radians per second.
194    pub mean_motion_rad_s: f64,
195    /// Eccentricity vector component `h = e·sin ω`. Zero for the circular model.
196    pub h: f64,
197    /// Eccentricity vector component `k = e·cos ω`. Zero for the circular model.
198    pub k: f64,
199    /// Argument of perigee `ω = atan2(h, k)`, radians. Zero for the circular
200    /// model (where it is undefined).
201    pub arg_perigee_rad: f64,
202}
203
204/// Residual statistics from a fit, in meters.
205#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct FitStats {
207    /// Root-mean-square GCRS position residual over the samples, meters.
208    pub rms_m: f64,
209    /// Maximum GCRS position residual over the samples, meters.
210    pub max_m: f64,
211    /// Number of samples used in the fit.
212    pub n_samples: usize,
213}
214
215/// A fitted model: elements plus the residual statistics of the fit.
216#[derive(Debug, Clone, Copy, PartialEq)]
217pub struct ReducedOrbit {
218    /// The fitted mean elements.
219    pub elements: Elements,
220    /// Residual statistics of the fit.
221    pub stats: FitStats,
222}
223
224/// Which reference frame a position/velocity result is expressed in.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum Frame {
227    /// Inertial GCRS (ECI).
228    Gcrs,
229    /// Earth-fixed ITRF/IGS ECEF.
230    Ecef,
231}
232
233/// A per-epoch drift entry: the model-vs-truth position error at one epoch.
234#[derive(Debug, Clone, Copy, PartialEq)]
235pub struct DriftEntry {
236    /// The epoch evaluated.
237    pub epoch: CalendarEpoch,
238    /// Position error magnitude (model minus truth), meters.
239    pub error_m: f64,
240}
241
242/// The result of a source-backed drift evaluation.
243#[derive(Debug, Clone)]
244pub struct DriftReport {
245    /// Per-epoch errors, in input order.
246    pub per_epoch: Vec<DriftEntry>,
247    /// Maximum error over the horizon, meters.
248    pub max_m: f64,
249    /// Root-mean-square error over the horizon, meters.
250    pub rms_m: f64,
251    /// The first epoch at which the error crosses the requested threshold, or
252    /// `None` if it never does over the supplied horizon.
253    pub threshold_horizon: Option<CalendarEpoch>,
254}
255
256/// One fitted segment in a piecewise reduced-orbit model.
257#[derive(Debug, Clone, PartialEq)]
258pub struct PiecewiseSegment {
259    /// Inclusive segment start.
260    pub t0: CalendarEpoch,
261    /// Exclusive segment end, except for the final segment where it is inclusive.
262    pub t1: CalendarEpoch,
263    /// Fitted reduced-orbit model for this segment.
264    pub orbit: ReducedOrbit,
265}
266
267/// A long span represented by contiguous independently-fitted reduced-orbit segments.
268#[derive(Debug, Clone, PartialEq)]
269pub struct PiecewiseOrbit {
270    /// Model fitted in every segment.
271    pub model: Model,
272    /// Advertised coverage start.
273    pub t0: CalendarEpoch,
274    /// Advertised coverage end, inclusive for the final segment.
275    pub t1: CalendarEpoch,
276    /// Rounded segment length used to tile the requested window, seconds.
277    pub segment_s: i64,
278    /// Contiguous fitted segments.
279    pub segments: Vec<PiecewiseSegment>,
280}
281
282/// Errors from fitting or evaluating a reduced orbit.
283#[derive(Debug, Clone)]
284pub enum ReducedOrbitError {
285    /// Fewer samples were supplied than the fit requires.
286    TooFewSamples {
287        /// The number of samples supplied.
288        got: usize,
289        /// The minimum number required ([`MIN_SAMPLES`]).
290        required: usize,
291    },
292    /// The fit window is empty or inverted (`t1 <= t0`), or all samples share an
293    /// epoch so no rate can be resolved.
294    InvalidWindow,
295    /// The samples are collinear or coincident, so the orbital plane normal is
296    /// undefined and the seed cannot be built.
297    SingularPlaneFit,
298    /// The orbit is near-equatorial (`i ~ 0`): the ascending node, and hence
299    /// `raan`, is undefined.
300    RaanAmbiguous,
301    /// The least-squares refinement hit a rank-deficient Jacobian.
302    Singular(least_squares::SolveError),
303    /// The least-squares refinement did not reach a stopping tolerance within
304    /// the evaluation budget.
305    FitDidNotConverge,
306    /// A public evaluation input was non-finite or outside the model domain.
307    InvalidInput {
308        /// Input field name.
309        field: &'static str,
310        /// Human-readable validation failure.
311        reason: &'static str,
312    },
313}
314
315impl core::fmt::Display for ReducedOrbitError {
316    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
317        match self {
318            ReducedOrbitError::TooFewSamples { got, required } => {
319                write!(f, "only {got} samples; need at least {required}")
320            }
321            ReducedOrbitError::InvalidWindow => {
322                write!(f, "the fit window is empty, inverted, or has no time span")
323            }
324            ReducedOrbitError::SingularPlaneFit => {
325                write!(
326                    f,
327                    "samples are collinear or coincident; orbital plane undefined"
328                )
329            }
330            ReducedOrbitError::RaanAmbiguous => {
331                write!(f, "near-equatorial orbit; ascending node (raan) undefined")
332            }
333            ReducedOrbitError::Singular(e) => write!(f, "degenerate fit geometry: {e}"),
334            ReducedOrbitError::FitDidNotConverge => {
335                write!(f, "least-squares refinement did not converge")
336            }
337            ReducedOrbitError::InvalidInput { field, reason } => {
338                write!(f, "invalid reduced-orbit input {field}: {reason}")
339            }
340        }
341    }
342}
343
344impl std::error::Error for ReducedOrbitError {}
345
346impl From<least_squares::SolveError> for ReducedOrbitError {
347    fn from(e: least_squares::SolveError) -> Self {
348        ReducedOrbitError::Singular(e)
349    }
350}
351
352/// Errors from fitting or evaluating a piecewise reduced orbit.
353#[derive(Debug, Clone)]
354pub enum PiecewiseOrbitError {
355    /// Segment length is missing, non-positive, or rounds below one second.
356    InvalidSegment,
357    /// Query epoch is outside the piecewise model coverage.
358    OutOfRange,
359    /// A fit/drift operation did not have enough samples for the requested step.
360    TooFewSamples {
361        /// The number of usable samples supplied.
362        got: usize,
363        /// The minimum number required.
364        required: usize,
365    },
366    /// The underlying single-segment model returned an error.
367    Reduced(ReducedOrbitError),
368}
369
370impl From<ReducedOrbitError> for PiecewiseOrbitError {
371    fn from(e: ReducedOrbitError) -> Self {
372        PiecewiseOrbitError::Reduced(e)
373    }
374}
375
376/// Near-equatorial threshold (radians) below which `raan` is treated as
377/// undefined.
378const MIN_INCLINATION_RAD: f64 = 1.0e-3;
379
380/// Relative floor on the averaged plane-normal magnitude, scaled by `a_km^2`
381/// (the cross product of two position vectors scales as `r^2 ~ a^2`). A normal
382/// below this is treated as a vanishing cross product from collinear/coincident
383/// samples, i.e. a singular plane fit.
384const PLANE_NORMAL_SINGULAR_REL_EPS: f64 = 1.0e-9;
385
386// ---------------------------------------------------------------------------
387// Element evaluation (GCRS), kilometers internally.
388// ---------------------------------------------------------------------------
389
390/// Free parameters in fit order: `[a_km, i, raan0, raan_rate, arg_lat0, n]`.
391/// Eccentricity is held at zero and is not a free parameter.
392const N_PARAMS: usize = 6;
393
394/// Map a normalized fit vector back to physical parameters
395/// `[a_km, i, raan0, raan_rate, arg_lat0, n]`. `a` is carried in units of the
396/// seed semi-major axis and the two rates as the angle swept across the window
397/// (`rate * window`), so the solver sees comparably scaled columns.
398fn unscale_params(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS] {
399    [
400        x[0] * a_scale,
401        x[1],
402        x[2],
403        x[3] / rate_scale,
404        x[4],
405        x[5] / rate_scale,
406    ]
407}
408
409/// Free parameters of the eccentric model, in fit order:
410/// `[a_km, i, raan0, raan_rate, h, k, L0, n]`.
411const N_PARAMS_ECC: usize = 8;
412
413/// Map a normalized eccentric fit vector back to physical parameters
414/// `[a_km, i, raan0, raan_rate, h, k, L0, n]`. The same normalization as the
415/// circular model: `a` in units of the seed axis, the two rates as swept angle.
416/// `h` and `k` are unscaled - at unit magnitude they perturb the position at the
417/// `~a` kilometre level, comparable to the angle columns.
418fn unscale_params_ecc(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS_ECC] {
419    [
420        x[0] * a_scale,
421        x[1],
422        x[2],
423        x[3] / rate_scale,
424        x[4],
425        x[5],
426        x[6],
427        x[7] / rate_scale,
428    ]
429}
430
431/// Solve Kepler's equation `E − e·sin E = M` for the eccentric anomaly `E`
432/// (radians) by Newton's method. `e < 1`. `M` is wrapped to `[0, 2π)` for
433/// conditioning; the GNSS eccentricities here (`e ~ 0.01-0.17`) converge in a
434/// few iterations.
435fn solve_kepler(m: f64, e: f64) -> f64 {
436    let two_pi = 2.0 * std::f64::consts::PI;
437    let m = m.rem_euclid(two_pi);
438    let mut ee = if e < 0.8 { m } else { std::f64::consts::PI };
439    for _ in 0..30 {
440        let f = ee - e * ee.sin() - m;
441        let fp = 1.0 - e * ee.cos();
442        let d = f / fp;
443        ee -= d;
444        if d.abs() < REDUCED_ORBIT_KEPLER_STEP_EPS_RAD {
445            break;
446        }
447    }
448    ee
449}
450
451/// Evaluate the GCRS position (kilometers) of the `eccentric_secular` model from
452/// a parameter vector `[a_km, i, raan0, raan_rate, h, k, L0, n]` at offset `dt`.
453fn eval_gcrs_km_ecc(p: &[f64], dt: f64) -> [f64; 3] {
454    let a = p[0];
455    let i = p[1];
456    let raan = p[2] + p[3] * dt;
457    let h = p[4];
458    let k = p[5];
459    let lambda = p[6] + p[7] * dt;
460
461    let e = (h * h + k * k).sqrt();
462    if e < ECCENTRICITY_ZERO_EPS {
463        // Circular fast path: u -> lambda, exactly the circular model.
464        return eval_gcrs_km(&[a, i, p[2], p[3], p[6], p[7]], dt);
465    }
466    let omega = h.atan2(k);
467    let mm = lambda - omega;
468    let big_e = solve_kepler(mm, e);
469    let (se, ce) = big_e.sin_cos();
470    let r = a * (1.0 - e * ce);
471    let nu = (((1.0 - e * e).sqrt()) * se).atan2(ce - e);
472    let u = omega + nu;
473
474    rotate_in_plane_km([r * u.cos(), r * u.sin()], i, raan)
475}
476
477/// Rotate an in-plane (node-aligned) 2-vector `[x, y]` km by `Rx(i)` then
478/// `Rz(raan)` into GCRS, matching the circular model's outer rotation.
479fn rotate_in_plane_km(xy: [f64; 2], i: f64, raan: f64) -> [f64; 3] {
480    let (si, ci) = i.sin_cos();
481    let (sr, cr) = raan.sin_cos();
482    let x1 = xy[0];
483    let y1 = xy[1] * ci;
484    let z1 = xy[1] * si;
485    [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
486}
487
488/// Analytic GCRS velocity (km/s) of the `eccentric_secular` model at offset `dt`.
489/// Derivative of [`eval_gcrs_km_ecc`] with respect to time (`ω`, `e` constant,
490/// `dM/dt = n`, `dRaan/dt = raan_rate`).
491fn eval_gcrs_velocity_km_s_ecc(p: &[f64], dt: f64) -> [f64; 3] {
492    let a = p[0];
493    let i = p[1];
494    let raan_rate = p[3];
495    let h = p[4];
496    let k = p[5];
497    let n = p[7];
498    let raan = p[2] + raan_rate * dt;
499    let lambda = p[6] + n * dt;
500
501    let e = (h * h + k * k).sqrt();
502    if e < ECCENTRICITY_ZERO_EPS {
503        return eval_gcrs_velocity_km_s(&[a, i, p[2], p[3], p[6], p[7]], dt);
504    }
505    let omega = h.atan2(k);
506    let mm = lambda - omega;
507    let big_e = solve_kepler(mm, e);
508    let (se, ce) = big_e.sin_cos();
509    // dE/dt from differentiating Kepler: (1 - e cos E) Edot = dM/dt = n.
510    let edot = n / (1.0 - e * ce);
511    let beta = (1.0 - e * e).sqrt();
512
513    // Perifocal position/velocity (x toward perigee).
514    let x_pf = a * (ce - e);
515    let y_pf = a * beta * se;
516    let xdot_pf = -a * se * edot;
517    let ydot_pf = a * beta * ce * edot;
518
519    // Rotate the perifocal frame into the node-aligned in-plane frame by ω.
520    let (so, co) = omega.sin_cos();
521    let x1 = co * x_pf - so * y_pf;
522    let y1 = so * x_pf + co * y_pf;
523    let dx1 = co * xdot_pf - so * ydot_pf;
524    let dy1 = so * xdot_pf + co * ydot_pf;
525
526    // Apply Rx(i) then Rz(raan) with the raan_rate coupling, exactly as the
527    // circular velocity does (only the in-plane inputs differ).
528    let (si, ci) = i.sin_cos();
529    let (sr, cr) = raan.sin_cos();
530    let y1i = y1 * ci;
531    let dy1i = dy1 * ci;
532
533    let vx = cr * dx1 - sr * dy1i + raan_rate * (-sr * x1 - cr * y1i);
534    let vy = sr * dx1 + cr * dy1i + raan_rate * (cr * x1 - sr * y1i);
535    let vz = dy1 * si;
536    [vx, vy, vz]
537}
538
539/// Evaluate the GCRS position (kilometers) of the `circular_secular` model from
540/// a parameter vector at offset `dt` seconds from epoch.
541fn eval_gcrs_km(p: &[f64], dt: f64) -> [f64; 3] {
542    let a = p[0];
543    let i = p[1];
544    let raan = p[2] + p[3] * dt;
545    let u = p[4] + p[5] * dt;
546
547    // In-plane circle.
548    let (su, cu) = u.sin_cos();
549    let xp = a * cu;
550    let yp = a * su;
551
552    // Rotate by inclination about x, then by raan about z.
553    let (si, ci) = i.sin_cos();
554    let (sr, cr) = raan.sin_cos();
555
556    // Rx(i) * [xp, yp, 0] = [xp, yp*ci, yp*si]
557    let x1 = xp;
558    let y1 = yp * ci;
559    let z1 = yp * si;
560
561    // Rz(raan) * [x1, y1, z1]
562    [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
563}
564
565/// Analytic GCRS velocity (km/s) of the model at offset `dt`. Derivative of
566/// [`eval_gcrs_km`] with respect to time.
567fn eval_gcrs_velocity_km_s(p: &[f64], dt: f64) -> [f64; 3] {
568    let a = p[0];
569    let i = p[1];
570    let raan_rate = p[3];
571    let n = p[5];
572    let raan = p[2] + raan_rate * dt;
573    let u = p[4] + n * dt;
574
575    let (su, cu) = u.sin_cos();
576    let (si, ci) = i.sin_cos();
577    let (sr, cr) = raan.sin_cos();
578
579    let xp = a * cu;
580    let yp = a * su;
581    // d/dt of in-plane position.
582    let dxp = -a * su * n;
583    let dyp = a * cu * n;
584
585    // Position in the inclined-but-not-yet-noded frame.
586    let x1 = xp;
587    let y1 = yp * ci;
588    // Its time derivative (i constant).
589    let dx1 = dxp;
590    let dy1 = dyp * ci;
591
592    // r_gcrs = Rz(raan) * [x1, y1, z1]; differentiate including dRaan/dt.
593    // d/dt(cr) = -sr*raan_rate, d/dt(sr) = cr*raan_rate.
594    let vx = cr * dx1 - sr * dy1 + raan_rate * (-sr * x1 - cr * y1);
595    let vy = sr * dx1 + cr * dy1 + raan_rate * (cr * x1 - sr * y1);
596    let vz = dyp * si;
597    [vx, vy, vz]
598}
599
600// ---------------------------------------------------------------------------
601// Seed extraction.
602// ---------------------------------------------------------------------------
603
604struct GcrsSample {
605    dt: f64,
606    r_km: [f64; 3],
607}
608
609/// Build the seed parameter vector from GCRS samples.
610fn seed_params(samples: &[GcrsSample]) -> Result<[f64; N_PARAMS], ReducedOrbitError> {
611    // Mean radius -> a.
612    let a_km = samples.iter().map(|s| norm(&s.r_km)).sum::<f64>() / samples.len() as f64;
613    if !a_km.is_finite() || a_km <= 0.0 {
614        return Err(ReducedOrbitError::SingularPlaneFit);
615    }
616
617    // Averaged plane normal from consecutive cross products.
618    let mut h = [0.0_f64; 3];
619    for w in samples.windows(2) {
620        let c = cross(&w[0].r_km, &w[1].r_km);
621        h[0] += c[0];
622        h[1] += c[1];
623        h[2] += c[2];
624    }
625    let hn = norm(&h);
626    if !hn.is_finite() || hn <= a_km * a_km * PLANE_NORMAL_SINGULAR_REL_EPS {
627        // Cross products vanish: collinear/coincident samples.
628        return Err(ReducedOrbitError::SingularPlaneFit);
629    }
630    let hhat = [h[0] / hn, h[1] / hn, h[2] / hn];
631
632    // Inclination from the normal's z component.
633    let i = hhat[2].clamp(-1.0, 1.0).acos();
634    if i < MIN_INCLINATION_RAD || (std::f64::consts::PI - i) < MIN_INCLINATION_RAD {
635        return Err(ReducedOrbitError::RaanAmbiguous);
636    }
637
638    // RAAN from the node direction n = z_hat x h_hat (points to ascending node).
639    let node = [-hhat[1], hhat[0], 0.0];
640    let node_n = norm(&node);
641    if node_n <= VECTOR_NORM_ZERO_EPS {
642        return Err(ReducedOrbitError::RaanAmbiguous);
643    }
644    let raan0 = node[1].atan2(node[0]);
645    let nhat = [node[0] / node_n, node[1] / node_n, 0.0];
646
647    // Argument of latitude of the first sample: angle from the node, measured in
648    // the orbit plane (positive toward h x n direction of motion).
649    let r0 = &samples[0].r_km;
650    let cos_u = (r0[0] * nhat[0] + r0[1] * nhat[1] + r0[2] * nhat[2]) / norm(r0);
651    // In-plane "perpendicular to node" axis: h_hat x n_hat.
652    let p_axis = cross(&hhat, &nhat);
653    let sin_u = (r0[0] * p_axis[0] + r0[1] * p_axis[1] + r0[2] * p_axis[2]) / norm(r0);
654    let arg_lat0 = sin_u.atan2(cos_u);
655
656    // Mean motion from the Keplerian relation at the fitted a (km, MU in km^3/s^2).
657    let n = (MU_EARTH / (a_km * a_km * a_km)).sqrt();
658
659    // J2 nodal regression seed.
660    let raan_rate = raan_rate_j2(n, i, a_km);
661
662    Ok([a_km, i, raan0, raan_rate, arg_lat0, n])
663}
664
665/// J2 secular nodal-regression rate (Vallado), radians per second.
666/// `a` in kilometers (matching `RE_EARTH`).
667fn raan_rate_j2(n: f64, i: f64, a_km: f64) -> f64 {
668    let re_over_a = RE_EARTH / a_km;
669    -1.5 * n * J2_EARTH * re_over_a * re_over_a * i.cos()
670}
671
672// ---------------------------------------------------------------------------
673// Fit.
674// ---------------------------------------------------------------------------
675
676/// Fit the default `circular_secular` model to ECEF samples, with all epochs
677/// interpreted in `scale` (e.g. an SP3 product's GPST).
678///
679/// `samples` are `(epoch, ECEF meters)`; they are ordered by time, the earliest
680/// becomes the model epoch `t0`, each is converted ITRS->GCRS at the correct
681/// Earth orientation, and the five free elements (`a`, `i`, `raan0`, `raan_rate`,
682/// `arg_lat0`, `n`) are refined to minimize stacked GCRS position residuals.
683///
684/// Equivalent to [`fit_with_model`] with [`Model::CircularSecular`].
685pub fn fit(samples: &[EcefSample], scale: TimeScale) -> Result<ReducedOrbit, ReducedOrbitError> {
686    fit_with_model(samples, scale, Model::CircularSecular)
687}
688
689/// Fit a chosen [`Model`] to ECEF samples interpreted in `scale`.
690///
691/// The seed (mean axis, plane normal, node, mean motion, J2 nodal rate) and the
692/// normalized Levenberg-Marquardt refinement are shared by both models; the
693/// eccentric model adds the `h`, `k` eccentricity-vector columns (seeded at
694/// zero) and solves Kepler's equation per residual.
695pub fn fit_with_model(
696    samples: &[EcefSample],
697    scale: TimeScale,
698    model: Model,
699) -> Result<ReducedOrbit, ReducedOrbitError> {
700    match model {
701        Model::CircularSecular => fit_circular(samples, scale),
702        Model::EccentricSecular => fit_eccentric(samples, scale),
703    }
704}
705
706fn fit_circular(
707    samples: &[EcefSample],
708    scale: TimeScale,
709) -> Result<ReducedOrbit, ReducedOrbitError> {
710    if samples.len() < MIN_SAMPLES {
711        return Err(ReducedOrbitError::TooFewSamples {
712            got: samples.len(),
713            required: MIN_SAMPLES,
714        });
715    }
716    validate_fit_epochs(samples, scale)?;
717
718    // Order by absolute time so the seed, t0, and consecutive-pair plane fit do
719    // not depend on the caller's sample order.
720    let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
721        .iter()
722        .map(|s| (s.epoch.time_scales(scale), s))
723        .collect();
724    ordered.sort_by(|a, b| {
725        (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
726    });
727
728    let t0_cal = ordered[0].1.epoch;
729    let t0_ts = ordered[0].0;
730
731    // Convert every ECEF sample to GCRS km at its own epoch.
732    let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
733    for (ts, s) in &ordered {
734        let (x, y, z) =
735            itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
736                .expect("valid frame transform");
737        let dt = dt_seconds(&t0_ts, ts);
738        gcrs.push(GcrsSample {
739            dt,
740            r_km: [x, y, z],
741        });
742    }
743
744    // Window must span time (also rejects a non-finite span).
745    let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
746        - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
747    if !dt_span.is_finite() || dt_span <= 0.0 {
748        return Err(ReducedOrbitError::InvalidWindow);
749    }
750
751    let seed = seed_params(&gcrs)?;
752
753    // The physical parameters span ~10 orders of magnitude (a ~ 2.7e4 km down to
754    // the nodal rate ~ 1e-8 rad/s), and the core solver damps with a uniform
755    // mu*I and no per-variable scaling. Left unscaled, the nodal rate is
756    // effectively invisible to the solver and stays at its seed. Fit instead in a
757    // normalized space where every parameter perturbs the position at the ~a
758    // kilometre level: a is measured in units of the seed semi-major axis, and
759    // the two rates are carried as the total angle they sweep across the window.
760    let a_scale = seed[0];
761    let rate_scale = dt_span; // raan_rate and n fit as (rate * window) = swept angle
762    let seed_scaled = [
763        seed[0] / a_scale,
764        seed[1],
765        seed[2],
766        seed[3] * rate_scale,
767        seed[4],
768        seed[5] * rate_scale,
769    ];
770
771    // Stacked GCRS position residuals (km), one xyz block per sample.
772    let m = 3 * gcrs.len();
773    let residual = {
774        let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
775        move |x: &DVector<f64>| -> DVector<f64> {
776            let xs = x.as_slice();
777            let p = unscale_params(xs, a_scale, rate_scale);
778            let mut r = Vec::with_capacity(m);
779            for (dt, obs) in &gcrs_ref {
780                let model = eval_gcrs_km(&p, *dt);
781                r.push(model[0] - obs[0]);
782                r.push(model[1] - obs[1]);
783                r.push(model[2] - obs[2]);
784            }
785            DVector::from_vec(r)
786        }
787    };
788
789    let x0 = DVector::from_row_slice(&seed_scaled);
790    let problem = LeastSquaresProblem::new(residual, x0);
791    let opts = SolveOptions {
792        gtol: REDUCED_ORBIT_SOLVER_TOL,
793        ftol: REDUCED_ORBIT_SOLVER_TOL,
794        xtol: REDUCED_ORBIT_SOLVER_TOL,
795        max_nfev: 400,
796    };
797    let report = solve_trf(&problem, &opts)?;
798
799    let converged = matches!(
800        report.status,
801        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
802    );
803    if !converged {
804        return Err(ReducedOrbitError::FitDidNotConverge);
805    }
806
807    let p = unscale_params(report.x.as_slice(), a_scale, rate_scale);
808    // Residual stats in meters.
809    let res = &report.residual;
810    let n_samp = gcrs.len();
811    let mut sumsq = 0.0;
812    let mut maxsq = 0.0_f64;
813    for k in 0..n_samp {
814        let dx = res[3 * k] * M_PER_KM;
815        let dy = res[3 * k + 1] * M_PER_KM;
816        let dz = res[3 * k + 2] * M_PER_KM;
817        let e2 = dx * dx + dy * dy + dz * dz;
818        sumsq += e2;
819        if e2 > maxsq {
820            maxsq = e2;
821        }
822    }
823    let rms_m = (sumsq / n_samp as f64).sqrt();
824    let max_m = maxsq.sqrt();
825
826    let elements = Elements {
827        model: Model::CircularSecular,
828        epoch: t0_cal,
829        a_m: p[0] * M_PER_KM,
830        e: 0.0,
831        i_rad: p[1],
832        raan_rad: p[2],
833        raan_rate_rad_s: p[3],
834        raan_rate_j2_rad_s: raan_rate_j2(p[5], p[1], p[0]),
835        arg_lat_rad: p[4],
836        mean_motion_rad_s: p[5],
837        h: 0.0,
838        k: 0.0,
839        arg_perigee_rad: 0.0,
840    };
841
842    Ok(ReducedOrbit {
843        elements,
844        stats: FitStats {
845            rms_m,
846            max_m,
847            n_samples: n_samp,
848        },
849    })
850}
851
852/// Convert ordered ECEF samples to GCRS-km samples and the model epoch.
853fn to_gcrs_samples(
854    samples: &[EcefSample],
855    scale: TimeScale,
856) -> Result<(CalendarEpoch, Vec<GcrsSample>, f64), ReducedOrbitError> {
857    if samples.len() < MIN_SAMPLES {
858        return Err(ReducedOrbitError::TooFewSamples {
859            got: samples.len(),
860            required: MIN_SAMPLES,
861        });
862    }
863    validate_fit_epochs(samples, scale)?;
864
865    let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
866        .iter()
867        .map(|s| (s.epoch.time_scales(scale), s))
868        .collect();
869    ordered.sort_by(|a, b| {
870        (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
871    });
872
873    let t0_cal = ordered[0].1.epoch;
874    let t0_ts = ordered[0].0;
875
876    let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
877    for (ts, s) in &ordered {
878        let (x, y, z) =
879            itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
880                .expect("valid frame transform");
881        let dt = dt_seconds(&t0_ts, ts);
882        gcrs.push(GcrsSample {
883            dt,
884            r_km: [x, y, z],
885        });
886    }
887
888    let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
889        - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
890    if !dt_span.is_finite() || dt_span <= 0.0 {
891        return Err(ReducedOrbitError::InvalidWindow);
892    }
893
894    Ok((t0_cal, gcrs, dt_span))
895}
896
897/// Fit the `eccentric_secular` model. Reuses the circular seed and the same
898/// normalized LM, adding the `h`, `k` columns (seeded at zero) and solving
899/// Kepler's equation per residual.
900fn fit_eccentric(
901    samples: &[EcefSample],
902    scale: TimeScale,
903) -> Result<ReducedOrbit, ReducedOrbitError> {
904    let (t0_cal, gcrs, dt_span) = to_gcrs_samples(samples, scale)?;
905
906    // The circular seed supplies a, i, raan0, raan_rate, arg_lat0 (=L0), n.
907    let seed_c = seed_params(&gcrs)?;
908    let a_scale = seed_c[0];
909    let rate_scale = dt_span;
910
911    // Seed e = 0 (h = k = 0); L0 from the first sample's argument of latitude.
912    // Normalized seed: a in seed-axis units, rates as swept angle, h/k unscaled.
913    let seed_scaled = [
914        1.0,                    // a / a_scale
915        seed_c[1],              // i
916        seed_c[2],              // raan0
917        seed_c[3] * rate_scale, // raan_rate (swept angle)
918        0.0,                    // h
919        0.0,                    // k
920        seed_c[4],              // L0 = arg_lat0
921        seed_c[5] * rate_scale, // n (swept angle)
922    ];
923
924    let m = 3 * gcrs.len();
925    let residual = {
926        let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
927        move |x: &DVector<f64>| -> DVector<f64> {
928            let xs = x.as_slice();
929            let p = unscale_params_ecc(xs, a_scale, rate_scale);
930            let mut r = Vec::with_capacity(m);
931            for (dt, obs) in &gcrs_ref {
932                let model = eval_gcrs_km_ecc(&p, *dt);
933                r.push(model[0] - obs[0]);
934                r.push(model[1] - obs[1]);
935                r.push(model[2] - obs[2]);
936            }
937            DVector::from_vec(r)
938        }
939    };
940
941    let x0 = DVector::from_row_slice(&seed_scaled);
942    let problem = LeastSquaresProblem::new(residual, x0);
943    let opts = SolveOptions {
944        gtol: REDUCED_ORBIT_SOLVER_TOL,
945        ftol: REDUCED_ORBIT_SOLVER_TOL,
946        xtol: REDUCED_ORBIT_SOLVER_TOL,
947        max_nfev: 400,
948    };
949    let report = solve_trf(&problem, &opts)?;
950
951    let converged = matches!(
952        report.status,
953        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
954    );
955    if !converged {
956        return Err(ReducedOrbitError::FitDidNotConverge);
957    }
958
959    let p = unscale_params_ecc(report.x.as_slice(), a_scale, rate_scale);
960    let res = &report.residual;
961    let n_samp = gcrs.len();
962    let mut sumsq = 0.0;
963    let mut maxsq = 0.0_f64;
964    for k in 0..n_samp {
965        let dx = res[3 * k] * M_PER_KM;
966        let dy = res[3 * k + 1] * M_PER_KM;
967        let dz = res[3 * k + 2] * M_PER_KM;
968        let e2 = dx * dx + dy * dy + dz * dz;
969        sumsq += e2;
970        if e2 > maxsq {
971            maxsq = e2;
972        }
973    }
974    let rms_m = (sumsq / n_samp as f64).sqrt();
975    let max_m = maxsq.sqrt();
976
977    let h = p[4];
978    let k = p[5];
979    let e = (h * h + k * k).sqrt();
980    let arg_perigee_rad = if e < ECCENTRICITY_ZERO_EPS {
981        0.0
982    } else {
983        h.atan2(k)
984    };
985
986    let elements = Elements {
987        model: Model::EccentricSecular,
988        epoch: t0_cal,
989        a_m: p[0] * M_PER_KM,
990        e,
991        i_rad: p[1],
992        raan_rad: p[2],
993        raan_rate_rad_s: p[3],
994        raan_rate_j2_rad_s: raan_rate_j2(p[7], p[1], p[0]),
995        arg_lat_rad: p[6],
996        mean_motion_rad_s: p[7],
997        h,
998        k,
999        arg_perigee_rad,
1000    };
1001
1002    Ok(ReducedOrbit {
1003        elements,
1004        stats: FitStats {
1005            rms_m,
1006            max_m,
1007            n_samples: n_samp,
1008        },
1009    })
1010}
1011
1012// ---------------------------------------------------------------------------
1013// Evaluation.
1014// ---------------------------------------------------------------------------
1015
1016fn params_from_elements(e: &Elements) -> [f64; N_PARAMS] {
1017    [
1018        e.a_m / M_PER_KM,
1019        e.i_rad,
1020        e.raan_rad,
1021        e.raan_rate_rad_s,
1022        e.arg_lat_rad,
1023        e.mean_motion_rad_s,
1024    ]
1025}
1026
1027fn params_from_elements_ecc(e: &Elements) -> [f64; N_PARAMS_ECC] {
1028    [
1029        e.a_m / M_PER_KM,
1030        e.i_rad,
1031        e.raan_rad,
1032        e.raan_rate_rad_s,
1033        e.h,
1034        e.k,
1035        e.arg_lat_rad,
1036        e.mean_motion_rad_s,
1037    ]
1038}
1039
1040/// GCRS position (km) of `elements` at offset `dt`, dispatching on the model.
1041fn eval_position_km(elements: &Elements, dt: f64) -> [f64; 3] {
1042    match elements.model {
1043        Model::CircularSecular => eval_gcrs_km(&params_from_elements(elements), dt),
1044        Model::EccentricSecular => eval_gcrs_km_ecc(&params_from_elements_ecc(elements), dt),
1045    }
1046}
1047
1048/// GCRS velocity (km/s) of `elements` at offset `dt`, dispatching on the model.
1049fn eval_velocity_km_s(elements: &Elements, dt: f64) -> [f64; 3] {
1050    match elements.model {
1051        Model::CircularSecular => eval_gcrs_velocity_km_s(&params_from_elements(elements), dt),
1052        Model::EccentricSecular => {
1053            eval_gcrs_velocity_km_s_ecc(&params_from_elements_ecc(elements), dt)
1054        }
1055    }
1056}
1057
1058/// Evaluate the model position at `epoch` (interpreted in `scale`) in the
1059/// requested frame, meters.
1060pub fn position(
1061    elements: &Elements,
1062    epoch: CalendarEpoch,
1063    scale: TimeScale,
1064    frame: Frame,
1065) -> Result<[f64; 3], ReducedOrbitError> {
1066    validate_elements_for_evaluation(elements, scale)?;
1067    validate_calendar_epoch(epoch, scale, "epoch")?;
1068    let t0_ts = elements.epoch.time_scales(scale);
1069    let ts = epoch.time_scales(scale);
1070    let dt = dt_seconds(&t0_ts, &ts);
1071    validate_finite(dt, "dt_s")?;
1072    let r_gcrs_km = eval_position_km(elements, dt);
1073    let r = match frame {
1074        Frame::Gcrs => [
1075            r_gcrs_km[0] * M_PER_KM,
1076            r_gcrs_km[1] * M_PER_KM,
1077            r_gcrs_km[2] * M_PER_KM,
1078        ],
1079        Frame::Ecef => {
1080            let mat = gcrs_to_itrs_matrix(&ts)
1081                .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1082            let r = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1083            [r[0] * M_PER_KM, r[1] * M_PER_KM, r[2] * M_PER_KM]
1084        }
1085    };
1086    validate_vec3(r, "position_m")?;
1087    Ok(r)
1088}
1089
1090/// Evaluate the model position and velocity at `epoch` in the requested frame.
1091/// Returns `(position_m, velocity_m_s)`. ECEF velocity includes the
1092/// Earth-rotation transport term.
1093pub fn position_velocity(
1094    elements: &Elements,
1095    epoch: CalendarEpoch,
1096    scale: TimeScale,
1097    frame: Frame,
1098) -> Result<([f64; 3], [f64; 3]), ReducedOrbitError> {
1099    validate_elements_for_evaluation(elements, scale)?;
1100    validate_calendar_epoch(epoch, scale, "epoch")?;
1101    let t0_ts = elements.epoch.time_scales(scale);
1102    let ts = epoch.time_scales(scale);
1103    let dt = dt_seconds(&t0_ts, &ts);
1104    validate_finite(dt, "dt_s")?;
1105    let r_gcrs_km = eval_position_km(elements, dt);
1106    let v_gcrs_km_s = eval_velocity_km_s(elements, dt);
1107
1108    let (r, v) = match frame {
1109        Frame::Gcrs => {
1110            let r = [
1111                r_gcrs_km[0] * M_PER_KM,
1112                r_gcrs_km[1] * M_PER_KM,
1113                r_gcrs_km[2] * M_PER_KM,
1114            ];
1115            let v = [
1116                v_gcrs_km_s[0] * M_PER_KM,
1117                v_gcrs_km_s[1] * M_PER_KM,
1118                v_gcrs_km_s[2] * M_PER_KM,
1119            ];
1120            (r, v)
1121        }
1122        Frame::Ecef => {
1123            let mat = gcrs_to_itrs_matrix(&ts)
1124                .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1125            let r_itrs_km = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1126            let v_rot_km_s = mat3_vec3_mul_unchecked(&mat, &v_gcrs_km_s);
1127            // Transport term: v_itrs = R v_gcrs - omega x r_itrs.
1128            let vx = v_rot_km_s[0] + OMEGA_E_DOT_RAD_S * r_itrs_km[1];
1129            let vy = v_rot_km_s[1] - OMEGA_E_DOT_RAD_S * r_itrs_km[0];
1130            let vz = v_rot_km_s[2];
1131            let r = [
1132                r_itrs_km[0] * M_PER_KM,
1133                r_itrs_km[1] * M_PER_KM,
1134                r_itrs_km[2] * M_PER_KM,
1135            ];
1136            let v = [vx * M_PER_KM, vy * M_PER_KM, vz * M_PER_KM];
1137            (r, v)
1138        }
1139    };
1140    validate_vec3(r, "position_m")?;
1141    validate_vec3(v, "velocity_m_s")?;
1142    Ok((r, v))
1143}
1144
1145fn validate_elements_for_evaluation(
1146    elements: &Elements,
1147    scale: TimeScale,
1148) -> Result<(), ReducedOrbitError> {
1149    validate_calendar_epoch(elements.epoch, scale, "elements.epoch")?;
1150    validate_positive(elements.a_m, "elements.a_m")?;
1151    validate_finite(elements.e, "elements.e")?;
1152    if !(0.0..1.0).contains(&elements.e) {
1153        return Err(invalid_input("elements.e", "must be in [0, 1)"));
1154    }
1155    validate_finite(elements.i_rad, "elements.i_rad")?;
1156    if !(0.0..=std::f64::consts::PI).contains(&elements.i_rad) {
1157        return Err(invalid_input("elements.i_rad", "must be in [0, pi]"));
1158    }
1159    validate_finite(elements.raan_rad, "elements.raan_rad")?;
1160    validate_finite(elements.raan_rate_rad_s, "elements.raan_rate_rad_s")?;
1161    validate_finite(elements.raan_rate_j2_rad_s, "elements.raan_rate_j2_rad_s")?;
1162    validate_finite(elements.arg_lat_rad, "elements.arg_lat_rad")?;
1163    validate_positive(elements.mean_motion_rad_s, "elements.mean_motion_rad_s")?;
1164    validate_finite(elements.h, "elements.h")?;
1165    validate_finite(elements.k, "elements.k")?;
1166    validate_finite(elements.arg_perigee_rad, "elements.arg_perigee_rad")?;
1167    if elements.model == Model::EccentricSecular {
1168        let derived_e = (elements.h * elements.h + elements.k * elements.k).sqrt();
1169        validate_finite(derived_e, "elements.h_k")?;
1170        if derived_e >= 1.0 {
1171            return Err(invalid_input("elements.h_k", "eccentricity must be < 1"));
1172        }
1173    }
1174    Ok(())
1175}
1176
1177fn validate_calendar_epoch(
1178    epoch: CalendarEpoch,
1179    scale: TimeScale,
1180    field: &'static str,
1181) -> Result<(), ReducedOrbitError> {
1182    let second_policy = match scale {
1183        TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
1184        TimeScale::Tai
1185        | TimeScale::Tt
1186        | TimeScale::Tdb
1187        | TimeScale::Gpst
1188        | TimeScale::Gst
1189        | TimeScale::Bdt => validate::CivilSecondPolicy::Continuous,
1190    };
1191    validate::civil_datetime_with_second_policy(
1192        i64::from(epoch.year),
1193        i64::from(epoch.month),
1194        i64::from(epoch.day),
1195        i64::from(epoch.hour),
1196        i64::from(epoch.minute),
1197        epoch.second,
1198        second_policy,
1199    )
1200    .map(|_| ())
1201    .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1202}
1203
1204fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1205    for sample in samples {
1206        validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1207        validate_finite(sample.x_m, "sample.x_m")?;
1208        validate_finite(sample.y_m, "sample.y_m")?;
1209        validate_finite(sample.z_m, "sample.z_m")?;
1210    }
1211    Ok(())
1212}
1213
1214fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1215    validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1216    validate_finite(sample.x_m, "truth.x_m")?;
1217    validate_finite(sample.y_m, "truth.y_m")?;
1218    validate_finite(sample.z_m, "truth.z_m")
1219}
1220
1221fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1222    for component in value {
1223        validate_finite(component, field)?;
1224    }
1225    Ok(())
1226}
1227
1228fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1229    if value.is_finite() {
1230        Ok(())
1231    } else {
1232        Err(invalid_input(field, "must be finite"))
1233    }
1234}
1235
1236fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1237    validate_finite(value, field)?;
1238    if value > 0.0 {
1239        Ok(())
1240    } else {
1241        Err(invalid_input(field, "must be positive"))
1242    }
1243}
1244
1245fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1246    ReducedOrbitError::InvalidInput { field, reason }
1247}
1248
1249// ---------------------------------------------------------------------------
1250// Drift.
1251// ---------------------------------------------------------------------------
1252
1253/// Evaluate the model against truth ECEF samples and report the per-epoch error,
1254/// its statistics, and the first epoch the error crosses `threshold_m`.
1255///
1256/// The error is computed in ECEF meters (model ECEF minus truth ECEF). This is
1257/// source-backed: the caller supplies the truth `(epoch, ECEF)` samples; the
1258/// function does not compare the model against itself.
1259pub fn drift(
1260    elements: &Elements,
1261    truth: &[EcefSample],
1262    scale: TimeScale,
1263    threshold_m: f64,
1264) -> Result<DriftReport, ReducedOrbitError> {
1265    validate_elements_for_evaluation(elements, scale)?;
1266    validate_finite(threshold_m, "threshold_m")?;
1267    let mut per_epoch = Vec::with_capacity(truth.len());
1268    let mut sumsq = 0.0;
1269    let mut max_m = 0.0_f64;
1270    let mut threshold_horizon = None;
1271
1272    for s in truth {
1273        validate_truth_sample(s, scale)?;
1274        let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1275        let dx = model[0] - s.x_m;
1276        let dy = model[1] - s.y_m;
1277        let dz = model[2] - s.z_m;
1278        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1279        validate_finite(err, "drift.error_m")?;
1280        sumsq += err * err;
1281        validate_finite(sumsq, "drift.sumsq")?;
1282        if err > max_m {
1283            max_m = err;
1284        }
1285        if threshold_horizon.is_none() && err > threshold_m {
1286            threshold_horizon = Some(s.epoch);
1287        }
1288        per_epoch.push(DriftEntry {
1289            epoch: s.epoch,
1290            error_m: err,
1291        });
1292    }
1293
1294    let rms_m = if per_epoch.is_empty() {
1295        0.0
1296    } else {
1297        (sumsq / per_epoch.len() as f64).sqrt()
1298    };
1299    validate_finite(max_m, "drift.max_m")?;
1300    validate_finite(rms_m, "drift.rms_m")?;
1301
1302    Ok(DriftReport {
1303        per_epoch,
1304        max_m,
1305        rms_m,
1306        threshold_horizon,
1307    })
1308}
1309
1310// ---------------------------------------------------------------------------
1311// Piecewise fit/evaluation.
1312// ---------------------------------------------------------------------------
1313
1314fn calendar_seconds(t: CalendarEpoch) -> f64 {
1315    julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1316        + (t.hour as f64) * 3600.0
1317        + (t.minute as f64) * 60.0
1318        + t.second
1319}
1320
1321fn civil_from_jdn(jdn: i64) -> (i32, i32, i32) {
1322    // Fliegel-Van Flandern inverse of the integer Julian Day Number.
1323    let mut l = jdn + 68569;
1324    let n = (4 * l) / 146097;
1325    l -= (146097 * n + 3) / 4;
1326    let i = (4000 * (l + 1)) / 1461001;
1327    l = l - (1461 * i) / 4 + 31;
1328    let j = (80 * l) / 2447;
1329    let day = l - (2447 * j) / 80;
1330    l = j / 11;
1331    let month = j + 2 - 12 * l;
1332    let year = 100 * (n - 49) + i + l;
1333    (year as i32, month as i32, day as i32)
1334}
1335
1336fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1337    let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1338    let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1339    if sod < 0.0 {
1340        jdn -= 1;
1341        sod += SECONDS_PER_DAY;
1342    }
1343    if sod >= SECONDS_PER_DAY {
1344        jdn += 1;
1345        sod -= SECONDS_PER_DAY;
1346    }
1347
1348    let (year, month, day) = civil_from_jdn(jdn);
1349    let hour = (sod / 3600.0).floor() as i32;
1350    let rem = sod - hour as f64 * 3600.0;
1351    let minute = (rem / 60.0).floor() as i32;
1352    let second = rem - minute as f64 * 60.0;
1353
1354    CalendarEpoch::new(year, month, day, hour, minute, second)
1355}
1356
1357fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1358    calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1359}
1360
1361fn segment_bounds(
1362    t0: CalendarEpoch,
1363    t1: CalendarEpoch,
1364    segment_s: i64,
1365) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1366    if segment_s < 1 {
1367        return Err(PiecewiseOrbitError::InvalidSegment);
1368    }
1369    if calendar_seconds(t1) <= calendar_seconds(t0) {
1370        return Err(PiecewiseOrbitError::Reduced(
1371            ReducedOrbitError::InvalidWindow,
1372        ));
1373    }
1374
1375    let mut bounds = Vec::new();
1376    let mut seg_t0 = t0;
1377    let end_s = calendar_seconds(t1);
1378    while calendar_seconds(seg_t0) < end_s {
1379        let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1380        if calendar_seconds(seg_t1) > end_s {
1381            seg_t1 = t1;
1382        }
1383        bounds.push((seg_t0, seg_t1));
1384        seg_t0 = seg_t1;
1385    }
1386    Ok(bounds)
1387}
1388
1389fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1390    matches!(
1391        e,
1392        ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1393    )
1394}
1395
1396fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1397    let s = calendar_seconds(sample);
1398    s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1399}
1400
1401/// Fit contiguous reduced-orbit segments over `[t0, t1]`.
1402///
1403/// `segment_s` is the positive, already-rounded segment length in whole
1404/// seconds. The final short segment may be dropped if it has too few samples;
1405/// interior under-covered segments surface an error because they would create a
1406/// coverage gap. Sample epochs are partitioned by civil calendar seconds, while
1407/// each segment fit still interprets those epochs in `scale` for frame
1408/// transforms.
1409pub fn fit_piecewise(
1410    samples: &[EcefSample],
1411    scale: TimeScale,
1412    model: Model,
1413    t0: CalendarEpoch,
1414    t1: CalendarEpoch,
1415    segment_s: i64,
1416) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1417    let bounds = segment_bounds(t0, t1, segment_s)?;
1418    let last_index = bounds.len().saturating_sub(1);
1419    let mut segments = Vec::new();
1420
1421    for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1422        let subset: Vec<EcefSample> = samples
1423            .iter()
1424            .copied()
1425            .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1426            .collect();
1427
1428        match fit_with_model(&subset, scale, model) {
1429            Ok(orbit) => segments.push(PiecewiseSegment {
1430                t0: seg_t0,
1431                t1: seg_t1,
1432                orbit,
1433            }),
1434            Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1435            Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1436        }
1437    }
1438
1439    let Some(last) = segments.last() else {
1440        return Err(PiecewiseOrbitError::TooFewSamples {
1441            got: 0,
1442            required: MIN_SAMPLES,
1443        });
1444    };
1445
1446    Ok(PiecewiseOrbit {
1447        model,
1448        t0,
1449        t1: last.t1,
1450        segment_s,
1451        segments,
1452    })
1453}
1454
1455/// Return the segment covering `epoch`.
1456///
1457/// Interior boundaries resolve to the later segment; the exact end of the final
1458/// segment resolves to that final segment.
1459pub fn select_piecewise_segment(
1460    piecewise: &PiecewiseOrbit,
1461    epoch: CalendarEpoch,
1462) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1463    validate_piecewise_segments(piecewise)?;
1464    let e = calendar_seconds(epoch);
1465    if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1466        return Err(PiecewiseOrbitError::OutOfRange);
1467    }
1468
1469    let Some((last, rest)) = piecewise.segments.split_last() else {
1470        return Err(PiecewiseOrbitError::OutOfRange);
1471    };
1472
1473    for seg in rest {
1474        if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1475            return Ok(seg);
1476        }
1477    }
1478
1479    if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1480        Ok(last)
1481    } else {
1482        Err(PiecewiseOrbitError::OutOfRange)
1483    }
1484}
1485
1486fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1487    if piecewise.segment_s < 1 {
1488        return Err(PiecewiseOrbitError::InvalidSegment);
1489    }
1490    validate::require_strictly_increasing(
1491        piecewise
1492            .segments
1493            .iter()
1494            .map(|segment| calendar_seconds(segment.t0)),
1495        "piecewise.segments.t0",
1496    )
1497    .map_err(map_piecewise_order_error)?;
1498
1499    for segment in &piecewise.segments {
1500        validate::require_strictly_increasing(
1501            [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1502            "piecewise.segment bounds",
1503        )
1504        .map_err(map_piecewise_order_error)?;
1505    }
1506    Ok(())
1507}
1508
1509fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1510    let reason = match error {
1511        validate::FieldError::NonFinite { .. } => "must be finite",
1512        validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1513        _ => error.reason(),
1514    };
1515    PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1516}
1517
1518/// Evaluate a piecewise reduced orbit at `epoch`.
1519pub fn piecewise_position(
1520    piecewise: &PiecewiseOrbit,
1521    epoch: CalendarEpoch,
1522    scale: TimeScale,
1523    frame: Frame,
1524) -> Result<[f64; 3], PiecewiseOrbitError> {
1525    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1526    let seg = select_piecewise_segment(piecewise, epoch)?;
1527    position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1528}
1529
1530/// Evaluate piecewise reduced-orbit position and velocity at `epoch`.
1531pub fn piecewise_position_velocity(
1532    piecewise: &PiecewiseOrbit,
1533    epoch: CalendarEpoch,
1534    scale: TimeScale,
1535    frame: Frame,
1536) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1537    validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1538    let seg = select_piecewise_segment(piecewise, epoch)?;
1539    position_velocity(&seg.orbit.elements, epoch, scale, frame)
1540        .map_err(PiecewiseOrbitError::Reduced)
1541}
1542
1543/// Evaluate a piecewise model against truth ECEF samples.
1544///
1545/// Truth samples outside the model span are skipped, matching the source-backed
1546/// single-model drift behavior when product coverage clips a requested horizon.
1547pub fn piecewise_drift(
1548    piecewise: &PiecewiseOrbit,
1549    truth: &[EcefSample],
1550    scale: TimeScale,
1551    threshold_m: f64,
1552) -> Result<DriftReport, PiecewiseOrbitError> {
1553    validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1554    if truth.is_empty() {
1555        return Ok(DriftReport {
1556            per_epoch: Vec::new(),
1557            max_m: 0.0,
1558            rms_m: 0.0,
1559            threshold_horizon: None,
1560        });
1561    }
1562
1563    let mut per_epoch = Vec::with_capacity(truth.len());
1564    let mut sumsq = 0.0;
1565    let mut max_m = 0.0_f64;
1566    let mut threshold_horizon = None;
1567
1568    for s in truth {
1569        validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1570        let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1571            continue;
1572        };
1573        let dx = model[0] - s.x_m;
1574        let dy = model[1] - s.y_m;
1575        let dz = model[2] - s.z_m;
1576        let err = (dx * dx + dy * dy + dz * dz).sqrt();
1577        sumsq += err * err;
1578        if err > max_m {
1579            max_m = err;
1580        }
1581        if threshold_horizon.is_none() && err > threshold_m {
1582            threshold_horizon = Some(s.epoch);
1583        }
1584        per_epoch.push(DriftEntry {
1585            epoch: s.epoch,
1586            error_m: err,
1587        });
1588    }
1589
1590    if per_epoch.is_empty() {
1591        return Err(PiecewiseOrbitError::TooFewSamples {
1592            got: 0,
1593            required: 1,
1594        });
1595    }
1596
1597    let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1598    Ok(DriftReport {
1599        per_epoch,
1600        max_m,
1601        rms_m,
1602        threshold_horizon,
1603    })
1604}
1605
1606#[cfg(all(test, sidereon_repo_tests))]
1607mod tests;