Skip to main content

sidereon_core/
geodetic_time_series.rs

1//! Geodetic position time-series velocity, trajectory, step, and field tools.
2//!
3//! This module is sans-IO. Callers supply positions already decoded from their
4//! product format, with epochs in decimal years and coordinates in either local
5//! ENU metres or ITRF/ECEF metres.
6
7use core::fmt;
8
9use nalgebra::DMatrix;
10pub use trust_region_least_squares::loss::Loss;
11use trust_region_least_squares::model::{solve_model_with, ResidualModel};
12use trust_region_least_squares::trf::{TrfError, TrfOptions};
13
14use crate::astro::math::least_squares::{
15    covariance_from_jacobian, normal_covariance, singular_value_diagnostics,
16};
17use crate::astro::math::portable::{self, PortableNumerics};
18use crate::astro::math::robust::{median, RobustError};
19use crate::dop;
20use crate::estimation::{mad_spread, PrimitiveError};
21use crate::frame::{geodetic_to_itrf, Wgs84Geodetic};
22use crate::geometry_quality::{
23    classify, GeometryQuality, GeometryQualityThresholds, ObservabilityTier,
24};
25
26const DEFAULT_MIDAS_PERIOD_YEARS: f64 = 1.0;
27const DEFAULT_MIDAS_PERIOD_TOLERANCE_YEARS: f64 = 0.001;
28const DEFAULT_MIDAS_MIN_PAIRS: usize = 3;
29const DEFAULT_STEP_WINDOW_YEARS: f64 = 0.75;
30const DEFAULT_STEP_SCORE_THRESHOLD: f64 = 8.0;
31const DEFAULT_STEP_MIN_OFFSET_M: f64 = 1.0e-4;
32const DEFAULT_STEP_MIN_SAMPLES_EACH_SIDE: usize = 4;
33const DEFAULT_STEP_MIN_SEPARATION_YEARS: f64 = 0.25;
34const STEP_ZERO_OFFSET_TOLERANCE_M: f64 = 1.0e-12;
35const TAU: f64 = core::f64::consts::PI * 2.0;
36
37/// One position sample in a station time series.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct PositionSample {
40    /// Epoch expressed as a decimal year in a continuous time scale.
41    pub epoch_year: f64,
42    /// Position vector in metres, interpreted by [`PositionFrame`].
43    pub position_m: [f64; 3],
44    /// Optional 3x3 coordinate covariance in square metres, in the same frame
45    /// as [`position_m`](Self::position_m).
46    pub covariance_m2: Option<[[f64; 3]; 3]>,
47}
48
49/// Coordinate frame of the supplied position samples.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum PositionFrame {
52    /// Position vectors are local east, north, up coordinates in metres.
53    Enu,
54    /// Position vectors are ITRF/ECEF metres and are differenced from the
55    /// supplied reference before rotation into local ENU.
56    Ecef {
57        /// Geodetic reference position used to define the local ENU frame.
58        reference: Wgs84Geodetic,
59    },
60}
61
62/// Borrowed station time series.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct PositionSeries<'a> {
65    /// Frame and reference metadata for every sample in the series.
66    pub frame: PositionFrame,
67    /// Position samples. They may be unsorted; duplicate epochs are rejected.
68    pub samples: &'a [PositionSample],
69}
70
71/// Options for [`velocity_midas`].
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct MidasOptions {
74    /// Dominant period used for pair selection, in years.
75    pub dominant_period_years: f64,
76    /// Allowed absolute difference from the dominant period, in years, before
77    /// the selector falls back to the nearest later sample.
78    pub period_tolerance_years: f64,
79    /// Minimum retained pair count required for each component.
80    pub min_pairs: usize,
81}
82
83impl Default for MidasOptions {
84    fn default() -> Self {
85        Self {
86            dominant_period_years: DEFAULT_MIDAS_PERIOD_YEARS,
87            period_tolerance_years: DEFAULT_MIDAS_PERIOD_TOLERANCE_YEARS,
88            min_pairs: DEFAULT_MIDAS_MIN_PAIRS,
89        }
90    }
91}
92
93/// Qualitative strength of a time-series estimate.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum TimeSeriesQuality {
96    /// The series has at least one dominant period of span and enough selected
97    /// pairs for the requested estimator.
98    Nominal,
99    /// The estimate is usable but has less than three dominant periods of span,
100    /// so MIDAS does not have its single-step robustness guarantee.
101    ShortSpan,
102}
103
104/// MIDAS diagnostics for one ENU component.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct MidasComponentStats {
107    /// Pair slopes selected before the MIDAS two-sigma trim.
108    pub pair_count: usize,
109    /// Pair slopes retained after the MIDAS two-sigma trim.
110    pub retained_pair_count: usize,
111    /// Robust standard deviation of retained pair slopes in metres per year.
112    pub slope_sigma_m_per_yr: f64,
113    /// Effective number of independent slope samples, `retained_pair_count / 4`.
114    pub effective_pair_count: f64,
115}
116
117/// Robust ENU velocity estimate.
118#[derive(Debug, Clone, PartialEq)]
119pub struct Velocity {
120    /// Velocity components `[east, north, up]` in metres per year.
121    pub rate_enu_m_per_yr: [f64; 3],
122    /// One-sigma MIDAS uncertainties `[east, north, up]` in metres per year.
123    pub sigma_enu_m_per_yr: [f64; 3],
124    /// Diagonal ENU velocity covariance in square metres per square year.
125    pub covariance_enu_m2_per_yr2: [[f64; 3]; 3],
126    /// Per-component MIDAS slope statistics.
127    pub component_stats: [MidasComponentStats; 3],
128    /// Number of position samples accepted after sorting and validation.
129    pub sample_count: usize,
130    /// Series span in years.
131    pub span_years: f64,
132    /// Strength flag for the estimate.
133    pub quality: TimeSeriesQuality,
134}
135
136/// Trajectory model terms used by [`fit_trajectory`].
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum TrajectoryTerm {
139    /// Position at the reference epoch, metres.
140    Position,
141    /// Linear velocity, metres per year.
142    Velocity,
143    /// Annual sine coefficient, metres.
144    AnnualSin,
145    /// Annual cosine coefficient, metres.
146    AnnualCos,
147    /// Semiannual sine coefficient, metres.
148    SemiannualSin,
149    /// Semiannual cosine coefficient, metres.
150    SemiannualCos,
151    /// Heaviside offset coefficient, metres.
152    Offset {
153        /// Offset index in [`TrajectoryModel::offset_epochs_year`].
154        index: usize,
155        /// Offset epoch in decimal years.
156        epoch_year: f64,
157    },
158}
159
160/// Linear trajectory model shape for [`fit_trajectory`].
161#[derive(Debug, Clone, PartialEq)]
162pub struct TrajectoryModel {
163    /// Optional reference epoch. When `None`, the mean sample epoch is used.
164    pub reference_epoch_year: Option<f64>,
165    /// Include annual sine and cosine terms.
166    pub include_annual: bool,
167    /// Include semiannual sine and cosine terms.
168    pub include_semiannual: bool,
169    /// Known offset epochs modeled with a Heaviside step.
170    pub offset_epochs_year: Vec<f64>,
171}
172
173impl Default for TrajectoryModel {
174    fn default() -> Self {
175        Self {
176            reference_epoch_year: None,
177            include_annual: true,
178            include_semiannual: true,
179            offset_epochs_year: Vec::new(),
180        }
181    }
182}
183
184/// Least-squares controls for [`fit_trajectory`].
185#[derive(Debug, Clone, Copy, PartialEq)]
186pub struct TrajectoryFitOptions {
187    /// Robust loss passed to the trust-region least-squares solver.
188    pub loss: Loss,
189    /// Robust-loss scale in metres. Ignored when [`Loss::Linear`] is selected.
190    pub f_scale_m: f64,
191    /// Optional maximum residual evaluations for the trust-region solve.
192    pub max_nfev: Option<usize>,
193}
194
195impl Default for TrajectoryFitOptions {
196    fn default() -> Self {
197        Self {
198            loss: Loss::Linear,
199            f_scale_m: 1.0,
200            max_nfev: None,
201        }
202    }
203}
204
205/// Fitted trajectory coefficients for one ENU component.
206#[derive(Debug, Clone, PartialEq)]
207pub struct TrajectoryComponent {
208    /// Position at the reference epoch, metres.
209    pub position_m: f64,
210    /// Linear velocity in metres per year.
211    pub velocity_m_per_yr: f64,
212    /// Annual sine coefficient in metres, or `None` when the term is omitted.
213    pub annual_sin_m: Option<f64>,
214    /// Annual cosine coefficient in metres, or `None` when the term is omitted.
215    pub annual_cos_m: Option<f64>,
216    /// Semiannual sine coefficient in metres, or `None` when the term is
217    /// omitted.
218    pub semiannual_sin_m: Option<f64>,
219    /// Semiannual cosine coefficient in metres, or `None` when the term is
220    /// omitted.
221    pub semiannual_cos_m: Option<f64>,
222    /// Heaviside offset coefficients in metres, ordered like the model offsets.
223    pub offsets_m: Vec<f64>,
224}
225
226/// Trajectory least-squares result.
227#[derive(Debug, Clone, PartialEq)]
228pub struct Trajectory {
229    /// Reference epoch used for the position parameter and harmonic phases.
230    pub reference_epoch_year: f64,
231    /// Parameter terms within each component block.
232    pub terms: Vec<TrajectoryTerm>,
233    /// ENU component coefficients, ordered `[east, north, up]`.
234    pub components: [TrajectoryComponent; 3],
235    /// Full parameter covariance in solver order. The order is all terms for
236    /// east, then all terms for north, then all terms for up.
237    pub parameter_covariance: Vec<Vec<f64>>,
238    /// Root-mean-square residuals `[east, north, up]` in metres.
239    pub residual_rms_enu_m: [f64; 3],
240    /// Design observability and covariance-validation diagnostics.
241    pub geometry_quality: GeometryQuality,
242    /// Trust-region termination status.
243    pub status: i32,
244    /// Residual evaluations used by the solver.
245    pub nfev: usize,
246    /// Jacobian evaluations used by the solver.
247    pub njev: usize,
248    /// Final least-squares cost.
249    pub cost: f64,
250    /// Infinity norm of the final gradient.
251    pub optimality: f64,
252}
253
254/// Controls for [`detect_steps`].
255#[derive(Debug, Clone, Copy, PartialEq)]
256pub struct StepDetectionOptions {
257    /// Half-window around a candidate epoch, in years.
258    pub window_years: f64,
259    /// Minimum robust normalized offset score to report.
260    pub score_threshold: f64,
261    /// Minimum three-dimensional offset norm in metres to report.
262    pub min_offset_m: f64,
263    /// Minimum number of samples required on each side of a candidate.
264    pub min_samples_each_side: usize,
265    /// Minimum separation retained between reported candidates, in years.
266    pub min_separation_years: f64,
267    /// MIDAS controls used to detrend the series before scoring steps.
268    pub midas: MidasOptions,
269}
270
271impl Default for StepDetectionOptions {
272    fn default() -> Self {
273        Self {
274            window_years: DEFAULT_STEP_WINDOW_YEARS,
275            score_threshold: DEFAULT_STEP_SCORE_THRESHOLD,
276            min_offset_m: DEFAULT_STEP_MIN_OFFSET_M,
277            min_samples_each_side: DEFAULT_STEP_MIN_SAMPLES_EACH_SIDE,
278            min_separation_years: DEFAULT_STEP_MIN_SEPARATION_YEARS,
279            midas: MidasOptions::default(),
280        }
281    }
282}
283
284/// Heuristic used to generate a step candidate.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum StepDetectionHeuristic {
287    /// Difference of pre-event and post-event residual medians after MIDAS
288    /// detrending, scored by robust local spread.
289    DetrendedSlidingMedian,
290}
291
292/// Candidate displacement step.
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct StepCandidate {
295    /// Candidate step epoch in decimal years.
296    pub epoch_year: f64,
297    /// Estimated offset `[east, north, up]` in metres, after minus before.
298    pub offset_enu_m: [f64; 3],
299    /// Robust normalized offset score. Larger means more step-like.
300    pub score: f64,
301    /// Number of samples before the candidate used by the score.
302    pub before_count: usize,
303    /// Number of samples after the candidate used by the score.
304    pub after_count: usize,
305    /// Explicit label that this diagnostic is a heuristic.
306    pub heuristic: StepDetectionHeuristic,
307}
308
309/// Network field frame and filtering controls.
310#[derive(Debug, Clone, Copy, PartialEq)]
311pub struct NetworkFrame {
312    /// Geodetic origin defining the output local ENU frame.
313    pub origin: Wgs84Geodetic,
314    /// Remove the unweighted mean velocity across stations in the output frame.
315    pub remove_common_mode: bool,
316}
317
318/// Station input for [`network_field`].
319#[derive(Debug, Clone, Copy, PartialEq)]
320pub struct NetworkStation<'a> {
321    /// Caller-provided station identifier copied into the output.
322    pub id: &'a str,
323    /// Station reference position used to rotate a local ENU velocity into ECEF.
324    pub reference: Wgs84Geodetic,
325    /// Station position time series.
326    pub series: PositionSeries<'a>,
327}
328
329/// One station motion in a network field.
330#[derive(Debug, Clone, PartialEq)]
331pub struct StationMotion {
332    /// Station identifier copied from [`NetworkStation::id`].
333    pub id: String,
334    /// Velocity in the network frame after optional common-mode removal.
335    pub rate_enu_m_per_yr: [f64; 3],
336    /// Velocity in the network frame before common-mode removal.
337    pub raw_rate_enu_m_per_yr: [f64; 3],
338    /// One-sigma uncertainty in the network frame, square-rooted by component.
339    pub sigma_enu_m_per_yr: [f64; 3],
340    /// Station-local MIDAS velocity before rotation into the network frame.
341    pub local_velocity: Velocity,
342}
343
344/// Network motion field.
345#[derive(Debug, Clone, PartialEq)]
346pub struct MotionField {
347    /// Output frame and filtering controls used for this field.
348    pub frame: NetworkFrame,
349    /// Station motions in the same order as the accepted inputs.
350    pub stations: Vec<StationMotion>,
351    /// Unweighted mean velocity removed from station rates, or zero when
352    /// common-mode removal is disabled.
353    pub common_mode_enu_m_per_yr: [f64; 3],
354}
355
356/// Error returned by geodetic time-series estimators.
357#[derive(Debug, Clone, PartialEq, thiserror::Error)]
358pub enum GeodeticTimeSeriesError {
359    /// A boundary input was malformed.
360    #[error("invalid geodetic time-series input {field}: {reason}")]
361    InvalidInput {
362        /// Name of the malformed field.
363        field: &'static str,
364        /// Stable validation reason.
365        reason: &'static str,
366    },
367    /// There are fewer position samples than the estimator requires.
368    #[error("geodetic time series has {samples} samples; need at least {needed}")]
369    TooFewSamples {
370        /// Number of supplied samples.
371        samples: usize,
372        /// Minimum sample count required.
373        needed: usize,
374    },
375    /// MIDAS could not select enough usable dominant-period pairs.
376    #[error("geodetic time series has {pairs} usable pairs; need at least {needed}")]
377    InsufficientPairs {
378        /// Number of selected or retained pairs.
379        pairs: usize,
380        /// Minimum pair count required.
381        needed: usize,
382    },
383    /// The trajectory design matrix is rank deficient.
384    #[error("trajectory design is rank deficient")]
385    SingularTrajectory,
386    /// The trust-region least-squares solver exhausted or stopped without
387    /// satisfying a convergence condition.
388    #[error("trajectory solver did not converge, status {status}")]
389    DidNotConverge {
390        /// Trust-region status code.
391        status: i32,
392    },
393    /// The trust-region least-squares solver failed.
394    #[error("trajectory solver failed: {0}")]
395    Solver(TrfError),
396}
397
398impl From<TrfError> for GeodeticTimeSeriesError {
399    fn from(value: TrfError) -> Self {
400        Self::Solver(value)
401    }
402}
403
404impl From<PrimitiveError> for GeodeticTimeSeriesError {
405    fn from(value: PrimitiveError) -> Self {
406        match value {
407            PrimitiveError::InvalidInput { field, reason } => Self::InvalidInput { field, reason },
408        }
409    }
410}
411
412impl From<RobustError> for GeodeticTimeSeriesError {
413    fn from(value: RobustError) -> Self {
414        match value {
415            RobustError::InvalidInput { field, reason } => Self::InvalidInput { field, reason },
416        }
417    }
418}
419
420#[derive(Debug, Clone)]
421struct PreparedSample {
422    epoch_year: f64,
423    enu_m: [f64; 3],
424    covariance_enu_m2: Option<[[f64; 3]; 3]>,
425}
426
427#[derive(Debug, Clone, Copy)]
428struct Pair {
429    first: usize,
430    second: usize,
431}
432
433/// Estimate robust station velocity with the MIDAS median interannual
434/// difference adjusted for skewness estimator.
435///
436/// Pair slopes are selected near [`MidasOptions::dominant_period_years`]. The
437/// component estimate is the median slope, followed by one two-sigma robust MAD
438/// trim and a recomputed median. The reported uncertainty is the MIDAS scaled
439/// median standard error using `retained_pair_count / 4` independent slopes.
440pub fn velocity_midas(
441    series: &PositionSeries<'_>,
442    options: MidasOptions,
443) -> Result<Velocity, GeodeticTimeSeriesError> {
444    validate_midas_options(options)?;
445    let samples = prepare_samples(series)?;
446    if samples.len() < 2 {
447        return Err(GeodeticTimeSeriesError::TooFewSamples {
448            samples: samples.len(),
449            needed: 2,
450        });
451    }
452    let span_years = samples.last().expect("checked nonempty").epoch_year
453        - samples.first().expect("checked nonempty").epoch_year;
454    if span_years < options.dominant_period_years {
455        return Err(GeodeticTimeSeriesError::InsufficientPairs {
456            pairs: 0,
457            needed: options.min_pairs,
458        });
459    }
460
461    let pairs = select_midas_pairs(&samples, options);
462    if pairs.len() < options.min_pairs {
463        return Err(GeodeticTimeSeriesError::InsufficientPairs {
464            pairs: pairs.len(),
465            needed: options.min_pairs,
466        });
467    }
468
469    let mut rate = [0.0; 3];
470    let mut sigma = [0.0; 3];
471    let mut covariance = [[0.0; 3]; 3];
472    let mut stats = [MidasComponentStats {
473        pair_count: 0,
474        retained_pair_count: 0,
475        slope_sigma_m_per_yr: 0.0,
476        effective_pair_count: 0.0,
477    }; 3];
478
479    for axis in 0..3 {
480        let component = midas_component(&samples, &pairs, axis, options.min_pairs)?;
481        rate[axis] = component.0;
482        sigma[axis] = component.1;
483        covariance[axis][axis] = component.1 * component.1;
484        stats[axis] = component.2;
485    }
486
487    Ok(Velocity {
488        rate_enu_m_per_yr: rate,
489        sigma_enu_m_per_yr: sigma,
490        covariance_enu_m2_per_yr2: covariance,
491        component_stats: stats,
492        sample_count: samples.len(),
493        span_years,
494        quality: if span_years < 3.0 * options.dominant_period_years {
495            TimeSeriesQuality::ShortSpan
496        } else {
497            TimeSeriesQuality::Nominal
498        },
499    })
500}
501
502/// Fit a linear geodetic trajectory model over the workspace trust-region
503/// least-squares solver.
504///
505/// The model is linear in its coefficients: position at reference epoch,
506/// velocity, optional annual and semiannual sine/cosine terms, and known
507/// Heaviside offsets. The state order is component-major: all terms for east,
508/// all terms for north, then all terms for up.
509pub fn fit_trajectory(
510    series: &PositionSeries<'_>,
511    model: &TrajectoryModel,
512    options: TrajectoryFitOptions,
513) -> Result<Trajectory, GeodeticTimeSeriesError> {
514    validate_fit_options(options)?;
515    let samples = prepare_samples(series)?;
516    if samples.is_empty() {
517        return Err(GeodeticTimeSeriesError::TooFewSamples {
518            samples: 0,
519            needed: 1,
520        });
521    }
522    validate_model(model)?;
523    let reference_epoch_year = model
524        .reference_epoch_year
525        .unwrap_or_else(|| mean_epoch(&samples));
526    let terms = trajectory_terms(model);
527    let terms_per_axis = terms.len();
528    let n_params = terms_per_axis * 3;
529    let m_residuals = samples.len() * 3;
530    if m_residuals < n_params {
531        return Err(GeodeticTimeSeriesError::TooFewSamples {
532            samples: samples.len(),
533            needed: n_params.div_ceil(3),
534        });
535    }
536
537    let problem = TrajectoryProblem {
538        samples: &samples,
539        terms: &terms,
540        reference_epoch_year,
541    };
542    let x0 = trajectory_initial_guess(&samples, &terms, reference_epoch_year);
543    let mut solver_options = TrfOptions {
544        loss: options.loss,
545        f_scale: options.f_scale_m,
546        max_nfev: options.max_nfev,
547        ..TrfOptions::default()
548    };
549    if solver_options.max_nfev.is_none() {
550        solver_options.max_nfev = Some(100 * n_params.max(1));
551    }
552    let solved = solve_model_with(&problem, &x0, &PortableNumerics, &solver_options)?;
553    if !solved.success() {
554        return Err(GeodeticTimeSeriesError::DidNotConverge {
555            status: solved.status,
556        });
557    }
558
559    let jacobian = DMatrix::from_row_slice(m_residuals, n_params, &solved.jac);
560    let covariance = covariance_from_jacobian(&jacobian, solved.cost)
561        .map_err(|_| GeodeticTimeSeriesError::SingularTrajectory)?;
562    let geometry_quality = trajectory_geometry_quality(&jacobian);
563    if geometry_quality.tier == ObservabilityTier::RankDeficient {
564        return Err(GeodeticTimeSeriesError::SingularTrajectory);
565    }
566
567    let components = [
568        trajectory_component(&solved.x, 0, &terms),
569        trajectory_component(&solved.x, 1, &terms),
570        trajectory_component(&solved.x, 2, &terms),
571    ];
572    let residual_rms_enu_m = residual_rms(&solved.fun);
573
574    Ok(Trajectory {
575        reference_epoch_year,
576        terms,
577        components,
578        parameter_covariance: matrix_to_vecs(&covariance),
579        residual_rms_enu_m,
580        geometry_quality,
581        status: solved.status,
582        nfev: solved.nfev,
583        njev: solved.njev,
584        cost: solved.cost,
585        optimality: solved.optimality,
586    })
587}
588
589/// Detect candidate displacement steps with a labelled heuristic.
590///
591/// The series is first detrended with [`velocity_midas`]. Each candidate split
592/// is scored from the difference of local pre-event and post-event residual
593/// medians divided by robust local scatter. Reported candidates are proposals
594/// only; they are not inserted into a trajectory model.
595pub fn detect_steps(
596    series: &PositionSeries<'_>,
597    options: StepDetectionOptions,
598) -> Result<Vec<StepCandidate>, GeodeticTimeSeriesError> {
599    validate_step_options(options)?;
600    let samples = prepare_samples(series)?;
601    if samples.len() < options.min_samples_each_side * 2 {
602        return Err(GeodeticTimeSeriesError::TooFewSamples {
603            samples: samples.len(),
604            needed: options.min_samples_each_side * 2,
605        });
606    }
607    let velocity = velocity_midas(series, options.midas)?;
608    let reference_epoch_year = samples[0].epoch_year;
609    let residuals = samples
610        .iter()
611        .map(|sample| {
612            let dt = sample.epoch_year - reference_epoch_year;
613            [
614                sample.enu_m[0] - velocity.rate_enu_m_per_yr[0] * dt,
615                sample.enu_m[1] - velocity.rate_enu_m_per_yr[1] * dt,
616                sample.enu_m[2] - velocity.rate_enu_m_per_yr[2] * dt,
617            ]
618        })
619        .collect::<Vec<_>>();
620
621    let mut candidates = Vec::new();
622    for split in options.min_samples_each_side..=(samples.len() - options.min_samples_each_side) {
623        let epoch = samples[split].epoch_year;
624        let before = window_indices(&samples, 0, split, epoch, options.window_years);
625        let after = window_indices(&samples, split, samples.len(), epoch, options.window_years);
626        if before.len() < options.min_samples_each_side
627            || after.len() < options.min_samples_each_side
628        {
629            continue;
630        }
631        let (offset, score) = step_score(&residuals, &before, &after)?;
632        let offset_norm =
633            (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2]).sqrt();
634        if score >= options.score_threshold && offset_norm >= options.min_offset_m {
635            candidates.push(StepCandidate {
636                epoch_year: epoch,
637                offset_enu_m: offset,
638                score,
639                before_count: before.len(),
640                after_count: after.len(),
641                heuristic: StepDetectionHeuristic::DetrendedSlidingMedian,
642            });
643        }
644    }
645    candidates.sort_by(|a, b| b.score.total_cmp(&a.score));
646    let mut retained: Vec<StepCandidate> = Vec::new();
647    for candidate in candidates {
648        if retained.iter().all(|kept| {
649            (kept.epoch_year - candidate.epoch_year).abs() >= options.min_separation_years
650        }) {
651            retained.push(candidate);
652        }
653    }
654    retained.sort_by(|a, b| a.epoch_year.total_cmp(&b.epoch_year));
655    Ok(retained)
656}
657
658/// Estimate a network motion field in one local ENU frame.
659///
660/// Each station is solved independently with [`velocity_midas`], rotated from
661/// station-local ENU to ECEF, then into the network frame. If requested, the
662/// unweighted mean network-frame velocity is subtracted from every station.
663pub fn network_field(
664    stations: &[NetworkStation<'_>],
665    frame: NetworkFrame,
666) -> Result<MotionField, GeodeticTimeSeriesError> {
667    validate_geodetic(frame.origin, "frame.origin")?;
668    if stations.is_empty() {
669        return Err(GeodeticTimeSeriesError::TooFewSamples {
670            samples: 0,
671            needed: 1,
672        });
673    }
674    let origin_rotation = dop::ecef_to_enu_rotation(frame.origin.lat_rad, frame.origin.lon_rad);
675    let mut motions = Vec::with_capacity(stations.len());
676    for station in stations {
677        validate_geodetic(station.reference, "station.reference")?;
678        if station.id.is_empty() {
679            return Err(invalid_input("station.id", "empty"));
680        }
681        let local_velocity = velocity_midas(&station.series, MidasOptions::default())?;
682        let station_rotation =
683            dop::ecef_to_enu_rotation(station.reference.lat_rad, station.reference.lon_rad);
684        let ecef_rate = enu_to_ecef(&station_rotation, local_velocity.rate_enu_m_per_yr);
685        let raw_rate = mat3_vec(&origin_rotation, ecef_rate);
686        let covariance_network = rotate_velocity_covariance(
687            &origin_rotation,
688            &station_rotation,
689            local_velocity.covariance_enu_m2_per_yr2,
690        );
691        let sigma = [
692            covariance_network[0][0].max(0.0).sqrt(),
693            covariance_network[1][1].max(0.0).sqrt(),
694            covariance_network[2][2].max(0.0).sqrt(),
695        ];
696        motions.push(StationMotion {
697            id: station.id.to_string(),
698            rate_enu_m_per_yr: raw_rate,
699            raw_rate_enu_m_per_yr: raw_rate,
700            sigma_enu_m_per_yr: sigma,
701            local_velocity,
702        });
703    }
704
705    let common_mode = if frame.remove_common_mode {
706        let mut sum = [0.0; 3];
707        for motion in &motions {
708            for (axis, value) in sum.iter_mut().enumerate() {
709                *value += motion.raw_rate_enu_m_per_yr[axis];
710            }
711        }
712        let scale = 1.0 / motions.len() as f64;
713        [sum[0] * scale, sum[1] * scale, sum[2] * scale]
714    } else {
715        [0.0; 3]
716    };
717    if frame.remove_common_mode {
718        for motion in &mut motions {
719            for (axis, value) in motion.rate_enu_m_per_yr.iter_mut().enumerate() {
720                *value -= common_mode[axis];
721            }
722        }
723    }
724
725    Ok(MotionField {
726        frame,
727        stations: motions,
728        common_mode_enu_m_per_yr: common_mode,
729    })
730}
731
732fn validate_midas_options(options: MidasOptions) -> Result<(), GeodeticTimeSeriesError> {
733    validate_positive(options.dominant_period_years, "dominant_period_years")?;
734    validate_nonnegative(options.period_tolerance_years, "period_tolerance_years")?;
735    if options.min_pairs == 0 {
736        return Err(invalid_input("min_pairs", "must be positive"));
737    }
738    Ok(())
739}
740
741fn validate_fit_options(options: TrajectoryFitOptions) -> Result<(), GeodeticTimeSeriesError> {
742    if options.loss != Loss::Linear {
743        validate_positive(options.f_scale_m, "f_scale_m")?;
744    } else {
745        validate_finite(options.f_scale_m, "f_scale_m")?;
746    }
747    if options.max_nfev == Some(0) {
748        return Err(invalid_input("max_nfev", "must be positive"));
749    }
750    Ok(())
751}
752
753fn validate_step_options(options: StepDetectionOptions) -> Result<(), GeodeticTimeSeriesError> {
754    validate_midas_options(options.midas)?;
755    validate_positive(options.window_years, "window_years")?;
756    validate_positive(options.score_threshold, "score_threshold")?;
757    validate_nonnegative(options.min_offset_m, "min_offset_m")?;
758    validate_nonnegative(options.min_separation_years, "min_separation_years")?;
759    if options.min_samples_each_side == 0 {
760        return Err(invalid_input("min_samples_each_side", "must be positive"));
761    }
762    Ok(())
763}
764
765fn validate_model(model: &TrajectoryModel) -> Result<(), GeodeticTimeSeriesError> {
766    if let Some(reference) = model.reference_epoch_year {
767        validate_finite(reference, "reference_epoch_year")?;
768    }
769    for epoch in &model.offset_epochs_year {
770        validate_finite(*epoch, "offset_epochs_year")?;
771    }
772    Ok(())
773}
774
775fn prepare_samples(
776    series: &PositionSeries<'_>,
777) -> Result<Vec<PreparedSample>, GeodeticTimeSeriesError> {
778    if series.samples.is_empty() {
779        return Err(GeodeticTimeSeriesError::TooFewSamples {
780            samples: 0,
781            needed: 1,
782        });
783    }
784    let (reference_ecef_m, rotation) = match series.frame {
785        PositionFrame::Enu => (None, None),
786        PositionFrame::Ecef { reference } => {
787            validate_geodetic(reference, "reference")?;
788            let ecef = geodetic_to_itrf(reference)
789                .map_err(|_| invalid_input("reference", "ECEF conversion failed"))?;
790            (
791                Some(ecef.as_array()),
792                Some(dop::ecef_to_enu_rotation(
793                    reference.lat_rad,
794                    reference.lon_rad,
795                )),
796            )
797        }
798    };
799
800    let mut samples = Vec::with_capacity(series.samples.len());
801    for sample in series.samples {
802        validate_finite(sample.epoch_year, "epoch_year")?;
803        validate_vec3(sample.position_m, "position_m")?;
804        let (enu_m, covariance_enu_m2) = match series.frame {
805            PositionFrame::Enu => {
806                let covariance = match sample.covariance_m2 {
807                    Some(covariance) => {
808                        validate_covariance(covariance, "covariance_m2")?;
809                        Some(covariance)
810                    }
811                    None => None,
812                };
813                (sample.position_m, covariance)
814            }
815            PositionFrame::Ecef { .. } => {
816                let reference = reference_ecef_m.expect("ECEF reference exists");
817                let rotation = rotation.expect("ECEF rotation exists");
818                let delta = [
819                    sample.position_m[0] - reference[0],
820                    sample.position_m[1] - reference[1],
821                    sample.position_m[2] - reference[2],
822                ];
823                let covariance = match sample.covariance_m2 {
824                    Some(covariance) => {
825                        validate_covariance(covariance, "covariance_m2")?;
826                        let rotated = rotate_covariance(&rotation, covariance);
827                        validate_covariance_diagonal(rotated, "covariance_m2")?;
828                        Some(rotated)
829                    }
830                    None => None,
831                };
832                (mat3_vec(&rotation, delta), covariance)
833            }
834        };
835        samples.push(PreparedSample {
836            epoch_year: sample.epoch_year,
837            enu_m,
838            covariance_enu_m2,
839        });
840    }
841    samples.sort_by(|a, b| a.epoch_year.total_cmp(&b.epoch_year));
842    for pair in samples.windows(2) {
843        if pair[0].epoch_year == pair[1].epoch_year {
844            return Err(invalid_input("epoch_year", "duplicate"));
845        }
846    }
847    Ok(samples)
848}
849
850fn select_midas_pairs(samples: &[PreparedSample], options: MidasOptions) -> Vec<Pair> {
851    let mut pairs = Vec::new();
852    select_midas_pairs_forward(samples, options, &mut pairs);
853    let reversed = samples.iter().rev().cloned().collect::<Vec<_>>();
854    let mut reverse_pairs = Vec::new();
855    select_midas_pairs_forward(&reversed, options, &mut reverse_pairs);
856    let n = samples.len();
857    for pair in reverse_pairs {
858        let first = n - 1 - pair.second;
859        let second = n - 1 - pair.first;
860        pairs.push(Pair { first, second });
861    }
862    pairs.sort_by_key(|pair| (pair.first, pair.second));
863    pairs.dedup_by_key(|pair| (pair.first, pair.second));
864    pairs
865}
866
867fn select_midas_pairs_forward(
868    samples: &[PreparedSample],
869    options: MidasOptions,
870    pairs: &mut Vec<Pair>,
871) {
872    for first in 0..samples.len() {
873        let mut best: Option<(usize, f64, bool)> = None;
874        for second in (first + 1)..samples.len() {
875            let dt = samples[second].epoch_year - samples[first].epoch_year;
876            if dt <= 0.0 {
877                continue;
878            }
879            let distance = (dt - options.dominant_period_years).abs();
880            let in_window = distance <= options.period_tolerance_years;
881            if dt < options.dominant_period_years - options.period_tolerance_years {
882                continue;
883            }
884            match best {
885                None => best = Some((second, distance, in_window)),
886                Some((_, best_distance, best_in_window)) => {
887                    let better = if in_window != best_in_window {
888                        in_window
889                    } else {
890                        distance < best_distance
891                    };
892                    if better {
893                        best = Some((second, distance, in_window));
894                    }
895                }
896            }
897            if in_window && distance == 0.0 {
898                break;
899            }
900            if dt > options.dominant_period_years + options.period_tolerance_years
901                && best.map(|(_, _, in_window)| in_window).unwrap_or(false)
902            {
903                break;
904            }
905        }
906        if let Some((second, _, _)) = best {
907            pairs.push(Pair { first, second });
908        }
909    }
910}
911
912fn midas_component(
913    samples: &[PreparedSample],
914    pairs: &[Pair],
915    axis: usize,
916    min_pairs: usize,
917) -> Result<(f64, f64, MidasComponentStats), GeodeticTimeSeriesError> {
918    let slopes = pairs
919        .iter()
920        .map(|pair| {
921            let first = &samples[pair.first];
922            let second = &samples[pair.second];
923            (second.enu_m[axis] - first.enu_m[axis]) / (second.epoch_year - first.epoch_year)
924        })
925        .collect::<Vec<_>>();
926    if slopes.len() < min_pairs {
927        return Err(GeodeticTimeSeriesError::InsufficientPairs {
928            pairs: slopes.len(),
929            needed: min_pairs,
930        });
931    }
932    let initial_median = median(&slopes)?;
933    let initial_sigma = mad_spread(&slopes, 0.0)?;
934    let retained = slopes
935        .iter()
936        .copied()
937        .filter(|slope| {
938            let deviation = (*slope - initial_median).abs();
939            if initial_sigma == 0.0 {
940                deviation == 0.0
941            } else {
942                deviation < 2.0 * initial_sigma
943            }
944        })
945        .collect::<Vec<_>>();
946    if retained.len() < min_pairs {
947        return Err(GeodeticTimeSeriesError::InsufficientPairs {
948            pairs: retained.len(),
949            needed: min_pairs,
950        });
951    }
952    let final_median = median(&retained)?;
953    let final_sigma = mad_spread(&retained, 0.0)?;
954    let effective_pair_count = retained.len() as f64 / 4.0;
955    let uncertainty =
956        3.0 * (core::f64::consts::PI / 2.0).sqrt() * final_sigma / effective_pair_count.sqrt();
957    Ok((
958        final_median,
959        uncertainty,
960        MidasComponentStats {
961            pair_count: slopes.len(),
962            retained_pair_count: retained.len(),
963            slope_sigma_m_per_yr: final_sigma,
964            effective_pair_count,
965        },
966    ))
967}
968
969fn trajectory_terms(model: &TrajectoryModel) -> Vec<TrajectoryTerm> {
970    let mut terms = vec![TrajectoryTerm::Position, TrajectoryTerm::Velocity];
971    if model.include_annual {
972        terms.push(TrajectoryTerm::AnnualSin);
973        terms.push(TrajectoryTerm::AnnualCos);
974    }
975    if model.include_semiannual {
976        terms.push(TrajectoryTerm::SemiannualSin);
977        terms.push(TrajectoryTerm::SemiannualCos);
978    }
979    for (index, &epoch_year) in model.offset_epochs_year.iter().enumerate() {
980        terms.push(TrajectoryTerm::Offset { index, epoch_year });
981    }
982    terms
983}
984
985fn basis_value(term: TrajectoryTerm, epoch_year: f64, reference_epoch_year: f64) -> f64 {
986    let dt = epoch_year - reference_epoch_year;
987    match term {
988        TrajectoryTerm::Position => 1.0,
989        TrajectoryTerm::Velocity => dt,
990        TrajectoryTerm::AnnualSin => libm::sin(TAU * dt),
991        TrajectoryTerm::AnnualCos => libm::cos(TAU * dt),
992        TrajectoryTerm::SemiannualSin => libm::sin(2.0 * TAU * dt),
993        TrajectoryTerm::SemiannualCos => libm::cos(2.0 * TAU * dt),
994        TrajectoryTerm::Offset { epoch_year, .. } => {
995            let step_dt = epoch_year - reference_epoch_year;
996            if dt > step_dt {
997                1.0
998            } else if dt == step_dt {
999                0.5
1000            } else {
1001                0.0
1002            }
1003        }
1004    }
1005}
1006
1007fn trajectory_initial_guess(
1008    samples: &[PreparedSample],
1009    terms: &[TrajectoryTerm],
1010    reference_epoch_year: f64,
1011) -> Vec<f64> {
1012    let mut x0 = vec![0.0; terms.len() * 3];
1013    let first = samples.first().expect("nonempty samples");
1014    let last = samples.last().expect("nonempty samples");
1015    let span = (last.epoch_year - first.epoch_year).max(f64::MIN_POSITIVE);
1016    for axis in 0..3 {
1017        let base = axis * terms.len();
1018        let rate = (last.enu_m[axis] - first.enu_m[axis]) / span;
1019        for (term_index, term) in terms.iter().enumerate() {
1020            x0[base + term_index] = match term {
1021                TrajectoryTerm::Position => {
1022                    first.enu_m[axis] + rate * (reference_epoch_year - first.epoch_year)
1023                }
1024                TrajectoryTerm::Velocity => rate,
1025                _ => 0.0,
1026            };
1027        }
1028    }
1029    x0
1030}
1031
1032struct TrajectoryProblem<'a> {
1033    samples: &'a [PreparedSample],
1034    terms: &'a [TrajectoryTerm],
1035    reference_epoch_year: f64,
1036}
1037
1038impl ResidualModel for TrajectoryProblem<'_> {
1039    fn residual(&self, x: &[f64], out: &mut Vec<f64>) {
1040        out.clear();
1041        let terms_per_axis = self.terms.len();
1042        for sample in self.samples {
1043            for axis in 0..3 {
1044                let base = axis * terms_per_axis;
1045                let mut predicted = 0.0;
1046                for (term_index, &term) in self.terms.iter().enumerate() {
1047                    predicted += x[base + term_index]
1048                        * basis_value(term, sample.epoch_year, self.reference_epoch_year);
1049                }
1050                let residual = predicted - sample.enu_m[axis];
1051                out.push(residual * sqrt_weight(sample, axis));
1052            }
1053        }
1054    }
1055
1056    fn jacobian(&self, _x: &[f64], _f0: &[f64], out: &mut Vec<f64>) {
1057        out.clear();
1058        let terms_per_axis = self.terms.len();
1059        let n = terms_per_axis * 3;
1060        out.resize(self.samples.len() * 3 * n, 0.0);
1061        for (sample_index, sample) in self.samples.iter().enumerate() {
1062            for axis in 0..3 {
1063                let row = sample_index * 3 + axis;
1064                let base = axis * terms_per_axis;
1065                let weight = sqrt_weight(sample, axis);
1066                for (term_index, &term) in self.terms.iter().enumerate() {
1067                    out[row * n + base + term_index] =
1068                        basis_value(term, sample.epoch_year, self.reference_epoch_year) * weight;
1069                }
1070            }
1071        }
1072    }
1073}
1074
1075fn sqrt_weight(sample: &PreparedSample, axis: usize) -> f64 {
1076    match sample.covariance_enu_m2 {
1077        Some(covariance) => {
1078            let variance = covariance[axis][axis];
1079            if variance > 0.0 {
1080                variance.sqrt().recip()
1081            } else {
1082                1.0
1083            }
1084        }
1085        None => 1.0,
1086    }
1087}
1088
1089fn trajectory_component(x: &[f64], axis: usize, terms: &[TrajectoryTerm]) -> TrajectoryComponent {
1090    let base = axis * terms.len();
1091    let mut component = TrajectoryComponent {
1092        position_m: 0.0,
1093        velocity_m_per_yr: 0.0,
1094        annual_sin_m: None,
1095        annual_cos_m: None,
1096        semiannual_sin_m: None,
1097        semiannual_cos_m: None,
1098        offsets_m: Vec::new(),
1099    };
1100    for (term_index, term) in terms.iter().enumerate() {
1101        let value = x[base + term_index];
1102        match term {
1103            TrajectoryTerm::Position => component.position_m = value,
1104            TrajectoryTerm::Velocity => component.velocity_m_per_yr = value,
1105            TrajectoryTerm::AnnualSin => component.annual_sin_m = Some(value),
1106            TrajectoryTerm::AnnualCos => component.annual_cos_m = Some(value),
1107            TrajectoryTerm::SemiannualSin => component.semiannual_sin_m = Some(value),
1108            TrajectoryTerm::SemiannualCos => component.semiannual_cos_m = Some(value),
1109            TrajectoryTerm::Offset { .. } => component.offsets_m.push(value),
1110        }
1111    }
1112    component
1113}
1114
1115fn trajectory_geometry_quality(jacobian: &DMatrix<f64>) -> GeometryQuality {
1116    let svd = portable::svd(jacobian, false, false);
1117    let singular_values: Vec<f64> = svd.singular_values.iter().map(|value| value.0).collect();
1118    let diagnostics =
1119        singular_value_diagnostics(&singular_values, jacobian.nrows(), jacobian.ncols());
1120    let gdop = normal_covariance(jacobian, 1.0)
1121        .map(|covariance| {
1122            let trace = (0..covariance.nrows())
1123                .map(|idx| covariance[(idx, idx)])
1124                .sum::<f64>();
1125            if trace >= 0.0 && trace.is_finite() {
1126                trace.sqrt()
1127            } else {
1128                f64::INFINITY
1129            }
1130        })
1131        .unwrap_or(f64::INFINITY);
1132    classify(
1133        diagnostics.rank,
1134        jacobian.ncols(),
1135        jacobian.nrows() as i32 - jacobian.ncols() as i32,
1136        diagnostics.condition_number,
1137        gdop,
1138        false,
1139        GeometryQualityThresholds::default(),
1140    )
1141}
1142
1143fn residual_rms(residuals: &[f64]) -> [f64; 3] {
1144    let mut sums = [0.0; 3];
1145    let mut counts = [0usize; 3];
1146    for (idx, residual) in residuals.iter().enumerate() {
1147        let axis = idx % 3;
1148        sums[axis] += residual * residual;
1149        counts[axis] += 1;
1150    }
1151    [
1152        (sums[0] / counts[0] as f64).sqrt(),
1153        (sums[1] / counts[1] as f64).sqrt(),
1154        (sums[2] / counts[2] as f64).sqrt(),
1155    ]
1156}
1157
1158fn mean_epoch(samples: &[PreparedSample]) -> f64 {
1159    samples.iter().map(|sample| sample.epoch_year).sum::<f64>() / samples.len() as f64
1160}
1161
1162fn matrix_to_vecs(matrix: &DMatrix<f64>) -> Vec<Vec<f64>> {
1163    (0..matrix.nrows())
1164        .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect())
1165        .collect()
1166}
1167
1168fn window_indices(
1169    samples: &[PreparedSample],
1170    start: usize,
1171    end: usize,
1172    epoch: f64,
1173    window_years: f64,
1174) -> Vec<usize> {
1175    (start..end)
1176        .filter(|&idx| (samples[idx].epoch_year - epoch).abs() <= window_years)
1177        .collect()
1178}
1179
1180fn step_score(
1181    residuals: &[[f64; 3]],
1182    before: &[usize],
1183    after: &[usize],
1184) -> Result<([f64; 3], f64), GeodeticTimeSeriesError> {
1185    let mut offset = [0.0; 3];
1186    let mut score_sq = 0.0;
1187    for axis in 0..3 {
1188        let before_values = before
1189            .iter()
1190            .map(|&idx| residuals[idx][axis])
1191            .collect::<Vec<_>>();
1192        let after_values = after
1193            .iter()
1194            .map(|&idx| residuals[idx][axis])
1195            .collect::<Vec<_>>();
1196        let before_median = median(&before_values)?;
1197        let after_median = median(&after_values)?;
1198        let delta = after_median - before_median;
1199        offset[axis] = delta;
1200        let mut centered = before_values
1201            .iter()
1202            .map(|value| value - before_median)
1203            .collect::<Vec<_>>();
1204        centered.extend(after_values.iter().map(|value| value - after_median));
1205        let spread = mad_spread(&centered, 0.0)?;
1206        let axis_score = if spread == 0.0 {
1207            if delta.abs() <= STEP_ZERO_OFFSET_TOLERANCE_M {
1208                0.0
1209            } else {
1210                f64::INFINITY
1211            }
1212        } else {
1213            delta.abs() / spread
1214        };
1215        score_sq += axis_score * axis_score;
1216    }
1217    Ok((offset, score_sq.sqrt()))
1218}
1219
1220fn rotate_velocity_covariance(
1221    origin_rotation: &[[f64; 3]; 3],
1222    station_rotation: &[[f64; 3]; 3],
1223    covariance_station_enu: [[f64; 3]; 3],
1224) -> [[f64; 3]; 3] {
1225    let station_to_ecef = transpose3(station_rotation);
1226    let covariance_ecef = rotate_covariance(&station_to_ecef, covariance_station_enu);
1227    rotate_covariance(origin_rotation, covariance_ecef)
1228}
1229
1230fn rotate_covariance(rotation: &[[f64; 3]; 3], covariance: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
1231    let rq = mat3_mul(rotation, &covariance);
1232    mat3_mul(&rq, &transpose3(rotation))
1233}
1234
1235fn mat3_vec(matrix: &[[f64; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
1236    [
1237        matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
1238        matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
1239        matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
1240    ]
1241}
1242
1243fn enu_to_ecef(rotation: &[[f64; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
1244    mat3_vec(&transpose3(rotation), vector)
1245}
1246
1247fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
1248    let mut out = [[0.0; 3]; 3];
1249    for row in 0..3 {
1250        for col in 0..3 {
1251            out[row][col] = a[row][0] * b[0][col] + a[row][1] * b[1][col] + a[row][2] * b[2][col];
1252        }
1253    }
1254    out
1255}
1256
1257fn transpose3(matrix: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
1258    [
1259        [matrix[0][0], matrix[1][0], matrix[2][0]],
1260        [matrix[0][1], matrix[1][1], matrix[2][1]],
1261        [matrix[0][2], matrix[1][2], matrix[2][2]],
1262    ]
1263}
1264
1265fn validate_covariance(
1266    covariance: [[f64; 3]; 3],
1267    field: &'static str,
1268) -> Result<(), GeodeticTimeSeriesError> {
1269    crate::validate::validate_covariance_psd(&covariance, field)
1270        .map_err(|error| invalid_input(error.field(), error.reason()))?;
1271    validate_covariance_diagonal(covariance, field)
1272}
1273
1274fn validate_covariance_diagonal(
1275    covariance: [[f64; 3]; 3],
1276    field: &'static str,
1277) -> Result<(), GeodeticTimeSeriesError> {
1278    for (axis, row) in covariance.iter().enumerate() {
1279        if row[axis] <= 0.0 {
1280            return Err(invalid_input(field, "diagonal must be positive"));
1281        }
1282    }
1283    Ok(())
1284}
1285
1286fn validate_geodetic(
1287    geodetic: Wgs84Geodetic,
1288    field: &'static str,
1289) -> Result<(), GeodeticTimeSeriesError> {
1290    validate_finite(geodetic.lat_rad, field)?;
1291    validate_finite(geodetic.lon_rad, field)?;
1292    validate_finite(geodetic.height_m, field)?;
1293    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&geodetic.lat_rad) {
1294        return Err(invalid_input(field, "latitude out of range"));
1295    }
1296    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&geodetic.lon_rad) {
1297        return Err(invalid_input(field, "longitude out of range"));
1298    }
1299    Ok(())
1300}
1301
1302fn validate_vec3(vector: [f64; 3], field: &'static str) -> Result<(), GeodeticTimeSeriesError> {
1303    for value in vector {
1304        validate_finite(value, field)?;
1305    }
1306    Ok(())
1307}
1308
1309fn validate_finite(value: f64, field: &'static str) -> Result<(), GeodeticTimeSeriesError> {
1310    if value.is_finite() {
1311        Ok(())
1312    } else {
1313        Err(invalid_input(field, "not finite"))
1314    }
1315}
1316
1317fn validate_positive(value: f64, field: &'static str) -> Result<(), GeodeticTimeSeriesError> {
1318    validate_finite(value, field)?;
1319    if value > 0.0 {
1320        Ok(())
1321    } else {
1322        Err(invalid_input(field, "must be positive"))
1323    }
1324}
1325
1326fn validate_nonnegative(value: f64, field: &'static str) -> Result<(), GeodeticTimeSeriesError> {
1327    validate_finite(value, field)?;
1328    if value >= 0.0 {
1329        Ok(())
1330    } else {
1331        Err(invalid_input(field, "must be non-negative"))
1332    }
1333}
1334
1335fn invalid_input(field: &'static str, reason: &'static str) -> GeodeticTimeSeriesError {
1336    GeodeticTimeSeriesError::InvalidInput { field, reason }
1337}
1338
1339impl fmt::Display for TimeSeriesQuality {
1340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1341        match self {
1342            Self::Nominal => write!(f, "nominal"),
1343            Self::ShortSpan => write!(f, "short-span"),
1344        }
1345    }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    //! Validation provenance: MIDAS equations and constants follow Blewitt,
1351    //! Kreemer, Hammond, and Gazeaux (2016), Journal of Geophysical Research:
1352    //! Solid Earth, doi:10.1002/2015JB012552. The trajectory model uses the
1353    //! constant velocity, annual/semiannual harmonic, and Heaviside jump terms
1354    //! described by Bevis and Brown (2014), Journal of Geodesy 88:283-311,
1355    //! doi:10.1007/s00190-013-0685-5. All oracle values below are analytic
1356    //! constants from those formulas or hand-constructed synthetic series.
1357
1358    use super::*;
1359    use crate::estimation::MAD_GAUSSIAN_CONSISTENCY;
1360
1361    fn enu_series(samples: &[(f64, [f64; 3])]) -> Vec<PositionSample> {
1362        samples
1363            .iter()
1364            .map(|&(epoch_year, position_m)| PositionSample {
1365                epoch_year,
1366                position_m,
1367                covariance_m2: None,
1368            })
1369            .collect()
1370    }
1371
1372    fn series(samples: &[PositionSample]) -> PositionSeries<'_> {
1373        PositionSeries {
1374            frame: PositionFrame::Enu,
1375            samples,
1376        }
1377    }
1378
1379    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1380        assert!(
1381            (actual - expected).abs() <= tolerance,
1382            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.1e}"
1383        );
1384    }
1385
1386    #[test]
1387    fn midas_matches_published_five_year_two_step_breakdown_case() {
1388        let rate = [0.012, -0.006, 0.02];
1389        let mut raw = Vec::new();
1390        for day in 0..=(5 * 365) {
1391            let t = day as f64 / 365.0;
1392            let mut position = [rate[0] * t, rate[1] * t, rate[2] * t];
1393            if t >= 1.5 {
1394                position[0] += 100.0;
1395                position[2] -= 50.0;
1396            }
1397            if t >= 3.5 {
1398                position[0] += 80.0;
1399                position[2] -= 40.0;
1400            }
1401            raw.push((t, position));
1402        }
1403        let samples = enu_series(&raw);
1404        let velocity = velocity_midas(&series(&samples), MidasOptions::default()).unwrap();
1405
1406        for (actual, expected) in velocity.rate_enu_m_per_yr.iter().zip(rate) {
1407            assert_close(*actual, expected, 2.0e-14);
1408        }
1409        assert_eq!(velocity.component_stats[0].pair_count, 1461);
1410        assert_eq!(velocity.component_stats[0].retained_pair_count, 731);
1411    }
1412
1413    #[test]
1414    fn midas_and_lsq_recover_known_velocity_and_midas_uncertainty() {
1415        let rate = [0.01, -0.02, 0.03];
1416        let noise = [0.001, -0.002, 0.003, 0.0, 0.003, -0.002, 0.001];
1417        let raw = (0..=6)
1418            .map(|year| {
1419                let t = year as f64;
1420                (
1421                    t,
1422                    [
1423                        rate[0] * t + noise[year],
1424                        rate[1] * t + 2.0 * noise[year],
1425                        rate[2] * t - noise[year],
1426                    ],
1427                )
1428            })
1429            .collect::<Vec<_>>();
1430        let samples = enu_series(&raw);
1431        let velocity = velocity_midas(&series(&samples), MidasOptions::default()).unwrap();
1432
1433        for (actual, expected) in velocity.rate_enu_m_per_yr.iter().zip(rate) {
1434            assert_close(*actual, expected, 1.0e-16);
1435        }
1436        let expected_sigma =
1437            3.0 * (core::f64::consts::PI / 2.0).sqrt() * MAD_GAUSSIAN_CONSISTENCY * 0.003
1438                / (6.0_f64 / 4.0).sqrt();
1439        assert_close(velocity.sigma_enu_m_per_yr[0], expected_sigma, 2.0e-17);
1440
1441        let model = TrajectoryModel {
1442            reference_epoch_year: Some(3.0),
1443            include_annual: false,
1444            include_semiannual: false,
1445            offset_epochs_year: Vec::new(),
1446        };
1447        let trajectory =
1448            fit_trajectory(&series(&samples), &model, TrajectoryFitOptions::default()).unwrap();
1449        for (component, expected) in trajectory.components.iter().zip(rate) {
1450            assert_close(component.velocity_m_per_yr, expected, 2.0e-12);
1451        }
1452    }
1453
1454    #[test]
1455    fn midas_resists_steps_seasons_and_outliers_that_bias_naive_lsq() {
1456        let true_rate = 0.011;
1457        let raw = (0..=24)
1458            .map(|quarter| {
1459                let t = quarter as f64 * 0.25;
1460                let seasonal = 0.012 * libm::sin(TAU * t) + 0.004 * libm::cos(TAU * t);
1461                let step = if t >= 2.25 { 0.09 } else { 0.0 };
1462                let outlier = if (t - 4.25).abs() < f64::EPSILON {
1463                    0.25
1464                } else {
1465                    0.0
1466                };
1467                (t, [true_rate * t + seasonal + step + outlier, 0.0, 0.0])
1468            })
1469            .collect::<Vec<_>>();
1470        let samples = enu_series(&raw);
1471        let midas = velocity_midas(&series(&samples), MidasOptions::default()).unwrap();
1472        assert_close(midas.rate_enu_m_per_yr[0], true_rate, 2.0e-15);
1473
1474        let model = TrajectoryModel {
1475            reference_epoch_year: Some(3.0),
1476            include_annual: false,
1477            include_semiannual: false,
1478            offset_epochs_year: Vec::new(),
1479        };
1480        let naive = fit_trajectory(&series(&samples), &model, TrajectoryFitOptions::default())
1481            .unwrap()
1482            .components[0]
1483            .velocity_m_per_yr;
1484        assert!((naive - true_rate).abs() > 0.015);
1485    }
1486
1487    #[test]
1488    fn trajectory_recovers_velocity_harmonics_and_offset() {
1489        let reference = 3.0;
1490        let offset_epoch = 2.3;
1491        let east = TrajectoryComponent {
1492            position_m: 0.25,
1493            velocity_m_per_yr: 0.017,
1494            annual_sin_m: Some(0.012),
1495            annual_cos_m: Some(-0.004),
1496            semiannual_sin_m: Some(0.006),
1497            semiannual_cos_m: Some(0.002),
1498            offsets_m: vec![0.08],
1499        };
1500        let raw = (0..=96)
1501            .map(|month| {
1502                let t = month as f64 / 12.0;
1503                let dt = t - reference;
1504                let value = east.position_m
1505                    + east.velocity_m_per_yr * dt
1506                    + east.annual_sin_m.unwrap() * libm::sin(TAU * dt)
1507                    + east.annual_cos_m.unwrap() * libm::cos(TAU * dt)
1508                    + east.semiannual_sin_m.unwrap() * libm::sin(2.0 * TAU * dt)
1509                    + east.semiannual_cos_m.unwrap() * libm::cos(2.0 * TAU * dt)
1510                    + if t > offset_epoch {
1511                        east.offsets_m[0]
1512                    } else {
1513                        0.0
1514                    };
1515                (t, [value, -0.5 * value, 0.25 * value])
1516            })
1517            .collect::<Vec<_>>();
1518        let samples = enu_series(&raw);
1519        let model = TrajectoryModel {
1520            reference_epoch_year: Some(reference),
1521            include_annual: true,
1522            include_semiannual: true,
1523            offset_epochs_year: vec![offset_epoch],
1524        };
1525        let trajectory =
1526            fit_trajectory(&series(&samples), &model, TrajectoryFitOptions::default()).unwrap();
1527        let actual = &trajectory.components[0];
1528
1529        assert_close(actual.position_m, east.position_m, 2.0e-10);
1530        assert_close(actual.velocity_m_per_yr, east.velocity_m_per_yr, 2.0e-10);
1531        assert_close(
1532            actual.annual_sin_m.unwrap(),
1533            east.annual_sin_m.unwrap(),
1534            2.0e-10,
1535        );
1536        assert_close(
1537            actual.annual_cos_m.unwrap(),
1538            east.annual_cos_m.unwrap(),
1539            2.0e-10,
1540        );
1541        assert_close(
1542            actual.semiannual_sin_m.unwrap(),
1543            east.semiannual_sin_m.unwrap(),
1544            2.0e-10,
1545        );
1546        assert_close(
1547            actual.semiannual_cos_m.unwrap(),
1548            east.semiannual_cos_m.unwrap(),
1549            2.0e-10,
1550        );
1551        assert_close(actual.offsets_m[0], east.offsets_m[0], 2.0e-10);
1552    }
1553
1554    #[test]
1555    fn detect_steps_flags_injected_offset_and_not_step_free_series() {
1556        let stepped = (0..=96)
1557            .map(|month| {
1558                let t = month as f64 / 12.0;
1559                let step = if t >= 3.0 { 0.12 } else { 0.0 };
1560                (t, [0.01 * t + step, -0.02 * t, 0.0])
1561            })
1562            .collect::<Vec<_>>();
1563        let stepped_samples = enu_series(&stepped);
1564        let candidates =
1565            detect_steps(&series(&stepped_samples), StepDetectionOptions::default()).unwrap();
1566        assert!(!candidates.is_empty());
1567        assert_close(candidates[0].epoch_year, 3.0, 0.25);
1568        assert!(candidates[0].offset_enu_m[0] > 0.10);
1569
1570        let clean = (0..=96)
1571            .map(|month| {
1572                let t = month as f64 / 12.0;
1573                (t, [0.01 * t, -0.02 * t, 0.0])
1574            })
1575            .collect::<Vec<_>>();
1576        let clean_samples = enu_series(&clean);
1577        let clean_candidates =
1578            detect_steps(&series(&clean_samples), StepDetectionOptions::default()).unwrap();
1579        assert!(clean_candidates.is_empty());
1580    }
1581
1582    #[test]
1583    fn short_sparse_series_returns_typed_error() {
1584        let samples = enu_series(&[(0.0, [0.0; 3]), (1.0, [1.0, 0.0, 0.0])]);
1585        let error = velocity_midas(&series(&samples), MidasOptions::default()).unwrap_err();
1586        assert!(matches!(
1587            error,
1588            GeodeticTimeSeriesError::InsufficientPairs {
1589                pairs: 1,
1590                needed: 3
1591            }
1592        ));
1593    }
1594
1595    #[test]
1596    fn network_field_removes_common_mode_in_requested_frame() {
1597        let reference = Wgs84Geodetic::new(0.7, -1.2, 10.0).unwrap();
1598        let first_samples = enu_series(&[
1599            (0.0, [0.0; 3]),
1600            (1.0, [1.0, 2.0, 0.0]),
1601            (2.0, [2.0, 4.0, 0.0]),
1602            (3.0, [3.0, 6.0, 0.0]),
1603        ]);
1604        let second_samples = enu_series(&[
1605            (0.0, [0.0; 3]),
1606            (1.0, [3.0, 4.0, 0.0]),
1607            (2.0, [6.0, 8.0, 0.0]),
1608            (3.0, [9.0, 12.0, 0.0]),
1609        ]);
1610        let stations = [
1611            NetworkStation {
1612                id: "A",
1613                reference,
1614                series: series(&first_samples),
1615            },
1616            NetworkStation {
1617                id: "B",
1618                reference,
1619                series: series(&second_samples),
1620            },
1621        ];
1622        let field = network_field(
1623            &stations,
1624            NetworkFrame {
1625                origin: reference,
1626                remove_common_mode: true,
1627            },
1628        )
1629        .unwrap();
1630
1631        assert_close(field.common_mode_enu_m_per_yr[0], 2.0, 1.0e-12);
1632        assert_close(field.common_mode_enu_m_per_yr[1], 3.0, 1.0e-12);
1633        assert_close(field.stations[0].rate_enu_m_per_yr[0], -1.0, 1.0e-12);
1634        assert_close(field.stations[0].rate_enu_m_per_yr[1], -1.0, 1.0e-12);
1635        assert_close(field.stations[1].rate_enu_m_per_yr[0], 1.0, 1.0e-12);
1636        assert_close(field.stations[1].rate_enu_m_per_yr[1], 1.0, 1.0e-12);
1637    }
1638}