1use std::f64::consts::PI;
9
10use crate::astro::time::Duration;
11use crate::constants::SECONDS_PER_DAY;
12use crate::ephemeris::BroadcastEphemeris;
13use crate::estimation::{ewma_update, mad_spread, PrimitiveError};
14use crate::{GnssSatelliteId, GnssSystem};
15
16pub const SIDEREAL_DAY_NANOS: i128 = 86_164_090_500_000;
21
22pub const SIDEREAL_DAY_SECONDS: f64 = 86_164.090_5;
24
25#[derive(Debug, Clone, PartialEq, thiserror::Error)]
27pub enum SiderealFilterError {
28 #[error("invalid sidereal filter {field}: {reason}")]
30 InvalidInput {
31 field: &'static str,
33 reason: &'static str,
35 },
36 #[error("no broadcast record for {sat} at requested epoch")]
38 NoBroadcastRecord {
39 sat: GnssSatelliteId,
41 },
42 #[error("unsupported orbit repeat constellation {system}")]
44 UnsupportedConstellation {
45 system: GnssSystem,
47 },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Default)]
52pub enum SiderealTemplateMethod {
53 #[default]
55 Mean,
56 RobustMad,
58 Ewma {
60 alpha: f64,
62 },
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct SiderealFilterOptions {
68 pub sample_interval: Duration,
70 pub prior_periods: usize,
72 pub min_coverage: usize,
74 pub template_method: SiderealTemplateMethod,
76}
77
78impl Default for SiderealFilterOptions {
79 fn default() -> Self {
80 Self {
81 sample_interval: Duration::from_nanos(1_000_000_000),
82 prior_periods: 1,
83 min_coverage: 1,
84 template_method: SiderealTemplateMethod::Mean,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct SiderealFilterOutput {
92 pub filtered: Vec<f64>,
94 pub template: Vec<f64>,
99 pub coverage: Vec<usize>,
101 pub under_covered: Vec<bool>,
103}
104
105pub fn repeat_period(system: GnssSystem) -> Duration {
112 let sidereal_days = match system {
113 GnssSystem::Gps | GnssSystem::Qzss | GnssSystem::Navic | GnssSystem::Sbas => 1,
114 GnssSystem::Glonass => 8,
115 GnssSystem::Galileo => 10,
116 GnssSystem::BeiDou => 7,
117 };
118 Duration::from_nanos(SIDEREAL_DAY_NANOS * sidereal_days)
119}
120
121pub fn orbit_repeat_lag(
128 ephemeris: &BroadcastEphemeris,
129 sat: GnssSatelliteId,
130 near_epoch_j2000_s: f64,
131) -> Result<Duration, SiderealFilterError> {
132 validate_finite(near_epoch_j2000_s, "near_epoch_j2000_s")?;
133 if !matches!(
134 sat.system,
135 GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou | GnssSystem::Qzss
136 ) {
137 return Err(SiderealFilterError::UnsupportedConstellation { system: sat.system });
138 }
139 let record = ephemeris
140 .select_record_at(sat, near_epoch_j2000_s)
141 .ok_or(SiderealFilterError::NoBroadcastRecord { sat })?;
142 let repeat_s = orbit_repeat_seconds(
143 record.elements.sqrt_a,
144 record.elements.delta_n,
145 record.constants().gm_m3_s2,
146 )?;
147 Duration::from_seconds(repeat_s).map_err(|_| invalid_input("orbit_repeat_lag", "out of range"))
148}
149
150pub fn sidereal_filter(
157 series: &[f64],
158 period: Duration,
159 options: SiderealFilterOptions,
160) -> Result<SiderealFilterOutput, SiderealFilterError> {
161 validate_series(series)?;
162 validate_options(options)?;
163 let period_s = validate_positive_duration(period, "period")?;
164 let sample_interval_s =
165 validate_positive_duration(options.sample_interval, "options.sample_interval")?;
166 let bins = phase_bin_count(period_s, sample_interval_s)?;
167
168 let mut histories = vec![Vec::<f64>::new(); bins];
169 let mut filtered = Vec::with_capacity(series.len());
170 let mut template = vec![f64::NAN; bins];
171 let mut coverage = vec![0usize; bins];
172 let mut under_covered = vec![true; bins];
173
174 for (sample_index, &sample) in series.iter().enumerate() {
175 let bin = phase_bin(sample_index, period_s, sample_interval_s, bins);
176 let history = &histories[bin];
177 let prior_count = history.len();
178 coverage[bin] = prior_count;
179 under_covered[bin] = prior_count < options.min_coverage;
180
181 if prior_count >= options.min_coverage {
182 let correction = estimate_template(history, options.template_method)?;
183 template[bin] = correction;
184 filtered.push(sample - correction);
185 } else {
186 filtered.push(sample);
187 }
188
189 let history = &mut histories[bin];
190 history.push(sample);
191 while history.len() > options.prior_periods {
192 history.remove(0);
193 }
194 }
195
196 Ok(SiderealFilterOutput {
197 filtered,
198 template,
199 coverage,
200 under_covered,
201 })
202}
203
204pub fn periodicity_strength(
211 series: &[f64],
212 candidate_periods: &[Duration],
213) -> Result<Vec<(Duration, f64)>, SiderealFilterError> {
214 periodicity_strength_with_sample_interval(
215 series,
216 candidate_periods,
217 Duration::from_nanos(1_000_000_000),
218 )
219}
220
221pub fn periodicity_strength_with_sample_interval(
228 series: &[f64],
229 candidate_periods: &[Duration],
230 sample_interval: Duration,
231) -> Result<Vec<(Duration, f64)>, SiderealFilterError> {
232 validate_series(series)?;
233 if series.len() < 2 {
234 return Err(invalid_input("series", "must contain at least two samples"));
235 }
236 let sample_interval_s = validate_positive_duration(sample_interval, "sample_interval")?;
237 let baseline = mad_spread(series, 0.0).map_err(map_primitive_error)?;
238 let mut scores = Vec::with_capacity(candidate_periods.len());
239
240 for &period in candidate_periods {
241 let period_s = validate_positive_duration(period, "candidate_periods")?;
242 let bins = phase_bin_count(period_s, sample_interval_s)?;
243 let strength = if baseline == 0.0 {
244 0.0
245 } else {
246 periodicity_strength_one(series, period_s, sample_interval_s, bins, baseline)?
247 };
248 scores.push((period, strength));
249 }
250
251 Ok(scores)
252}
253
254fn periodicity_strength_one(
255 series: &[f64],
256 period_s: f64,
257 sample_interval_s: f64,
258 bins: usize,
259 baseline: f64,
260) -> Result<f64, SiderealFilterError> {
261 let mut phases = vec![Vec::<f64>::new(); bins];
262 for (sample_index, &sample) in series.iter().enumerate() {
263 let bin = phase_bin(sample_index, period_s, sample_interval_s, bins);
264 phases[bin].push(sample);
265 }
266
267 let mut templates = vec![f64::NAN; bins];
268 let mut covered_bins = 0usize;
269 for (bin, values) in phases.iter().enumerate() {
270 if values.len() >= 2 {
271 templates[bin] = robust_mad_template(values)?;
272 covered_bins += 1;
273 }
274 }
275 if covered_bins == 0 {
276 return Ok(0.0);
277 }
278
279 let residuals = series
280 .iter()
281 .enumerate()
282 .map(|(sample_index, &sample)| {
283 let bin = phase_bin(sample_index, period_s, sample_interval_s, bins);
284 let correction = templates[bin];
285 if correction.is_finite() {
286 sample - correction
287 } else {
288 sample
289 }
290 })
291 .collect::<Vec<_>>();
292 let residual_spread = mad_spread(&residuals, 0.0).map_err(map_primitive_error)?;
293 let raw = 1.0 - (residual_spread / baseline).powi(2);
294 Ok(raw.clamp(0.0, 1.0))
295}
296
297fn orbit_repeat_seconds(
298 sqrt_a: f64,
299 delta_n: f64,
300 gm_m3_s2: f64,
301) -> Result<f64, SiderealFilterError> {
302 validate_positive(sqrt_a, "record.elements.sqrt_a")?;
303 validate_finite(delta_n, "record.elements.delta_n")?;
304 validate_positive(gm_m3_s2, "record.constants.gm_m3_s2")?;
305 let a = sqrt_a * sqrt_a;
306 let n0 = (gm_m3_s2 / (a * a * a)).sqrt();
307 let n = n0 + delta_n;
308 validate_positive(n, "record.mean_motion")?;
309 let repeat_s = 4.0 * PI / n;
310 validate_positive(repeat_s, "orbit_repeat_lag")
311}
312
313fn validate_options(options: SiderealFilterOptions) -> Result<(), SiderealFilterError> {
314 if options.prior_periods == 0 {
315 return Err(invalid_input("options.prior_periods", "must be positive"));
316 }
317 if options.min_coverage == 0 {
318 return Err(invalid_input("options.min_coverage", "must be positive"));
319 }
320 match options.template_method {
321 SiderealTemplateMethod::Mean | SiderealTemplateMethod::RobustMad => {}
322 SiderealTemplateMethod::Ewma { alpha } => {
323 validate_finite(alpha, "options.template_method.alpha")?;
324 if !(0.0..=1.0).contains(&alpha) {
325 return Err(invalid_input(
326 "options.template_method.alpha",
327 "must be in [0, 1]",
328 ));
329 }
330 }
331 }
332 Ok(())
333}
334
335fn validate_series(series: &[f64]) -> Result<(), SiderealFilterError> {
336 for &sample in series {
337 validate_finite(sample, "series")?;
338 }
339 Ok(())
340}
341
342fn validate_positive_duration(
343 duration: Duration,
344 field: &'static str,
345) -> Result<f64, SiderealFilterError> {
346 if duration.nanos <= 0 {
347 return Err(invalid_input(field, "must be positive"));
348 }
349 let seconds = duration.as_seconds();
350 validate_positive(seconds, field)
351}
352
353fn validate_finite(value: f64, field: &'static str) -> Result<f64, SiderealFilterError> {
354 if value.is_finite() {
355 Ok(value)
356 } else {
357 Err(invalid_input(field, "must be finite"))
358 }
359}
360
361fn validate_positive(value: f64, field: &'static str) -> Result<f64, SiderealFilterError> {
362 validate_finite(value, field)?;
363 if value > 0.0 {
364 Ok(value)
365 } else {
366 Err(invalid_input(field, "must be positive"))
367 }
368}
369
370fn phase_bin_count(period_s: f64, sample_interval_s: f64) -> Result<usize, SiderealFilterError> {
371 let bins = (period_s / sample_interval_s).ceil();
372 if !bins.is_finite() || bins < 1.0 || bins > usize::MAX as f64 {
373 return Err(invalid_input("period", "phase-bin count is out of range"));
374 }
375 Ok(bins as usize)
376}
377
378fn phase_bin(sample_index: usize, period_s: f64, sample_interval_s: f64, bins: usize) -> usize {
379 let phase_s = (sample_index as f64 * sample_interval_s).rem_euclid(period_s);
380 let bin = (phase_s / sample_interval_s).floor() as usize;
381 bin.min(bins - 1)
382}
383
384fn estimate_template(
385 values: &[f64],
386 method: SiderealTemplateMethod,
387) -> Result<f64, SiderealFilterError> {
388 match method {
389 SiderealTemplateMethod::Mean => mean(values),
390 SiderealTemplateMethod::RobustMad => robust_mad_template(values),
391 SiderealTemplateMethod::Ewma { alpha } => ewma_template(values, alpha),
392 }
393}
394
395fn mean(values: &[f64]) -> Result<f64, SiderealFilterError> {
396 if values.is_empty() {
397 return Err(invalid_input("values", "must not be empty"));
398 }
399 Ok(values.iter().sum::<f64>() / values.len() as f64)
400}
401
402fn ewma_template(values: &[f64], alpha: f64) -> Result<f64, SiderealFilterError> {
403 let Some((&first, rest)) = values.split_first() else {
404 return Err(invalid_input("values", "must not be empty"));
405 };
406 let mut template = first;
407 for &sample in rest {
408 template = ewma_update(template, sample, alpha).map_err(map_primitive_error)?;
409 }
410 Ok(template)
411}
412
413fn robust_mad_template(values: &[f64]) -> Result<f64, SiderealFilterError> {
414 let median = median(values)?;
415 let spread = mad_spread(values, 0.0).map_err(map_primitive_error)?;
416 if spread == 0.0 {
417 return Ok(median);
418 }
419 let gate = 3.0 * spread;
420 let mut sum = 0.0;
421 let mut count = 0usize;
422 for &value in values {
423 if (value - median).abs() <= gate {
424 sum += value;
425 count += 1;
426 }
427 }
428 if count == 0 {
429 Ok(median)
430 } else {
431 Ok(sum / count as f64)
432 }
433}
434
435fn median(values: &[f64]) -> Result<f64, SiderealFilterError> {
436 let mut sorted = values.to_vec();
437 crate::astro::math::robust::median_sorting_in_place(&mut sorted)
438 .ok_or_else(|| invalid_input("values", "must not be empty"))
439}
440
441fn map_primitive_error(error: PrimitiveError) -> SiderealFilterError {
442 match error {
443 PrimitiveError::InvalidInput { field, reason } => {
444 SiderealFilterError::InvalidInput { field, reason }
445 }
446 }
447}
448
449const fn invalid_input(field: &'static str, reason: &'static str) -> SiderealFilterError {
450 SiderealFilterError::InvalidInput { field, reason }
451}
452
453pub fn solar_day_period() -> Duration {
455 Duration::from_nanos((SECONDS_PER_DAY as i128) * 1_000_000_000)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
472 use crate::astro::time::GnssWeekTow;
473 use crate::broadcast::{ClockPolynomial, ConstellationConstants, KeplerianElements};
474 use crate::constants::{GPS_EPOCH_TO_J2000_S, SECONDS_PER_HOUR, SECONDS_PER_WEEK};
475 use crate::rinex_nav::{BroadcastGroupDelays, BroadcastIssue, BroadcastRecord, NavMessage};
476
477 #[test]
478 fn repeat_periods_use_documented_sidereal_day_multiples() {
479 assert_eq!(
480 repeat_period(GnssSystem::Gps),
481 Duration::from_nanos(SIDEREAL_DAY_NANOS)
482 );
483 assert_eq!(
484 repeat_period(GnssSystem::Glonass),
485 Duration::from_nanos(SIDEREAL_DAY_NANOS * 8)
486 );
487 assert_eq!(
488 repeat_period(GnssSystem::Galileo),
489 Duration::from_nanos(SIDEREAL_DAY_NANOS * 10)
490 );
491 assert_eq!(
492 repeat_period(GnssSystem::BeiDou),
493 Duration::from_nanos(SIDEREAL_DAY_NANOS * 7)
494 );
495 }
496
497 #[test]
498 fn orbit_repeat_lag_matches_agnew_larson_sv1_table_value() {
499 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite");
500 let target_repeat_s = 86_152.95;
501 let record = agnew_larson_table_record(sat, target_repeat_s);
502 let week = record.toe.week;
503 let toe = record.toe.tow_s;
504 let store = BroadcastEphemeris::new(vec![record]).expect("valid broadcast store");
505 let near_epoch_j2000_s = f64::from(week) * SECONDS_PER_WEEK + toe - GPS_EPOCH_TO_J2000_S;
506
507 let lag = orbit_repeat_lag(&store, sat, near_epoch_j2000_s)
508 .expect("orbit repeat lag from selected record");
509 let repeat_s = lag.as_seconds();
510
511 assert!(
512 (repeat_s - target_repeat_s).abs() <= 1.0e-9,
513 "repeat_s={repeat_s:.12}"
514 );
515 }
516
517 #[test]
518 fn sidereal_filter_recovers_exact_template_and_noise_floor() {
519 let injected = [1.0, 0.5, -0.25, -1.25, 2.0, -0.75, 0.125, 0.875];
520 let noise = [0.125, -0.25, 0.0625, 0.0, -0.125, 0.25, -0.0625, 0.1875];
521 let mut series = injected.to_vec();
522 series.extend(
523 injected
524 .iter()
525 .zip(noise)
526 .map(|(&signal, noise)| signal + noise),
527 );
528
529 let output = sidereal_filter(
530 &series,
531 Duration::from_nanos(8_000_000_000),
532 SiderealFilterOptions::default(),
533 )
534 .expect("sidereal filter");
535
536 assert_eq!(&output.template, &injected);
537 assert_eq!(&output.filtered[8..], &noise);
538 assert_eq!(variance(&output.filtered[8..]), variance(&noise));
539 assert_eq!(output.coverage, vec![1; injected.len()]);
540 assert_eq!(output.under_covered, vec![false; injected.len()]);
541 }
542
543 #[test]
544 fn short_or_undercovered_series_is_flagged_and_left_unchanged() {
545 let series = [0.25, -0.5, 0.75, -1.0, 1.25];
546 let output = sidereal_filter(
547 &series,
548 Duration::from_nanos(4_000_000_000),
549 SiderealFilterOptions {
550 min_coverage: 2,
551 ..SiderealFilterOptions::default()
552 },
553 )
554 .expect("sidereal filter");
555
556 assert_eq!(output.filtered, series);
557 assert_eq!(output.coverage, vec![1, 0, 0, 0]);
558 assert_eq!(output.under_covered, vec![true, true, true, true]);
559 assert!(output.template.iter().all(|value| value.is_nan()));
560 }
561
562 #[test]
563 fn periodicity_strength_separates_solar_and_sidereal_components() {
564 let solar = solar_day_period();
565 let sidereal = repeat_period(GnssSystem::Gps);
566 let cadence = Duration::from_nanos(60_000_000_000);
567 let samples = (5.0 * SECONDS_PER_DAY / cadence.as_seconds()) as usize;
568
569 let solar_only = patterned_period_series(samples, solar, cadence, 1.0);
570 let sidereal_only = patterned_period_series(samples, sidereal, cadence, 1.0);
571 let mixed = solar_only
572 .iter()
573 .zip(&sidereal_only)
574 .map(|(&solar, &sidereal)| solar + 0.5 * sidereal)
575 .collect::<Vec<_>>();
576 let candidates = [solar, sidereal];
577
578 let solar_scores =
579 periodicity_strength_with_sample_interval(&solar_only, &candidates, cadence)
580 .expect("solar scores");
581 let sidereal_scores =
582 periodicity_strength_with_sample_interval(&sidereal_only, &candidates, cadence)
583 .expect("sidereal scores");
584 let mixed_scores = periodicity_strength_with_sample_interval(&mixed, &candidates, cadence)
585 .expect("mixed scores");
586
587 assert_eq!(solar_scores[0].1.to_bits(), 1.0f64.to_bits());
588 assert_eq!(solar_scores[1].1.to_bits(), 0x3fd7_0a3d_70a3_d708);
589 assert_eq!(sidereal_scores[0].1.to_bits(), 0x3fcf_db97_530e_ca84);
590 assert_eq!(sidereal_scores[1].1.to_bits(), 1.0f64.to_bits());
591 assert_eq!(mixed_scores[0].1.to_bits(), 0x3fe9_fdb9_7530_eca9);
592 assert_eq!(mixed_scores[1].1.to_bits(), 0x3fd7_0a3d_70a3_d708);
593 }
594
595 fn agnew_larson_table_record(sat: GnssSatelliteId, repeat_s: f64) -> BroadcastRecord {
596 let sqrt_a = 5_153.795_477_5;
597 let a = sqrt_a * sqrt_a;
598 let n0 = (ConstellationConstants::GPS.gm_m3_s2 / (a * a * a)).sqrt();
599 let delta_n = 4.0 * PI / repeat_s - n0;
600 let toe_sow = 100_000.0;
601 let toe = GnssWeekTow::new(crate::astro::time::TimeScale::Gpst, 2_295, toe_sow)
602 .expect("valid toe");
603 BroadcastRecord {
604 satellite_id: sat,
605 message: NavMessage::GpsLnav,
606 issue_of_data: BroadcastIssue {
607 issue: 0,
608 message: NavMessage::GpsLnav,
609 },
610 week: toe.week,
611 toe,
612 toc: toe,
613 elements: KeplerianElements {
614 sqrt_a,
615 e: 0.01,
616 m0: 0.0,
617 delta_n,
618 omega0: 1.0,
619 i0: 0.94,
620 omega: 0.25,
621 omega_dot: -8.0e-9,
622 idot: 0.0,
623 cuc: 0.0,
624 cus: 0.0,
625 crc: 100.0,
626 crs: -50.0,
627 cic: 0.0,
628 cis: 0.0,
629 toe_sow,
630 },
631 clock: ClockPolynomial {
632 af0: 0.0,
633 af1: 0.0,
634 af2: 0.0,
635 toc_sow: toe_sow,
636 },
637 group_delays: BroadcastGroupDelays::default(),
638 cnav: None,
639 sv_health: 0.0,
640 sv_accuracy_m: 0.0,
641 fit_interval_s: Some(4.0 * SECONDS_PER_HOUR),
642 }
643 }
644
645 fn variance(values: &[f64]) -> f64 {
646 let mean = values.iter().sum::<f64>() / values.len() as f64;
647 values
648 .iter()
649 .map(|value| {
650 let residual = value - mean;
651 residual * residual
652 })
653 .sum::<f64>()
654 / values.len() as f64
655 }
656
657 fn patterned_period_series(
658 samples: usize,
659 period: Duration,
660 cadence: Duration,
661 amplitude: f64,
662 ) -> Vec<f64> {
663 let pattern = [1.0, -0.5, 0.25, -1.0, 0.75];
664 let period_s = period.as_seconds();
665 let cadence_s = cadence.as_seconds();
666 let bins = phase_bin_count(period_s, cadence_s).expect("phase bins");
667 (0..samples)
668 .map(|sample_index| {
669 let bin = phase_bin(sample_index, period_s, cadence_s, bins);
670 amplitude * pattern[bin % pattern.len()]
671 })
672 .collect()
673 }
674}