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