1use 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#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct PositionSample {
39 pub epoch_year: f64,
41 pub position_m: [f64; 3],
43 pub covariance_m2: Option<[[f64; 3]; 3]>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub enum PositionFrame {
51 Enu,
53 Ecef {
56 reference: Wgs84Geodetic,
58 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct PositionSeries<'a> {
64 pub frame: PositionFrame,
66 pub samples: &'a [PositionSample],
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct MidasOptions {
73 pub dominant_period_years: f64,
75 pub period_tolerance_years: f64,
78 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TimeSeriesQuality {
95 Nominal,
98 ShortSpan,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct MidasComponentStats {
106 pub pair_count: usize,
108 pub retained_pair_count: usize,
110 pub slope_sigma_m_per_yr: f64,
112 pub effective_pair_count: f64,
114}
115
116#[derive(Debug, Clone, PartialEq)]
118pub struct Velocity {
119 pub rate_enu_m_per_yr: [f64; 3],
121 pub sigma_enu_m_per_yr: [f64; 3],
123 pub covariance_enu_m2_per_yr2: [[f64; 3]; 3],
125 pub component_stats: [MidasComponentStats; 3],
127 pub sample_count: usize,
129 pub span_years: f64,
131 pub quality: TimeSeriesQuality,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq)]
137pub enum TrajectoryTerm {
138 Position,
140 Velocity,
142 AnnualSin,
144 AnnualCos,
146 SemiannualSin,
148 SemiannualCos,
150 Offset {
152 index: usize,
154 epoch_year: f64,
156 },
157}
158
159#[derive(Debug, Clone, PartialEq)]
161pub struct TrajectoryModel {
162 pub reference_epoch_year: Option<f64>,
164 pub include_annual: bool,
166 pub include_semiannual: bool,
168 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#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct TrajectoryFitOptions {
186 pub loss: Loss,
188 pub f_scale_m: f64,
190 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#[derive(Debug, Clone, PartialEq)]
206pub struct TrajectoryComponent {
207 pub position_m: f64,
209 pub velocity_m_per_yr: f64,
211 pub annual_sin_m: Option<f64>,
213 pub annual_cos_m: Option<f64>,
215 pub semiannual_sin_m: Option<f64>,
218 pub semiannual_cos_m: Option<f64>,
221 pub offsets_m: Vec<f64>,
223}
224
225#[derive(Debug, Clone, PartialEq)]
227pub struct Trajectory {
228 pub reference_epoch_year: f64,
230 pub terms: Vec<TrajectoryTerm>,
232 pub components: [TrajectoryComponent; 3],
234 pub parameter_covariance: Vec<Vec<f64>>,
237 pub residual_rms_enu_m: [f64; 3],
239 pub geometry_quality: GeometryQuality,
241 pub status: i32,
243 pub nfev: usize,
245 pub njev: usize,
247 pub cost: f64,
249 pub optimality: f64,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq)]
255pub struct StepDetectionOptions {
256 pub window_years: f64,
258 pub score_threshold: f64,
260 pub min_offset_m: f64,
262 pub min_samples_each_side: usize,
264 pub min_separation_years: f64,
266 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum StepDetectionHeuristic {
286 DetrendedSlidingMedian,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq)]
293pub struct StepCandidate {
294 pub epoch_year: f64,
296 pub offset_enu_m: [f64; 3],
298 pub score: f64,
300 pub before_count: usize,
302 pub after_count: usize,
304 pub heuristic: StepDetectionHeuristic,
306}
307
308#[derive(Debug, Clone, Copy, PartialEq)]
310pub struct NetworkFrame {
311 pub origin: Wgs84Geodetic,
313 pub remove_common_mode: bool,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq)]
319pub struct NetworkStation<'a> {
320 pub id: &'a str,
322 pub reference: Wgs84Geodetic,
324 pub series: PositionSeries<'a>,
326}
327
328#[derive(Debug, Clone, PartialEq)]
330pub struct StationMotion {
331 pub id: String,
333 pub rate_enu_m_per_yr: [f64; 3],
335 pub raw_rate_enu_m_per_yr: [f64; 3],
337 pub sigma_enu_m_per_yr: [f64; 3],
339 pub local_velocity: Velocity,
341}
342
343#[derive(Debug, Clone, PartialEq)]
345pub struct MotionField {
346 pub frame: NetworkFrame,
348 pub stations: Vec<StationMotion>,
350 pub common_mode_enu_m_per_yr: [f64; 3],
353}
354
355#[derive(Debug, Clone, PartialEq, thiserror::Error)]
357pub enum GeodeticTimeSeriesError {
358 #[error("invalid geodetic time-series input {field}: {reason}")]
360 InvalidInput {
361 field: &'static str,
363 reason: &'static str,
365 },
366 #[error("geodetic time series has {samples} samples; need at least {needed}")]
368 TooFewSamples {
369 samples: usize,
371 needed: usize,
373 },
374 #[error("geodetic time series has {pairs} usable pairs; need at least {needed}")]
376 InsufficientPairs {
377 pairs: usize,
379 needed: usize,
381 },
382 #[error("trajectory design is rank deficient")]
384 SingularTrajectory,
385 #[error("trajectory solver did not converge, status {status}")]
388 DidNotConverge {
389 status: i32,
391 },
392 #[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
432pub 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
501pub 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
588pub 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
657pub 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(¢ered, 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 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}