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