1use std::cell::Cell;
9use std::collections::BTreeMap;
10
11use crate::astro::constants::earth::GM_EARTH_M3_S2;
12use crate::astro::math::vec3::{norm3, sub3};
13use crate::astro::time::civil::{civil_from_j2000_seconds, day_of_year, second_of_day};
14use crate::astro::time::model::TimeScale;
15use crate::atmosphere::{ionex_slant_delay, Ionex};
16use crate::clock_stability::PowerLawNoiseType;
17use crate::constants::{C_M_S, F_L1_HZ, OMEGA_E_DOT_RAD_S};
18use crate::frame::{geodetic_to_itrf, itrf_to_geodetic, ItrfPositionM, Wgs84Geodetic};
19use crate::id::{GnssSatelliteId, GnssSystem};
20use crate::observables::{predict, ObservableEphemerisSource, ObservableState, ObservablesError};
21use crate::rinex_obs::{ObsEpoch, ObsEpochTime, ObsHeader, ObsValue, PgmRunByDate, RinexObs};
22use crate::spp::{
23 sat_model, Corrections, EphemerisSource, KlobucharCoeffs, SatModelEnv, SppIonosphere,
24 SppModelRecipe, SurfaceMet,
25};
26use crate::validate;
27
28pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
30
31pub const SCENARIO_ENGINE_VERSION: &str =
33 concat!(env!("CARGO_PKG_VERSION"), ":scenario-observables-v1");
34
35pub const DEFAULT_SCENARIO_SEED: u64 = 0x515c_1e7e_0b5e_a11d;
37
38const RINEX_QUANTIZATION: f64 = 1000.0;
39const FIXED_POINT_ITERS: usize = 8;
40const NOISE_STREAM_OBSERVABLE: u64 = 0x0b5e_a11d_f00d_0001;
41const NOISE_STREAM_RECEIVER_CLOCK: u64 = 0x0b5e_a11d_f00d_0002;
42const NOISE_STREAM_SAT_CLOCK: u64 = 0x0b5e_a11d_f00d_0003;
43
44#[derive(Debug, thiserror::Error)]
46pub enum ScenarioError {
47 #[error("invalid scenario {field}: {reason}")]
49 InvalidInput {
50 field: &'static str,
52 reason: &'static str,
54 },
55 #[error("scenario requires an external ephemeris source")]
57 ExternalSourceRequired,
58 #[error("external source identity mismatch for {field}: expected {expected}, got {actual}")]
60 ExternalSourceMismatch {
61 field: &'static str,
63 expected: String,
65 actual: String,
67 },
68 #[error("scenario requires a declared IONEX source")]
70 ExternalIonosphereRequired,
71 #[error("ionosphere evaluation failed: {0}")]
73 Ionosphere(String),
74 #[error("no ephemeris state for {satellite}")]
76 NoEphemeris {
77 satellite: GnssSatelliteId,
79 },
80 #[error("observable prediction failed: {0}")]
82 Observable(ObservablesError),
83 #[error("frame conversion failed: {0}")]
85 Frame(String),
86}
87
88#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct Scenario {
91 pub schema_version: u32,
93 pub seed: u64,
95 pub epochs: ScenarioEpochRange,
97 pub receiver: ScenarioReceiver,
99 pub constellation: ScenarioConstellation,
101 pub signals: Vec<ScenarioSignal>,
103 pub error_budget: ScenarioErrorBudget,
105}
106
107impl Scenario {
108 pub fn validate(&self) -> Result<(), ScenarioError> {
110 if self.schema_version != SCENARIO_SCHEMA_VERSION {
111 return Err(invalid("schema_version", "unsupported schema version"));
112 }
113 self.epochs.validate()?;
114 self.receiver.validate()?;
115 self.constellation.validate()?;
116 if self.signals.is_empty() {
117 return Err(invalid("signals", "must not be empty"));
118 }
119 for signal in &self.signals {
120 signal.validate()?;
121 }
122 self.error_budget.validate()?;
123 Ok(())
124 }
125
126 pub fn satellites(&self) -> Vec<GnssSatelliteId> {
128 match &self.constellation {
129 ScenarioConstellation::ExternalProducts { satellites, .. } => satellites.clone(),
130 ScenarioConstellation::SyntheticKeplerian { satellites } => {
131 satellites.iter().map(|sat| sat.satellite_id).collect()
132 }
133 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
139pub struct ScenarioEpochRange {
140 pub start_j2000_s: f64,
142 pub count: usize,
144 pub cadence_s: f64,
146}
147
148impl ScenarioEpochRange {
149 pub fn validate(&self) -> Result<(), ScenarioError> {
151 validate::finite(self.start_j2000_s, "epochs.start_j2000_s").map_err(map_field)?;
152 if self.count == 0 {
153 return Err(invalid("epochs.count", "must be positive"));
154 }
155 validate::finite_positive(self.cadence_s, "epochs.cadence_s").map_err(map_field)?;
156 Ok(())
157 }
158
159 pub fn epochs_j2000_s(&self) -> Vec<f64> {
161 (0..self.count)
162 .map(|idx| self.start_j2000_s + self.cadence_s * idx as f64)
163 .collect()
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
169#[serde(tag = "kind", rename_all = "snake_case")]
170pub enum ScenarioReceiver {
171 StaticGeodetic {
173 position: ScenarioGeodeticPosition,
175 },
176 KinematicWaypoints {
178 waypoints: Vec<ScenarioReceiverWaypoint>,
180 },
181}
182
183impl ScenarioReceiver {
184 pub fn validate(&self) -> Result<(), ScenarioError> {
186 match self {
187 Self::StaticGeodetic { position } => position.validate(),
188 Self::KinematicWaypoints { waypoints } => {
189 if waypoints.len() < 2 {
190 return Err(invalid("receiver.waypoints", "must contain at least two"));
191 }
192 let mut previous = None;
193 for waypoint in waypoints {
194 waypoint.validate()?;
195 if previous.is_some_and(|t| waypoint.offset_s <= t) {
196 return Err(invalid(
197 "receiver.waypoints.offset_s",
198 "must increase strictly",
199 ));
200 }
201 previous = Some(waypoint.offset_s);
202 }
203 Ok(())
204 }
205 }
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
211pub struct ScenarioGeodeticPosition {
212 pub lat_rad: f64,
214 pub lon_rad: f64,
216 pub height_m: f64,
218}
219
220impl ScenarioGeodeticPosition {
221 pub fn to_wgs84(self) -> Result<Wgs84Geodetic, ScenarioError> {
223 Wgs84Geodetic::new(self.lat_rad, self.lon_rad, self.height_m)
224 .map_err(|error| ScenarioError::Frame(error.to_string()))
225 }
226
227 pub fn validate(&self) -> Result<(), ScenarioError> {
229 self.to_wgs84().map(|_| ())
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
235pub struct ScenarioReceiverWaypoint {
236 pub offset_s: f64,
238 pub position: ScenarioGeodeticPosition,
240 pub velocity_ecef_m_s: Option<[f64; 3]>,
242}
243
244impl ScenarioReceiverWaypoint {
245 pub fn validate(&self) -> Result<(), ScenarioError> {
247 validate::finite(self.offset_s, "receiver.waypoint.offset_s").map_err(map_field)?;
248 self.position.validate()?;
249 if let Some(velocity) = self.velocity_ecef_m_s {
250 validate::finite_vec3(velocity, "receiver.waypoint.velocity_ecef_m_s")
251 .map_err(map_field)?;
252 }
253 Ok(())
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum ScenarioExternalProductKind {
261 Sp3,
263 Broadcast,
265 Tle,
267 Ionex,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
273pub struct ScenarioExternalProduct {
274 pub kind: ScenarioExternalProductKind,
276 pub product_id: String,
278 pub content_digest: String,
280}
281
282impl ScenarioExternalProduct {
283 pub fn validate(&self, field: &'static str) -> Result<(), ScenarioError> {
285 if self.product_id.is_empty() {
286 return Err(invalid(field, "product_id must not be empty"));
287 }
288 if self.content_digest.is_empty() {
289 return Err(invalid(field, "content_digest must not be empty"));
290 }
291 if !self.product_id.is_ascii() || !self.content_digest.is_ascii() {
292 return Err(invalid(field, "identity fields must be ASCII"));
293 }
294 Ok(())
295 }
296
297 fn label(&self) -> String {
298 format!(
299 "{:?}:{}:{}",
300 self.kind, self.product_id, self.content_digest
301 )
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
307#[serde(tag = "kind", rename_all = "snake_case")]
308pub enum ScenarioConstellation {
309 ExternalProducts {
311 source: ScenarioExternalProduct,
313 satellites: Vec<GnssSatelliteId>,
315 },
316 SyntheticKeplerian {
318 satellites: Vec<SyntheticKeplerOrbit>,
320 },
321}
322
323impl ScenarioConstellation {
324 pub fn validate(&self) -> Result<(), ScenarioError> {
326 match self {
327 Self::ExternalProducts { source, satellites } => {
328 source.validate("constellation.source")?;
329 if source.kind == ScenarioExternalProductKind::Ionex {
330 return Err(invalid(
331 "constellation.source.kind",
332 "must be sp3, broadcast, or tle",
333 ));
334 }
335 if satellites.is_empty() {
336 return Err(invalid("constellation.satellites", "must not be empty"));
337 }
338 Ok(())
339 }
340 Self::SyntheticKeplerian { satellites } => {
341 if satellites.is_empty() {
342 return Err(invalid("constellation.satellites", "must not be empty"));
343 }
344 for satellite in satellites {
345 satellite.validate()?;
346 }
347 Ok(())
348 }
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
355pub struct SyntheticKeplerOrbit {
356 pub satellite_id: GnssSatelliteId,
358 pub semi_major_axis_m: f64,
360 pub eccentricity: f64,
362 pub inclination_rad: f64,
364 pub raan_rad: f64,
366 pub arg_perigee_rad: f64,
368 pub mean_anomaly_rad: f64,
370 pub epoch_j2000_s: f64,
372 pub clock_bias_s: f64,
374 pub clock_drift_s_s: f64,
376}
377
378impl SyntheticKeplerOrbit {
379 pub fn validate(&self) -> Result<(), ScenarioError> {
381 validate::finite_positive(self.semi_major_axis_m, "orbit.semi_major_axis_m")
382 .map_err(map_field)?;
383 validate::finite(self.eccentricity, "orbit.eccentricity").map_err(map_field)?;
384 if !(0.0..1.0).contains(&self.eccentricity) {
385 return Err(invalid("orbit.eccentricity", "must be in [0, 1)"));
386 }
387 for (field, value) in [
388 ("orbit.inclination_rad", self.inclination_rad),
389 ("orbit.raan_rad", self.raan_rad),
390 ("orbit.arg_perigee_rad", self.arg_perigee_rad),
391 ("orbit.mean_anomaly_rad", self.mean_anomaly_rad),
392 ("orbit.epoch_j2000_s", self.epoch_j2000_s),
393 ("orbit.clock_bias_s", self.clock_bias_s),
394 ("orbit.clock_drift_s_s", self.clock_drift_s_s),
395 ] {
396 validate::finite(value, field).map_err(map_field)?;
397 }
398 Ok(())
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
404pub struct ScenarioSignal {
405 pub system: GnssSystem,
407 pub code_observable: String,
409 pub phase_observable: String,
411 pub doppler_observable: String,
413 pub carrier_hz: f64,
415 pub carrier_phase_bias_cycles: f64,
417}
418
419impl ScenarioSignal {
420 pub fn l1_ca(system: GnssSystem) -> Self {
422 Self {
423 system,
424 code_observable: "C1C".to_string(),
425 phase_observable: "L1C".to_string(),
426 doppler_observable: "D1C".to_string(),
427 carrier_hz: F_L1_HZ,
428 carrier_phase_bias_cycles: 0.0,
429 }
430 }
431
432 pub fn validate(&self) -> Result<(), ScenarioError> {
434 validate_code(&self.code_observable, "signal.code_observable", b'C')?;
435 validate_code(&self.phase_observable, "signal.phase_observable", b'L')?;
436 validate_code(&self.doppler_observable, "signal.doppler_observable", b'D')?;
437 validate::finite_positive(self.carrier_hz, "signal.carrier_hz").map_err(map_field)?;
438 validate::finite(
439 self.carrier_phase_bias_cycles,
440 "signal.carrier_phase_bias_cycles",
441 )
442 .map_err(map_field)?;
443 Ok(())
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
449pub struct ScenarioErrorBudget {
450 pub receiver_clock: ScenarioClockModel,
452 pub satellite_clock: ScenarioClockModel,
454 pub ionosphere: ScenarioIonosphereModel,
456 pub troposphere: ScenarioTroposphereModel,
458 pub thermal_noise: ScenarioThermalNoise,
460 pub multipath: ScenarioSpecularMultipath,
462 pub elevation_mask_deg: f64,
464}
465
466impl Default for ScenarioErrorBudget {
467 fn default() -> Self {
468 Self {
469 receiver_clock: ScenarioClockModel::disabled(),
470 satellite_clock: ScenarioClockModel::disabled(),
471 ionosphere: ScenarioIonosphereModel::Off,
472 troposphere: ScenarioTroposphereModel::Off,
473 thermal_noise: ScenarioThermalNoise::disabled(),
474 multipath: ScenarioSpecularMultipath::disabled(),
475 elevation_mask_deg: 0.0,
476 }
477 }
478}
479
480impl ScenarioErrorBudget {
481 pub fn validate(&self) -> Result<(), ScenarioError> {
483 self.receiver_clock
484 .validate("error_budget.receiver_clock")?;
485 self.satellite_clock
486 .validate("error_budget.satellite_clock")?;
487 self.ionosphere.validate()?;
488 self.troposphere.validate()?;
489 self.thermal_noise.validate()?;
490 self.multipath.validate()?;
491 validate::finite(self.elevation_mask_deg, "error_budget.elevation_mask_deg")
492 .map_err(map_field)?;
493 if !(-90.0..=90.0).contains(&self.elevation_mask_deg) {
494 return Err(invalid(
495 "error_budget.elevation_mask_deg",
496 "must be in [-90, 90]",
497 ));
498 }
499 Ok(())
500 }
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
505pub struct ScenarioClockModel {
506 pub enabled: bool,
508 pub bias_s: f64,
510 pub drift_s_s: f64,
512 pub power_law_coefficients: [f64; 5],
514}
515
516impl ScenarioClockModel {
517 pub const fn disabled() -> Self {
519 Self {
520 enabled: false,
521 bias_s: 0.0,
522 drift_s_s: 0.0,
523 power_law_coefficients: [0.0; 5],
524 }
525 }
526
527 pub fn validate(&self, prefix: &'static str) -> Result<(), ScenarioError> {
529 validate::finite(self.bias_s, prefix).map_err(map_field)?;
530 validate::finite(self.drift_s_s, prefix).map_err(map_field)?;
531 for &coefficient in &self.power_law_coefficients {
532 validate::finite(coefficient, prefix).map_err(map_field)?;
533 if coefficient < 0.0 {
534 return Err(invalid(
535 prefix,
536 "power-law coefficients must be non-negative",
537 ));
538 }
539 }
540 Ok(())
541 }
542}
543
544#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
546#[serde(tag = "kind", rename_all = "snake_case")]
547pub enum ScenarioIonosphereModel {
548 Off,
550 Klobuchar {
552 alpha: [f64; 4],
554 beta: [f64; 4],
556 },
557 SuppliedIonex {
559 source: ScenarioExternalProduct,
561 },
562}
563
564impl ScenarioIonosphereModel {
565 pub fn validate(&self) -> Result<(), ScenarioError> {
567 match self {
568 Self::Off => Ok(()),
569 Self::Klobuchar { alpha, beta } => {
570 validate::finite_slice(alpha, "error_budget.ionosphere.alpha")
571 .map_err(map_field)?;
572 validate::finite_slice(beta, "error_budget.ionosphere.beta").map_err(map_field)?;
573 Ok(())
574 }
575 Self::SuppliedIonex { source } => {
576 source.validate("error_budget.ionosphere.source")?;
577 if source.kind != ScenarioExternalProductKind::Ionex {
578 return Err(invalid(
579 "error_budget.ionosphere.source.kind",
580 "must be ionex",
581 ));
582 }
583 Ok(())
584 }
585 }
586 }
587
588 fn corrections(&self) -> Corrections {
589 Corrections {
590 ionosphere: matches!(self, Self::Klobuchar { .. }),
591 troposphere: false,
592 }
593 }
594
595 fn coefficients(&self) -> KlobucharCoeffs {
596 match self {
597 Self::Off => KlobucharCoeffs {
598 alpha: [0.0; 4],
599 beta: [0.0; 4],
600 },
601 Self::Klobuchar { alpha, beta } => KlobucharCoeffs {
602 alpha: *alpha,
603 beta: *beta,
604 },
605 Self::SuppliedIonex { .. } => KlobucharCoeffs {
606 alpha: [0.0; 4],
607 beta: [0.0; 4],
608 },
609 }
610 }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
615#[serde(tag = "kind", rename_all = "snake_case")]
616pub enum ScenarioTroposphereModel {
617 Off,
619 SaastamoinenNiell {
621 pressure_hpa: f64,
623 temperature_k: f64,
625 relative_humidity: f64,
627 },
628}
629
630impl ScenarioTroposphereModel {
631 pub fn validate(&self) -> Result<(), ScenarioError> {
633 match self {
634 Self::Off => Ok(()),
635 Self::SaastamoinenNiell {
636 pressure_hpa,
637 temperature_k,
638 relative_humidity,
639 } => {
640 validate::finite_positive(*pressure_hpa, "error_budget.troposphere.pressure_hpa")
641 .map_err(map_field)?;
642 validate::finite_positive(*temperature_k, "error_budget.troposphere.temperature_k")
643 .map_err(map_field)?;
644 validate::finite(
645 *relative_humidity,
646 "error_budget.troposphere.relative_humidity",
647 )
648 .map_err(map_field)?;
649 if !(0.0..=1.0).contains(relative_humidity) {
650 return Err(invalid(
651 "error_budget.troposphere.relative_humidity",
652 "must be in [0, 1]",
653 ));
654 }
655 Ok(())
656 }
657 }
658 }
659
660 fn corrections(self) -> Corrections {
661 Corrections {
662 ionosphere: false,
663 troposphere: matches!(self, Self::SaastamoinenNiell { .. }),
664 }
665 }
666
667 fn surface_met(self) -> SurfaceMet {
668 match self {
669 Self::Off => SurfaceMet::default(),
670 Self::SaastamoinenNiell {
671 pressure_hpa,
672 temperature_k,
673 relative_humidity,
674 } => SurfaceMet {
675 pressure_hpa,
676 temperature_k,
677 relative_humidity,
678 },
679 }
680 }
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
685pub struct ScenarioThermalNoise {
686 pub enabled: bool,
688 pub pseudorange_sigma_m: f64,
690 pub carrier_phase_sigma_m: f64,
692 pub doppler_sigma_hz: f64,
694}
695
696impl ScenarioThermalNoise {
697 pub const fn disabled() -> Self {
699 Self {
700 enabled: false,
701 pseudorange_sigma_m: 0.0,
702 carrier_phase_sigma_m: 0.0,
703 doppler_sigma_hz: 0.0,
704 }
705 }
706
707 pub fn validate(&self) -> Result<(), ScenarioError> {
709 for (field, value) in [
710 (
711 "error_budget.thermal_noise.pseudorange_sigma_m",
712 self.pseudorange_sigma_m,
713 ),
714 (
715 "error_budget.thermal_noise.carrier_phase_sigma_m",
716 self.carrier_phase_sigma_m,
717 ),
718 (
719 "error_budget.thermal_noise.doppler_sigma_hz",
720 self.doppler_sigma_hz,
721 ),
722 ] {
723 validate::finite(value, field).map_err(map_field)?;
724 if value < 0.0 {
725 return Err(invalid(field, "must be non-negative"));
726 }
727 }
728 Ok(())
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
734pub struct ScenarioSpecularMultipath {
735 pub enabled: bool,
737 pub amplitude_m: f64,
739 pub reflector_height_m: f64,
741 pub phase_rad: f64,
743}
744
745impl ScenarioSpecularMultipath {
746 pub const fn disabled() -> Self {
748 Self {
749 enabled: false,
750 amplitude_m: 0.0,
751 reflector_height_m: 0.0,
752 phase_rad: 0.0,
753 }
754 }
755
756 pub fn validate(&self) -> Result<(), ScenarioError> {
758 for (field, value) in [
759 ("error_budget.multipath.amplitude_m", self.amplitude_m),
760 (
761 "error_budget.multipath.reflector_height_m",
762 self.reflector_height_m,
763 ),
764 ("error_budget.multipath.phase_rad", self.phase_rad),
765 ] {
766 validate::finite(value, field).map_err(map_field)?;
767 }
768 if self.amplitude_m < 0.0 || self.reflector_height_m < 0.0 {
769 return Err(invalid(
770 "error_budget.multipath",
771 "amplitude and reflector height must be non-negative",
772 ));
773 }
774 Ok(())
775 }
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
780pub struct SyntheticReceiverTruth {
781 pub t_rx_j2000_s: f64,
783 pub position_ecef_m: [f64; 3],
785 pub velocity_ecef_m_s: [f64; 3],
787 pub clock_m: f64,
789 pub clock_rate_m_s: f64,
791}
792
793#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
795pub struct SyntheticObservableArrays {
796 pub epoch_offsets: Vec<usize>,
798 pub epoch_index: Vec<usize>,
800 pub satellite_id: Vec<GnssSatelliteId>,
802 pub code_observable: Vec<String>,
804 pub phase_observable: Vec<String>,
806 pub doppler_observable: Vec<String>,
808 pub carrier_hz: Vec<f64>,
810 pub pseudorange_m: Vec<f64>,
812 pub carrier_phase_cycles: Vec<f64>,
814 pub doppler_hz: Vec<f64>,
816}
817
818impl SyntheticObservableArrays {
819 fn new(epoch_count: usize) -> Self {
820 Self {
821 epoch_offsets: Vec::with_capacity(epoch_count + 1),
822 epoch_index: Vec::new(),
823 satellite_id: Vec::new(),
824 code_observable: Vec::new(),
825 phase_observable: Vec::new(),
826 doppler_observable: Vec::new(),
827 carrier_hz: Vec::new(),
828 pseudorange_m: Vec::new(),
829 carrier_phase_cycles: Vec::new(),
830 doppler_hz: Vec::new(),
831 }
832 }
833
834 fn len(&self) -> usize {
835 self.pseudorange_m.len()
836 }
837}
838
839#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
841pub struct SyntheticTermArrays {
842 pub geometric_range_m: Vec<f64>,
844 pub satellite_clock_m: Vec<f64>,
846 pub receiver_clock_m: Vec<f64>,
848 pub satellite_clock_error_m: Vec<f64>,
850 pub ionosphere_m: Vec<f64>,
852 pub troposphere_m: Vec<f64>,
854 pub thermal_noise_m: Vec<f64>,
856 pub multipath_m: Vec<f64>,
858 pub quantization_m: Vec<f64>,
860 pub carrier_phase_geometric_cycles: Vec<f64>,
862 pub carrier_phase_receiver_clock_cycles: Vec<f64>,
864 pub carrier_phase_satellite_clock_cycles: Vec<f64>,
866 pub carrier_phase_satellite_clock_error_cycles: Vec<f64>,
868 pub carrier_phase_ionosphere_cycles: Vec<f64>,
870 pub carrier_phase_troposphere_cycles: Vec<f64>,
872 pub carrier_phase_thermal_noise_cycles: Vec<f64>,
874 pub carrier_phase_bias_cycles: Vec<f64>,
876 pub carrier_phase_quantization_cycles: Vec<f64>,
878 pub doppler_satellite_motion_hz: Vec<f64>,
880 pub doppler_receiver_motion_hz: Vec<f64>,
882 pub doppler_satellite_clock_hz: Vec<f64>,
884 pub doppler_receiver_clock_hz: Vec<f64>,
886 pub doppler_satellite_clock_error_hz: Vec<f64>,
888 pub doppler_thermal_noise_hz: Vec<f64>,
890 pub doppler_quantization_hz: Vec<f64>,
892}
893
894impl SyntheticTermArrays {
895 fn new() -> Self {
896 Self {
897 geometric_range_m: Vec::new(),
898 satellite_clock_m: Vec::new(),
899 receiver_clock_m: Vec::new(),
900 satellite_clock_error_m: Vec::new(),
901 ionosphere_m: Vec::new(),
902 troposphere_m: Vec::new(),
903 thermal_noise_m: Vec::new(),
904 multipath_m: Vec::new(),
905 quantization_m: Vec::new(),
906 carrier_phase_geometric_cycles: Vec::new(),
907 carrier_phase_receiver_clock_cycles: Vec::new(),
908 carrier_phase_satellite_clock_cycles: Vec::new(),
909 carrier_phase_satellite_clock_error_cycles: Vec::new(),
910 carrier_phase_ionosphere_cycles: Vec::new(),
911 carrier_phase_troposphere_cycles: Vec::new(),
912 carrier_phase_thermal_noise_cycles: Vec::new(),
913 carrier_phase_bias_cycles: Vec::new(),
914 carrier_phase_quantization_cycles: Vec::new(),
915 doppler_satellite_motion_hz: Vec::new(),
916 doppler_receiver_motion_hz: Vec::new(),
917 doppler_satellite_clock_hz: Vec::new(),
918 doppler_receiver_clock_hz: Vec::new(),
919 doppler_satellite_clock_error_hz: Vec::new(),
920 doppler_thermal_noise_hz: Vec::new(),
921 doppler_quantization_hz: Vec::new(),
922 }
923 }
924
925 fn push(&mut self, terms: ObservationTerms) {
926 self.geometric_range_m.push(terms.geometric_range_m);
927 self.satellite_clock_m.push(terms.satellite_clock_m);
928 self.receiver_clock_m.push(terms.receiver_clock_m);
929 self.satellite_clock_error_m
930 .push(terms.satellite_clock_error_m);
931 self.ionosphere_m.push(terms.ionosphere_m);
932 self.troposphere_m.push(terms.troposphere_m);
933 self.thermal_noise_m.push(terms.thermal_noise_m);
934 self.multipath_m.push(terms.multipath_m);
935 self.quantization_m.push(terms.quantization_m);
936 self.carrier_phase_geometric_cycles
937 .push(terms.carrier_phase_geometric_cycles);
938 self.carrier_phase_receiver_clock_cycles
939 .push(terms.carrier_phase_receiver_clock_cycles);
940 self.carrier_phase_satellite_clock_cycles
941 .push(terms.carrier_phase_satellite_clock_cycles);
942 self.carrier_phase_satellite_clock_error_cycles
943 .push(terms.carrier_phase_satellite_clock_error_cycles);
944 self.carrier_phase_ionosphere_cycles
945 .push(terms.carrier_phase_ionosphere_cycles);
946 self.carrier_phase_troposphere_cycles
947 .push(terms.carrier_phase_troposphere_cycles);
948 self.carrier_phase_thermal_noise_cycles
949 .push(terms.carrier_phase_thermal_noise_cycles);
950 self.carrier_phase_bias_cycles
951 .push(terms.carrier_phase_bias_cycles);
952 self.carrier_phase_quantization_cycles
953 .push(terms.carrier_phase_quantization_cycles);
954 self.doppler_satellite_motion_hz
955 .push(terms.doppler_satellite_motion_hz);
956 self.doppler_receiver_motion_hz
957 .push(terms.doppler_receiver_motion_hz);
958 self.doppler_satellite_clock_hz
959 .push(terms.doppler_satellite_clock_hz);
960 self.doppler_receiver_clock_hz
961 .push(terms.doppler_receiver_clock_hz);
962 self.doppler_satellite_clock_error_hz
963 .push(terms.doppler_satellite_clock_error_hz);
964 self.doppler_thermal_noise_hz
965 .push(terms.doppler_thermal_noise_hz);
966 self.doppler_quantization_hz
967 .push(terms.doppler_quantization_hz);
968 }
969
970 pub fn pseudorange_sum_m(&self, index: usize) -> Option<f64> {
972 let mut value = *self.geometric_range_m.get(index)?;
973 value += *self.receiver_clock_m.get(index)?;
974 value += *self.satellite_clock_m.get(index)?;
975 value += *self.satellite_clock_error_m.get(index)?;
976 value += *self.ionosphere_m.get(index)?;
977 value += *self.troposphere_m.get(index)?;
978 value += *self.thermal_noise_m.get(index)?;
979 value += *self.multipath_m.get(index)?;
980 value += *self.quantization_m.get(index)?;
981 Some(value)
982 }
983
984 pub fn carrier_phase_sum_cycles(&self, index: usize) -> Option<f64> {
986 let mut value = *self.carrier_phase_geometric_cycles.get(index)?;
987 value += *self.carrier_phase_receiver_clock_cycles.get(index)?;
988 value += *self.carrier_phase_satellite_clock_cycles.get(index)?;
989 value += *self.carrier_phase_satellite_clock_error_cycles.get(index)?;
990 value += *self.carrier_phase_ionosphere_cycles.get(index)?;
991 value += *self.carrier_phase_troposphere_cycles.get(index)?;
992 value += *self.carrier_phase_thermal_noise_cycles.get(index)?;
993 value += *self.carrier_phase_bias_cycles.get(index)?;
994 value += *self.carrier_phase_quantization_cycles.get(index)?;
995 Some(value)
996 }
997
998 pub fn doppler_sum_hz(&self, index: usize) -> Option<f64> {
1000 let mut value = *self.doppler_satellite_motion_hz.get(index)?;
1001 value += *self.doppler_receiver_motion_hz.get(index)?;
1002 value += *self.doppler_satellite_clock_hz.get(index)?;
1003 value += *self.doppler_receiver_clock_hz.get(index)?;
1004 value += *self.doppler_satellite_clock_error_hz.get(index)?;
1005 value += *self.doppler_thermal_noise_hz.get(index)?;
1006 value += *self.doppler_quantization_hz.get(index)?;
1007 Some(value)
1008 }
1009}
1010
1011#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1013pub struct SyntheticObservationSet {
1014 pub schema_version: u32,
1016 pub engine_version: String,
1018 pub seed: u64,
1020 pub receiver_truth: Vec<SyntheticReceiverTruth>,
1022 pub observations: SyntheticObservableArrays,
1024 pub truth_terms: SyntheticTermArrays,
1026}
1027
1028impl SyntheticObservationSet {
1029 pub fn observation_count(&self) -> usize {
1031 self.observations.len()
1032 }
1033
1034 pub fn determinism_fingerprint(&self) -> u64 {
1036 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
1037 hash_u64(&mut hash, self.schema_version as u64);
1038 hash_u64(&mut hash, self.seed);
1039 for byte in self.engine_version.as_bytes() {
1040 hash_u64(&mut hash, u64::from(*byte));
1041 }
1042 for state in &self.receiver_truth {
1043 hash_f64(&mut hash, state.t_rx_j2000_s);
1044 for value in state.position_ecef_m {
1045 hash_f64(&mut hash, value);
1046 }
1047 for value in state.velocity_ecef_m_s {
1048 hash_f64(&mut hash, value);
1049 }
1050 hash_f64(&mut hash, state.clock_m);
1051 hash_f64(&mut hash, state.clock_rate_m_s);
1052 }
1053 for &epoch_offset in &self.observations.epoch_offsets {
1054 hash_u64(&mut hash, epoch_offset as u64);
1055 }
1056 for index in 0..self.observation_count() {
1057 hash_u64(&mut hash, self.observations.epoch_index[index] as u64);
1058 hash_u64(
1059 &mut hash,
1060 satellite_hash(self.observations.satellite_id[index]),
1061 );
1062 hash_str(&mut hash, &self.observations.code_observable[index]);
1063 hash_str(&mut hash, &self.observations.phase_observable[index]);
1064 hash_str(&mut hash, &self.observations.doppler_observable[index]);
1065 hash_f64(&mut hash, self.observations.carrier_hz[index]);
1066 hash_f64(&mut hash, self.observations.pseudorange_m[index]);
1067 hash_f64(&mut hash, self.observations.carrier_phase_cycles[index]);
1068 hash_f64(&mut hash, self.observations.doppler_hz[index]);
1069 hash_f64(&mut hash, self.truth_terms.geometric_range_m[index]);
1070 hash_f64(&mut hash, self.truth_terms.satellite_clock_m[index]);
1071 hash_f64(&mut hash, self.truth_terms.receiver_clock_m[index]);
1072 hash_f64(&mut hash, self.truth_terms.satellite_clock_error_m[index]);
1073 hash_f64(&mut hash, self.truth_terms.ionosphere_m[index]);
1074 hash_f64(&mut hash, self.truth_terms.troposphere_m[index]);
1075 hash_f64(&mut hash, self.truth_terms.thermal_noise_m[index]);
1076 hash_f64(&mut hash, self.truth_terms.multipath_m[index]);
1077 hash_f64(&mut hash, self.truth_terms.quantization_m[index]);
1078 hash_f64(
1079 &mut hash,
1080 self.truth_terms.carrier_phase_geometric_cycles[index],
1081 );
1082 hash_f64(
1083 &mut hash,
1084 self.truth_terms.carrier_phase_receiver_clock_cycles[index],
1085 );
1086 hash_f64(
1087 &mut hash,
1088 self.truth_terms.carrier_phase_satellite_clock_cycles[index],
1089 );
1090 hash_f64(
1091 &mut hash,
1092 self.truth_terms.carrier_phase_satellite_clock_error_cycles[index],
1093 );
1094 hash_f64(
1095 &mut hash,
1096 self.truth_terms.carrier_phase_ionosphere_cycles[index],
1097 );
1098 hash_f64(
1099 &mut hash,
1100 self.truth_terms.carrier_phase_troposphere_cycles[index],
1101 );
1102 hash_f64(
1103 &mut hash,
1104 self.truth_terms.carrier_phase_thermal_noise_cycles[index],
1105 );
1106 hash_f64(&mut hash, self.truth_terms.carrier_phase_bias_cycles[index]);
1107 hash_f64(
1108 &mut hash,
1109 self.truth_terms.carrier_phase_quantization_cycles[index],
1110 );
1111 hash_f64(
1112 &mut hash,
1113 self.truth_terms.doppler_satellite_motion_hz[index],
1114 );
1115 hash_f64(
1116 &mut hash,
1117 self.truth_terms.doppler_receiver_motion_hz[index],
1118 );
1119 hash_f64(
1120 &mut hash,
1121 self.truth_terms.doppler_satellite_clock_hz[index],
1122 );
1123 hash_f64(&mut hash, self.truth_terms.doppler_receiver_clock_hz[index]);
1124 hash_f64(
1125 &mut hash,
1126 self.truth_terms.doppler_satellite_clock_error_hz[index],
1127 );
1128 hash_f64(&mut hash, self.truth_terms.doppler_thermal_noise_hz[index]);
1129 hash_f64(&mut hash, self.truth_terms.doppler_quantization_hz[index]);
1130 }
1131 hash
1132 }
1133
1134 pub fn to_rinex_observation_file(&self) -> RinexObs {
1136 let obs_codes = self.rinex_obs_codes();
1137 let mut epochs = Vec::with_capacity(self.receiver_truth.len());
1138 for epoch_index in 0..self.receiver_truth.len() {
1139 let start = self.observations.epoch_offsets[epoch_index];
1140 let end = self.observations.epoch_offsets[epoch_index + 1];
1141 let mut sats = BTreeMap::new();
1142 for obs_index in start..end {
1143 let sat = self.observations.satellite_id[obs_index];
1144 let codes = obs_codes.get(&sat.system).expect("system code list exists");
1145 let values = sats.entry(sat).or_insert_with(|| {
1146 vec![
1147 ObsValue {
1148 value: None,
1149 lli: None,
1150 ssi: None,
1151 };
1152 codes.len()
1153 ]
1154 });
1155 set_obs_value(
1156 values,
1157 codes,
1158 &self.observations.code_observable[obs_index],
1159 round_rinex(self.observations.pseudorange_m[obs_index]),
1160 );
1161 set_obs_value(
1162 values,
1163 codes,
1164 &self.observations.phase_observable[obs_index],
1165 round_rinex(self.observations.carrier_phase_cycles[obs_index]),
1166 );
1167 set_obs_value(
1168 values,
1169 codes,
1170 &self.observations.doppler_observable[obs_index],
1171 round_rinex(self.observations.doppler_hz[obs_index]),
1172 );
1173 }
1174 epochs.push(ObsEpoch {
1175 epoch: obs_epoch_time(self.receiver_truth[epoch_index].t_rx_j2000_s),
1176 flag: 0,
1177 rcv_clock_offset_s: None,
1178 epoch_picoseconds: None,
1179 declared_record_count: sats.len(),
1180 special_record_count: 0,
1181 sats,
1182 });
1183 }
1184
1185 let first = self
1186 .receiver_truth
1187 .first()
1188 .map(|state| (obs_epoch_time(state.t_rx_j2000_s), TimeScale::Gpst));
1189 let last = self
1190 .receiver_truth
1191 .last()
1192 .map(|state| (obs_epoch_time(state.t_rx_j2000_s), TimeScale::Gpst));
1193 let approx_position_m = self
1194 .receiver_truth
1195 .first()
1196 .map(|state| state.position_ecef_m);
1197
1198 RinexObs {
1199 header: ObsHeader {
1200 version: 3.05,
1201 approx_position_m,
1202 antenna_delta_hen_m: None,
1203 obs_codes,
1204 program_run_by_date: Some(PgmRunByDate {
1205 program: "SIDEREON-SCENARIO".to_string(),
1206 run_by: "sidereon-core".to_string(),
1207 date: "SCENARIO-V1".to_string(),
1208 }),
1209 comments: vec![
1210 "Synthetic observations from sidereon-core scenario engine".to_string()
1211 ],
1212 marker_number: None,
1213 marker_type: Some("SIMULATED".to_string()),
1214 observer: None,
1215 agency: None,
1216 receiver: None,
1217 antenna: None,
1218 interval_s: self
1219 .receiver_truth
1220 .windows(2)
1221 .next()
1222 .map(|pair| pair[1].t_rx_j2000_s - pair[0].t_rx_j2000_s),
1223 time_of_first_obs: first,
1224 time_of_last_obs: last,
1225 n_satellites: Some(
1226 self.observations
1227 .satellite_id
1228 .iter()
1229 .copied()
1230 .collect::<std::collections::BTreeSet<_>>()
1231 .len(),
1232 ),
1233 prn_obs_counts: BTreeMap::new(),
1234 phase_shifts: Vec::new(),
1235 scale_factors: Vec::new(),
1236 glonass_slots: BTreeMap::new(),
1237 glonass_cod_phs_bis: None,
1238 signal_strength_unit: None,
1239 leap_seconds: None,
1240 marker_name: Some("SYNTHETIC".to_string()),
1241 unretained_header_labels: Vec::new(),
1242 },
1243 epochs,
1244 skipped_records: 0,
1245 }
1246 }
1247
1248 pub fn to_rinex_string(&self) -> String {
1250 self.to_rinex_observation_file().to_rinex_string()
1251 }
1252
1253 pub fn spp_observations_for_epoch(&self, epoch_index: usize) -> Vec<crate::spp::Observation> {
1255 let Some((&start, &end)) = self
1256 .observations
1257 .epoch_offsets
1258 .get(epoch_index)
1259 .zip(self.observations.epoch_offsets.get(epoch_index + 1))
1260 else {
1261 return Vec::new();
1262 };
1263 let mut by_sat = BTreeMap::new();
1264 for index in start..end {
1265 by_sat
1266 .entry(self.observations.satellite_id[index])
1267 .or_insert(self.observations.pseudorange_m[index]);
1268 }
1269 by_sat
1270 .into_iter()
1271 .map(|(satellite_id, pseudorange_m)| crate::spp::Observation {
1272 satellite_id,
1273 pseudorange_m,
1274 })
1275 .collect()
1276 }
1277
1278 fn rinex_obs_codes(&self) -> BTreeMap<GnssSystem, Vec<String>> {
1279 let mut codes: BTreeMap<GnssSystem, Vec<String>> = BTreeMap::new();
1280 for index in 0..self.observation_count() {
1281 let system = self.observations.satellite_id[index].system;
1282 let list = codes.entry(system).or_default();
1283 push_unique_code(list, &self.observations.code_observable[index]);
1284 push_unique_code(list, &self.observations.phase_observable[index]);
1285 push_unique_code(list, &self.observations.doppler_observable[index]);
1286 }
1287 codes
1288 }
1289}
1290
1291#[derive(Debug)]
1293pub struct DeclaredScenarioSource<'a, E: ?Sized> {
1294 source: &'a E,
1295 identity: ScenarioExternalProduct,
1296}
1297
1298impl<'a, E: ?Sized> DeclaredScenarioSource<'a, E> {
1299 pub fn new(source: &'a E, identity: ScenarioExternalProduct) -> Self {
1301 Self { source, identity }
1302 }
1303
1304 pub fn identity(&self) -> &ScenarioExternalProduct {
1306 &self.identity
1307 }
1308
1309 pub fn source(&self) -> &'a E {
1311 self.source
1312 }
1313}
1314
1315impl<E: EphemerisSource + ?Sized> EphemerisSource for DeclaredScenarioSource<'_, E> {
1316 fn position_clock_at_j2000_s(
1317 &self,
1318 sat: GnssSatelliteId,
1319 t_j2000_s: f64,
1320 ) -> Option<([f64; 3], f64)> {
1321 self.source.position_clock_at_j2000_s(sat, t_j2000_s)
1322 }
1323}
1324
1325impl<E: ObservableEphemerisSource + ?Sized> ObservableEphemerisSource
1326 for DeclaredScenarioSource<'_, E>
1327{
1328 fn observable_state_at_j2000_s(
1329 &self,
1330 sat: GnssSatelliteId,
1331 t_j2000_s: f64,
1332 ) -> Result<ObservableState, ObservablesError> {
1333 self.source.observable_state_at_j2000_s(sat, t_j2000_s)
1334 }
1335}
1336
1337#[derive(Debug)]
1338struct SourceTranscript<'a, E> {
1339 source: &'a E,
1340 hash: Cell<u64>,
1341}
1342
1343impl<'a, E> SourceTranscript<'a, E> {
1344 fn new(source: &'a E) -> Self {
1345 Self {
1346 source,
1347 hash: Cell::new(0xcbf2_9ce4_8422_2325),
1348 }
1349 }
1350
1351 fn digest(&self) -> String {
1352 fingerprint_label(self.hash.get())
1353 }
1354
1355 fn hash_query(&self, tag: u64, sat: GnssSatelliteId, t_j2000_s: f64) -> u64 {
1356 let mut hash = self.hash.get();
1357 hash_u64(&mut hash, tag);
1358 hash_u64(&mut hash, satellite_hash(sat));
1359 hash_f64(&mut hash, t_j2000_s);
1360 hash
1361 }
1362
1363 fn store_hash(&self, hash: u64) {
1364 self.hash.set(hash);
1365 }
1366}
1367
1368impl<E: EphemerisSource> EphemerisSource for SourceTranscript<'_, E> {
1369 fn position_clock_at_j2000_s(
1370 &self,
1371 sat: GnssSatelliteId,
1372 t_j2000_s: f64,
1373 ) -> Option<([f64; 3], f64)> {
1374 let result = self.source.position_clock_at_j2000_s(sat, t_j2000_s);
1375 let mut hash = self.hash_query(0x4550_4845_4d45_5249, sat, t_j2000_s);
1376 match result {
1377 Some((position, clock)) => {
1378 hash_u64(&mut hash, 1);
1379 for value in position {
1380 hash_f64(&mut hash, value);
1381 }
1382 hash_f64(&mut hash, clock);
1383 }
1384 None => hash_u64(&mut hash, 0),
1385 }
1386 self.store_hash(hash);
1387 result
1388 }
1389}
1390
1391impl<E: ObservableEphemerisSource> ObservableEphemerisSource for SourceTranscript<'_, E> {
1392 fn observable_state_at_j2000_s(
1393 &self,
1394 sat: GnssSatelliteId,
1395 t_j2000_s: f64,
1396 ) -> Result<ObservableState, ObservablesError> {
1397 let result = self.source.observable_state_at_j2000_s(sat, t_j2000_s);
1398 let mut hash = self.hash_query(0x4f42_5345_5256_4552, sat, t_j2000_s);
1399 match &result {
1400 Ok(state) => {
1401 hash_u64(&mut hash, 1);
1402 for value in state.position_ecef_m {
1403 hash_f64(&mut hash, value);
1404 }
1405 match state.clock_s {
1406 Some(clock) => {
1407 hash_u64(&mut hash, 1);
1408 hash_f64(&mut hash, clock);
1409 }
1410 None => hash_u64(&mut hash, 0),
1411 }
1412 }
1413 Err(_) => hash_u64(&mut hash, 0),
1414 }
1415 self.store_hash(hash);
1416 result
1417 }
1418}
1419
1420#[derive(Debug, Clone, Copy)]
1422pub struct DeclaredIonexSource<'a> {
1423 product: &'a Ionex,
1424 identity: &'a ScenarioExternalProduct,
1425}
1426
1427impl<'a> DeclaredIonexSource<'a> {
1428 pub const fn new(product: &'a Ionex, identity: &'a ScenarioExternalProduct) -> Self {
1430 Self { product, identity }
1431 }
1432
1433 pub const fn identity(&self) -> &'a ScenarioExternalProduct {
1435 self.identity
1436 }
1437
1438 pub const fn product(&self) -> &'a Ionex {
1440 self.product
1441 }
1442}
1443
1444#[derive(Debug, Clone, Copy, Default)]
1446pub struct ScenarioMediaSources<'a> {
1447 pub ionex: Option<DeclaredIonexSource<'a>>,
1449}
1450
1451#[derive(Debug, Clone, PartialEq)]
1453pub struct SyntheticKeplerSource {
1454 satellites: Vec<SyntheticKeplerOrbit>,
1455}
1456
1457impl SyntheticKeplerSource {
1458 pub fn new(mut satellites: Vec<SyntheticKeplerOrbit>) -> Result<Self, ScenarioError> {
1460 if satellites.is_empty() {
1461 return Err(invalid("satellites", "must not be empty"));
1462 }
1463 satellites.sort_by_key(|orbit| orbit.satellite_id);
1464 for satellite in &satellites {
1465 satellite.validate()?;
1466 }
1467 Ok(Self { satellites })
1468 }
1469
1470 pub fn satellites(&self) -> &[SyntheticKeplerOrbit] {
1472 &self.satellites
1473 }
1474
1475 pub fn state_at_j2000_s(
1477 &self,
1478 sat: GnssSatelliteId,
1479 t_j2000_s: f64,
1480 ) -> Option<ObservableState> {
1481 let orbit = self
1482 .satellites
1483 .iter()
1484 .find(|orbit| orbit.satellite_id == sat)?;
1485 Some(kepler_state(*orbit, t_j2000_s))
1486 }
1487}
1488
1489impl EphemerisSource for SyntheticKeplerSource {
1490 fn position_clock_at_j2000_s(
1491 &self,
1492 sat: GnssSatelliteId,
1493 t_j2000_s: f64,
1494 ) -> Option<([f64; 3], f64)> {
1495 let state = self.state_at_j2000_s(sat, t_j2000_s)?;
1496 Some((state.position_ecef_m, state.clock_s.unwrap_or(0.0)))
1497 }
1498}
1499
1500impl ObservableEphemerisSource for SyntheticKeplerSource {
1501 fn observable_state_at_j2000_s(
1502 &self,
1503 sat: GnssSatelliteId,
1504 t_j2000_s: f64,
1505 ) -> Result<ObservableState, ObservablesError> {
1506 self.state_at_j2000_s(sat, t_j2000_s)
1507 .ok_or(ObservablesError::NoEphemeris)
1508 }
1509}
1510
1511pub fn simulate_scenario(scenario: &Scenario) -> Result<SyntheticObservationSet, ScenarioError> {
1513 simulate_scenario_with_media(scenario, &ScenarioMediaSources::default())
1514}
1515
1516pub fn simulate_scenario_with_media(
1518 scenario: &Scenario,
1519 media: &ScenarioMediaSources<'_>,
1520) -> Result<SyntheticObservationSet, ScenarioError> {
1521 scenario.validate()?;
1522 let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation else {
1523 return Err(ScenarioError::ExternalSourceRequired);
1524 };
1525 let source = SyntheticKeplerSource::new(satellites.clone())?;
1526 simulate_resolved_scenario(scenario, &source, media)
1527}
1528
1529pub fn simulate_scenario_with_source<E>(
1531 scenario: &Scenario,
1532 source: &DeclaredScenarioSource<'_, E>,
1533) -> Result<SyntheticObservationSet, ScenarioError>
1534where
1535 E: EphemerisSource + ObservableEphemerisSource,
1536{
1537 simulate_scenario_with_source_and_media(scenario, source, &ScenarioMediaSources::default())
1538}
1539
1540pub fn simulate_scenario_with_source_and_media<E>(
1542 scenario: &Scenario,
1543 source: &DeclaredScenarioSource<'_, E>,
1544 media: &ScenarioMediaSources<'_>,
1545) -> Result<SyntheticObservationSet, ScenarioError>
1546where
1547 E: EphemerisSource + ObservableEphemerisSource,
1548{
1549 scenario.validate()?;
1550 let ScenarioConstellation::ExternalProducts {
1551 source: expected, ..
1552 } = &scenario.constellation
1553 else {
1554 return Err(invalid(
1555 "constellation.kind",
1556 "external source supplied for synthetic constellation",
1557 ));
1558 };
1559 validate_identity("constellation.source", expected, source.identity())?;
1560 let transcript = SourceTranscript::new(source.source());
1561 let set = simulate_resolved_scenario(scenario, &transcript, media)?;
1562 let actual = transcript.digest();
1563 if actual != expected.content_digest {
1564 return Err(ScenarioError::ExternalSourceMismatch {
1565 field: "constellation.source.content_digest",
1566 expected: expected.content_digest.clone(),
1567 actual,
1568 });
1569 }
1570 Ok(set)
1571}
1572
1573pub fn scenario_source_transcript_fingerprint<E>(
1575 scenario: &Scenario,
1576 source: &DeclaredScenarioSource<'_, E>,
1577 media: &ScenarioMediaSources<'_>,
1578) -> Result<String, ScenarioError>
1579where
1580 E: EphemerisSource + ObservableEphemerisSource,
1581{
1582 scenario.validate()?;
1583 let ScenarioConstellation::ExternalProducts {
1584 source: expected, ..
1585 } = &scenario.constellation
1586 else {
1587 return Err(invalid(
1588 "constellation.kind",
1589 "external source fingerprint requires external products",
1590 ));
1591 };
1592 if expected.kind != source.identity().kind
1593 || expected.product_id != source.identity().product_id
1594 {
1595 return Err(ScenarioError::ExternalSourceMismatch {
1596 field: "constellation.source",
1597 expected: expected.label(),
1598 actual: source.identity().label(),
1599 });
1600 }
1601 let transcript = SourceTranscript::new(source.source());
1602 let _ = simulate_resolved_scenario(scenario, &transcript, media)?;
1603 Ok(transcript.digest())
1604}
1605
1606pub fn ionex_content_fingerprint(ionex: &Ionex) -> String {
1608 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
1609 hash_str(&mut hash, &ionex.to_ionex_string());
1610 fingerprint_label(hash)
1611}
1612
1613fn simulate_resolved_scenario<E>(
1614 scenario: &Scenario,
1615 source: &E,
1616 media: &ScenarioMediaSources<'_>,
1617) -> Result<SyntheticObservationSet, ScenarioError>
1618where
1619 E: EphemerisSource + ObservableEphemerisSource,
1620{
1621 let satellites = scenario.satellites();
1622 let signal_by_system = signal_map(&scenario.signals);
1623 let epochs = scenario.epochs.epochs_j2000_s();
1624 let mut receiver_clock = ClockSynth::new(
1625 scenario.error_budget.receiver_clock,
1626 mix_seed(scenario.seed, NOISE_STREAM_RECEIVER_CLOCK),
1627 );
1628 let mut satellite_clocks: BTreeMap<GnssSatelliteId, ClockSynth> = satellites
1629 .iter()
1630 .copied()
1631 .map(|sat| {
1632 (
1633 sat,
1634 ClockSynth::new(
1635 scenario.error_budget.satellite_clock,
1636 mix_seed(scenario.seed ^ satellite_hash(sat), NOISE_STREAM_SAT_CLOCK),
1637 ),
1638 )
1639 })
1640 .collect();
1641 let mut noise = SplitMix64::new(mix_seed(scenario.seed, NOISE_STREAM_OBSERVABLE));
1642
1643 let mut receiver_truth = Vec::with_capacity(epochs.len());
1644 let mut observations = SyntheticObservableArrays::new(epochs.len());
1645 let mut truth_terms = SyntheticTermArrays::new();
1646
1647 for (epoch_index, &t_rx_j2000_s) in epochs.iter().enumerate() {
1648 let receiver = receiver_state(scenario, t_rx_j2000_s, &mut receiver_clock)?;
1649 receiver_truth.push(receiver);
1650 observations.epoch_offsets.push(observations.len());
1651 let epoch_context = EpochContext::new(scenario, t_rx_j2000_s);
1652
1653 for &sat in &satellites {
1654 let Some(signals) = signal_by_system.get(&sat.system) else {
1655 continue;
1656 };
1657 let Some(sat_clock) = satellite_clocks.get_mut(&sat) else {
1658 continue;
1659 };
1660 let sat_clock_error = sat_clock.sample(t_rx_j2000_s - scenario.epochs.start_j2000_s);
1661 for signal in signals {
1662 let thermal_code_m =
1663 thermal_noise_m(scenario.error_budget.thermal_noise, &mut noise);
1664 let thermal_phase_m =
1665 thermal_phase_noise_m(scenario.error_budget.thermal_noise, &mut noise);
1666 let thermal_doppler_hz =
1667 thermal_doppler_noise_hz(scenario.error_budget.thermal_noise, &mut noise);
1668
1669 let Some((pseudorange_m, mut terms)) = synthesize_pseudorange(
1670 source,
1671 PseudorangeRequest {
1672 sat,
1673 receiver,
1674 signal,
1675 scenario,
1676 epoch_context: &epoch_context,
1677 media,
1678 sat_clock_error_s: sat_clock_error.offset_s,
1679 thermal_noise_m: thermal_code_m,
1680 },
1681 )?
1682 else {
1683 continue;
1684 };
1685
1686 let phase_cycles = synthesize_phase_terms(&mut terms, signal, thermal_phase_m);
1687 let doppler_hz = synthesize_doppler_terms(
1688 source,
1689 sat,
1690 receiver,
1691 signal.carrier_hz,
1692 sat_clock_error.rate_s_s,
1693 thermal_doppler_hz,
1694 &mut terms,
1695 )?;
1696
1697 observations.epoch_index.push(epoch_index);
1698 observations.satellite_id.push(sat);
1699 observations
1700 .code_observable
1701 .push(signal.code_observable.clone());
1702 observations
1703 .phase_observable
1704 .push(signal.phase_observable.clone());
1705 observations
1706 .doppler_observable
1707 .push(signal.doppler_observable.clone());
1708 observations.carrier_hz.push(signal.carrier_hz);
1709 observations.pseudorange_m.push(pseudorange_m);
1710 observations.carrier_phase_cycles.push(phase_cycles);
1711 observations.doppler_hz.push(doppler_hz);
1712 truth_terms.push(terms);
1713 }
1714 }
1715 }
1716 observations.epoch_offsets.push(observations.len());
1717
1718 Ok(SyntheticObservationSet {
1719 schema_version: scenario.schema_version,
1720 engine_version: SCENARIO_ENGINE_VERSION.to_string(),
1721 seed: scenario.seed,
1722 receiver_truth,
1723 observations,
1724 truth_terms,
1725 })
1726}
1727
1728#[derive(Debug, Clone, Copy)]
1729struct EpochContext {
1730 t_rx_second_of_day_s: f64,
1731 day_of_year: f64,
1732 corrections: Corrections,
1733 klobuchar: KlobucharCoeffs,
1734 met: SurfaceMet,
1735}
1736
1737impl EpochContext {
1738 fn new(scenario: &Scenario, t_rx_j2000_s: f64) -> Self {
1739 let time = obs_epoch_time(t_rx_j2000_s);
1740 let corrections = Corrections {
1741 ionosphere: scenario.error_budget.ionosphere.corrections().ionosphere,
1742 troposphere: scenario.error_budget.troposphere.corrections().troposphere,
1743 };
1744 Self {
1745 t_rx_second_of_day_s: second_of_day(
1746 i32::from(time.hour),
1747 i32::from(time.minute),
1748 time.second,
1749 ),
1750 day_of_year: day_of_year(
1751 time.year,
1752 i32::from(time.month),
1753 i32::from(time.day),
1754 i32::from(time.hour),
1755 i32::from(time.minute),
1756 time.second,
1757 ),
1758 corrections,
1759 klobuchar: scenario.error_budget.ionosphere.coefficients(),
1760 met: scenario.error_budget.troposphere.surface_met(),
1761 }
1762 }
1763}
1764
1765#[derive(Debug, Clone, Copy)]
1766struct ObservationTerms {
1767 geometric_range_m: f64,
1768 satellite_clock_m: f64,
1769 receiver_clock_m: f64,
1770 satellite_clock_error_m: f64,
1771 ionosphere_m: f64,
1772 troposphere_m: f64,
1773 thermal_noise_m: f64,
1774 multipath_m: f64,
1775 quantization_m: f64,
1776 carrier_phase_geometric_cycles: f64,
1777 carrier_phase_receiver_clock_cycles: f64,
1778 carrier_phase_satellite_clock_cycles: f64,
1779 carrier_phase_satellite_clock_error_cycles: f64,
1780 carrier_phase_ionosphere_cycles: f64,
1781 carrier_phase_troposphere_cycles: f64,
1782 carrier_phase_thermal_noise_cycles: f64,
1783 carrier_phase_bias_cycles: f64,
1784 carrier_phase_quantization_cycles: f64,
1785 doppler_satellite_motion_hz: f64,
1786 doppler_receiver_motion_hz: f64,
1787 doppler_satellite_clock_hz: f64,
1788 doppler_receiver_clock_hz: f64,
1789 doppler_satellite_clock_error_hz: f64,
1790 doppler_thermal_noise_hz: f64,
1791 doppler_quantization_hz: f64,
1792}
1793
1794#[derive(Debug, Clone, Copy)]
1795struct PseudorangeRequest<'a, 'm> {
1796 sat: GnssSatelliteId,
1797 receiver: SyntheticReceiverTruth,
1798 signal: &'a ScenarioSignal,
1799 scenario: &'a Scenario,
1800 epoch_context: &'a EpochContext,
1801 media: &'a ScenarioMediaSources<'m>,
1802 sat_clock_error_s: f64,
1803 thermal_noise_m: f64,
1804}
1805
1806fn synthesize_pseudorange<E>(
1807 source: &E,
1808 request: PseudorangeRequest<'_, '_>,
1809) -> Result<Option<(f64, ObservationTerms)>, ScenarioError>
1810where
1811 E: EphemerisSource + ObservableEphemerisSource,
1812{
1813 let PseudorangeRequest {
1814 sat,
1815 receiver,
1816 signal,
1817 scenario,
1818 epoch_context,
1819 media,
1820 sat_clock_error_s,
1821 thermal_noise_m,
1822 } = request;
1823 let mask_rad = scenario.error_budget.elevation_mask_deg.to_radians();
1824 let mut p_meas_m = rough_range(source, sat, receiver.position_ecef_m, receiver.t_rx_j2000_s)?;
1825 let mut out = None;
1826 for _ in 0..FIXED_POINT_ITERS {
1827 let model = spp_model(source, sat, receiver, p_meas_m, scenario, epoch_context)?;
1828 if model.el_rad < mask_rad {
1829 return Ok(None);
1830 }
1831 let ionosphere_m =
1832 ionosphere_delay_m(source, sat, receiver, signal, scenario, media, model.iono_m)?;
1833 let multipath_m = multipath_m(
1834 scenario.error_budget.multipath,
1835 model.el_rad,
1836 signal.carrier_hz,
1837 );
1838 let satellite_clock_error_m = -C_M_S * sat_clock_error_s;
1839 let mut unrounded = model.rho_m;
1840 unrounded += receiver.clock_m;
1841 unrounded += -C_M_S * model.dt_sat_s;
1842 unrounded += satellite_clock_error_m;
1843 unrounded += ionosphere_m;
1844 unrounded += model.tropo_m;
1845 unrounded += thermal_noise_m;
1846 unrounded += multipath_m;
1847 let terms = ObservationTerms {
1848 geometric_range_m: model.rho_m,
1849 satellite_clock_m: -C_M_S * model.dt_sat_s,
1850 receiver_clock_m: receiver.clock_m,
1851 satellite_clock_error_m,
1852 ionosphere_m,
1853 troposphere_m: model.tropo_m,
1854 thermal_noise_m,
1855 multipath_m,
1856 quantization_m: 0.0,
1857 carrier_phase_geometric_cycles: 0.0,
1858 carrier_phase_receiver_clock_cycles: 0.0,
1859 carrier_phase_satellite_clock_cycles: 0.0,
1860 carrier_phase_satellite_clock_error_cycles: 0.0,
1861 carrier_phase_ionosphere_cycles: 0.0,
1862 carrier_phase_troposphere_cycles: 0.0,
1863 carrier_phase_thermal_noise_cycles: 0.0,
1864 carrier_phase_bias_cycles: 0.0,
1865 carrier_phase_quantization_cycles: 0.0,
1866 doppler_satellite_motion_hz: 0.0,
1867 doppler_receiver_motion_hz: 0.0,
1868 doppler_satellite_clock_hz: 0.0,
1869 doppler_receiver_clock_hz: 0.0,
1870 doppler_satellite_clock_error_hz: 0.0,
1871 doppler_thermal_noise_hz: 0.0,
1872 doppler_quantization_hz: 0.0,
1873 };
1874 p_meas_m = unrounded;
1875 out = Some((unrounded, terms, model.el_rad));
1876 }
1877 Ok(out.map(|(pseudorange_m, terms, _)| (pseudorange_m, terms)))
1878}
1879
1880fn spp_model<E>(
1881 source: &E,
1882 sat: GnssSatelliteId,
1883 receiver: SyntheticReceiverTruth,
1884 p_meas_m: f64,
1885 scenario: &Scenario,
1886 epoch_context: &EpochContext,
1887) -> Result<crate::spp::SatModel, ScenarioError>
1888where
1889 E: EphemerisSource,
1890{
1891 let glonass_channels = BTreeMap::new();
1892 let env = SatModelEnv {
1893 eph: source,
1894 t_rx_j2000_s: receiver.t_rx_j2000_s,
1895 t_rx_second_of_day_s: epoch_context.t_rx_second_of_day_s,
1896 day_of_year: epoch_context.day_of_year,
1897 corrections: epoch_context.corrections,
1898 met: &epoch_context.met,
1899 glonass_channels: &glonass_channels,
1900 model: SppModelRecipe::reference(),
1901 };
1902 let ionosphere = match &scenario.error_budget.ionosphere {
1903 ScenarioIonosphereModel::Off => SppIonosphere::Klobuchar(KlobucharCoeffs {
1904 alpha: [0.0; 4],
1905 beta: [0.0; 4],
1906 }),
1907 ScenarioIonosphereModel::Klobuchar { .. } => {
1908 SppIonosphere::Klobuchar(epoch_context.klobuchar)
1909 }
1910 ScenarioIonosphereModel::SuppliedIonex { .. } => {
1911 SppIonosphere::Klobuchar(KlobucharCoeffs {
1912 alpha: [0.0; 4],
1913 beta: [0.0; 4],
1914 })
1915 }
1916 };
1917 sat_model(
1918 &env,
1919 sat,
1920 receiver.position_ecef_m,
1921 receiver.clock_m,
1922 p_meas_m,
1923 ionosphere,
1924 )
1925 .ok_or(ScenarioError::NoEphemeris { satellite: sat })
1926}
1927
1928fn rough_range<E>(
1929 source: &E,
1930 sat: GnssSatelliteId,
1931 receiver_ecef_m: [f64; 3],
1932 t_rx_j2000_s: f64,
1933) -> Result<f64, ScenarioError>
1934where
1935 E: EphemerisSource,
1936{
1937 let (sat_pos, sat_clock_s) = source
1938 .position_clock_at_j2000_s(sat, t_rx_j2000_s)
1939 .ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
1940 Ok(norm3(sub3(sat_pos, receiver_ecef_m)) - C_M_S * sat_clock_s)
1941}
1942
1943fn source_clock_rate_s_s<E>(
1944 source: &E,
1945 sat: GnssSatelliteId,
1946 t_j2000_s: f64,
1947) -> Result<f64, ScenarioError>
1948where
1949 E: EphemerisSource,
1950{
1951 let (_, plus) = source
1952 .position_clock_at_j2000_s(sat, t_j2000_s + 0.5)
1953 .ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
1954 let (_, minus) = source
1955 .position_clock_at_j2000_s(sat, t_j2000_s - 0.5)
1956 .ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
1957 Ok((plus - minus) / 1.0)
1958}
1959
1960fn ionosphere_delay_m<E>(
1961 source: &E,
1962 sat: GnssSatelliteId,
1963 receiver: SyntheticReceiverTruth,
1964 signal: &ScenarioSignal,
1965 scenario: &Scenario,
1966 media: &ScenarioMediaSources<'_>,
1967 klobuchar_iono_m: f64,
1968) -> Result<f64, ScenarioError>
1969where
1970 E: ObservableEphemerisSource,
1971{
1972 let ScenarioIonosphereModel::SuppliedIonex { source: expected } =
1973 &scenario.error_budget.ionosphere
1974 else {
1975 return Ok(klobuchar_iono_m);
1976 };
1977 let Some(ionex) = media.ionex else {
1978 return Err(ScenarioError::ExternalIonosphereRequired);
1979 };
1980 validate_identity("error_budget.ionosphere.source", expected, ionex.identity())?;
1981 let actual = ionex_content_fingerprint(ionex.product());
1982 if actual != expected.content_digest {
1983 return Err(ScenarioError::ExternalSourceMismatch {
1984 field: "error_budget.ionosphere.source.content_digest",
1985 expected: expected.content_digest.clone(),
1986 actual,
1987 });
1988 }
1989 let prediction = predict(
1990 source,
1991 sat,
1992 receiver.position_ecef_m,
1993 receiver.t_rx_j2000_s,
1994 crate::observables::PredictOptions {
1995 carrier_hz: signal.carrier_hz,
1996 ..crate::observables::PredictOptions::default()
1997 },
1998 )
1999 .map_err(ScenarioError::Observable)?;
2000 let receiver_geodetic = receiver_geodetic(receiver.position_ecef_m)?;
2001 let epoch_j2000_s = rounded_j2000_seconds(receiver.t_rx_j2000_s)?;
2002 ionex_slant_delay(
2003 ionex.product(),
2004 receiver_geodetic,
2005 prediction.elevation_deg.to_radians(),
2006 prediction.azimuth_deg.to_radians(),
2007 epoch_j2000_s,
2008 signal.carrier_hz,
2009 )
2010 .map_err(|error| ScenarioError::Ionosphere(error.to_string()))
2011}
2012
2013fn synthesize_phase_terms(
2014 terms: &mut ObservationTerms,
2015 signal: &ScenarioSignal,
2016 thermal_phase_m: f64,
2017) -> f64 {
2018 let lambda = wavelength_m(signal.carrier_hz);
2019 let mut phase_cycles = terms.geometric_range_m / lambda;
2020 terms.carrier_phase_geometric_cycles = phase_cycles;
2021 let value = terms.receiver_clock_m / lambda;
2022 terms.carrier_phase_receiver_clock_cycles = value;
2023 phase_cycles += value;
2024 let value = terms.satellite_clock_m / lambda;
2025 terms.carrier_phase_satellite_clock_cycles = value;
2026 phase_cycles += value;
2027 let value = terms.satellite_clock_error_m / lambda;
2028 terms.carrier_phase_satellite_clock_error_cycles = value;
2029 phase_cycles += value;
2030 let value = -terms.ionosphere_m / lambda;
2031 terms.carrier_phase_ionosphere_cycles = value;
2032 phase_cycles += value;
2033 let value = terms.troposphere_m / lambda;
2034 terms.carrier_phase_troposphere_cycles = value;
2035 phase_cycles += value;
2036 let value = thermal_phase_m / lambda;
2037 terms.carrier_phase_thermal_noise_cycles = value;
2038 phase_cycles += value;
2039 terms.carrier_phase_bias_cycles = signal.carrier_phase_bias_cycles;
2040 phase_cycles += signal.carrier_phase_bias_cycles;
2041 terms.carrier_phase_quantization_cycles = 0.0;
2042 phase_cycles
2043}
2044
2045fn synthesize_doppler_terms<E>(
2046 source: &E,
2047 sat: GnssSatelliteId,
2048 receiver: SyntheticReceiverTruth,
2049 carrier_hz: f64,
2050 sat_clock_error_rate_s_s: f64,
2051 thermal_doppler_hz: f64,
2052 terms: &mut ObservationTerms,
2053) -> Result<f64, ScenarioError>
2054where
2055 E: EphemerisSource + ObservableEphemerisSource,
2056{
2057 let prediction = predict(
2058 source,
2059 sat,
2060 receiver.position_ecef_m,
2061 receiver.t_rx_j2000_s,
2062 crate::observables::PredictOptions {
2063 carrier_hz,
2064 ..crate::observables::PredictOptions::default()
2065 },
2066 )
2067 .map_err(ScenarioError::Observable)?;
2068 let receiver_range_rate = dot3(prediction.los_unit, receiver.velocity_ecef_m_s);
2069 let mut doppler_hz = prediction.doppler_hz;
2070 terms.doppler_satellite_motion_hz = doppler_hz;
2071 let value = receiver_range_rate * carrier_hz / C_M_S;
2072 terms.doppler_receiver_motion_hz = value;
2073 doppler_hz += value;
2074 let value = source_clock_rate_s_s(source, sat, receiver.t_rx_j2000_s)? * carrier_hz;
2075 terms.doppler_satellite_clock_hz = value;
2076 doppler_hz += value;
2077 let value = -receiver.clock_rate_m_s * carrier_hz / C_M_S;
2078 terms.doppler_receiver_clock_hz = value;
2079 doppler_hz += value;
2080 let value = sat_clock_error_rate_s_s * carrier_hz;
2081 terms.doppler_satellite_clock_error_hz = value;
2082 doppler_hz += value;
2083 terms.doppler_thermal_noise_hz = thermal_doppler_hz;
2084 doppler_hz += thermal_doppler_hz;
2085 terms.doppler_quantization_hz = 0.0;
2086 Ok(doppler_hz)
2087}
2088
2089fn receiver_state(
2090 scenario: &Scenario,
2091 t_rx_j2000_s: f64,
2092 clock: &mut ClockSynth,
2093) -> Result<SyntheticReceiverTruth, ScenarioError> {
2094 let offset_s = t_rx_j2000_s - scenario.epochs.start_j2000_s;
2095 let (position_ecef_m, velocity_ecef_m_s) = match &scenario.receiver {
2096 ScenarioReceiver::StaticGeodetic { position } => {
2097 let ecef = geodetic_to_itrf(position.to_wgs84()?)
2098 .map_err(|error| ScenarioError::Frame(error.to_string()))?
2099 .as_array();
2100 (ecef, [0.0; 3])
2101 }
2102 ScenarioReceiver::KinematicWaypoints { waypoints } => {
2103 interpolate_receiver_waypoints(waypoints, offset_s)?
2104 }
2105 };
2106 let clock_sample = clock.sample(offset_s);
2107 Ok(SyntheticReceiverTruth {
2108 t_rx_j2000_s,
2109 position_ecef_m,
2110 velocity_ecef_m_s,
2111 clock_m: C_M_S * clock_sample.offset_s,
2112 clock_rate_m_s: C_M_S * clock_sample.rate_s_s,
2113 })
2114}
2115
2116fn interpolate_receiver_waypoints(
2117 waypoints: &[ScenarioReceiverWaypoint],
2118 offset_s: f64,
2119) -> Result<([f64; 3], [f64; 3]), ScenarioError> {
2120 let mut segment = waypoints
2121 .windows(2)
2122 .last()
2123 .expect("validated waypoint count");
2124 for pair in waypoints.windows(2) {
2125 if offset_s >= pair[0].offset_s && offset_s <= pair[1].offset_s {
2126 segment = pair;
2127 break;
2128 }
2129 }
2130 let start = segment[0];
2131 let end = segment[1];
2132 let p0 = geodetic_to_itrf(start.position.to_wgs84()?)
2133 .map_err(|error| ScenarioError::Frame(error.to_string()))?
2134 .as_array();
2135 let p1 = geodetic_to_itrf(end.position.to_wgs84()?)
2136 .map_err(|error| ScenarioError::Frame(error.to_string()))?
2137 .as_array();
2138 let dt = end.offset_s - start.offset_s;
2139 let u = ((offset_s - start.offset_s) / dt).clamp(0.0, 1.0);
2140 let position = [
2141 p0[0] + (p1[0] - p0[0]) * u,
2142 p0[1] + (p1[1] - p0[1]) * u,
2143 p0[2] + (p1[2] - p0[2]) * u,
2144 ];
2145 let velocity = start.velocity_ecef_m_s.unwrap_or([
2146 (p1[0] - p0[0]) / dt,
2147 (p1[1] - p0[1]) / dt,
2148 (p1[2] - p0[2]) / dt,
2149 ]);
2150 Ok((position, velocity))
2151}
2152
2153fn kepler_state(orbit: SyntheticKeplerOrbit, t_j2000_s: f64) -> ObservableState {
2154 let dt = t_j2000_s - orbit.epoch_j2000_s;
2155 let n_rad_s = (GM_EARTH_M3_S2
2156 / (orbit.semi_major_axis_m * orbit.semi_major_axis_m * orbit.semi_major_axis_m))
2157 .sqrt();
2158 let mean_anomaly = orbit.mean_anomaly_rad + n_rad_s * dt;
2159 let eccentric_anomaly = solve_kepler(mean_anomaly, orbit.eccentricity);
2160 let cos_e = libm::cos(eccentric_anomaly);
2161 let sin_e = libm::sin(eccentric_anomaly);
2162 let a = orbit.semi_major_axis_m;
2163 let e = orbit.eccentricity;
2164 let one_minus_e2 = 1.0 - e * e;
2165 let x_orb = a * (cos_e - e);
2166 let y_orb = a * one_minus_e2.sqrt() * sin_e;
2167 let eci = perifocal_to_inertial(
2168 [x_orb, y_orb, 0.0],
2169 orbit.raan_rad,
2170 orbit.inclination_rad,
2171 orbit.arg_perigee_rad,
2172 );
2173 let theta = OMEGA_E_DOT_RAD_S * dt;
2174 let cos_t = libm::cos(theta);
2175 let sin_t = libm::sin(theta);
2176 let ecef = [
2177 cos_t * eci[0] + sin_t * eci[1],
2178 -sin_t * eci[0] + cos_t * eci[1],
2179 eci[2],
2180 ];
2181 ObservableState {
2182 position_ecef_m: ecef,
2183 clock_s: Some(orbit.clock_bias_s + orbit.clock_drift_s_s * dt),
2184 }
2185}
2186
2187fn solve_kepler(mean_anomaly_rad: f64, eccentricity: f64) -> f64 {
2188 let mut e_anomaly = mean_anomaly_rad;
2189 for _ in 0..16 {
2190 let sin_e = libm::sin(e_anomaly);
2191 let cos_e = libm::cos(e_anomaly);
2192 let f = e_anomaly - eccentricity * sin_e - mean_anomaly_rad;
2193 let fp = 1.0 - eccentricity * cos_e;
2194 e_anomaly -= f / fp;
2195 }
2196 e_anomaly
2197}
2198
2199fn perifocal_to_inertial(
2200 position: [f64; 3],
2201 raan_rad: f64,
2202 inclination_rad: f64,
2203 arg_perigee_rad: f64,
2204) -> [f64; 3] {
2205 let cos_o = libm::cos(raan_rad);
2206 let sin_o = libm::sin(raan_rad);
2207 let cos_i = libm::cos(inclination_rad);
2208 let sin_i = libm::sin(inclination_rad);
2209 let cos_w = libm::cos(arg_perigee_rad);
2210 let sin_w = libm::sin(arg_perigee_rad);
2211 let r11 = cos_o * cos_w - sin_o * sin_w * cos_i;
2212 let r12 = -cos_o * sin_w - sin_o * cos_w * cos_i;
2213 let r21 = sin_o * cos_w + cos_o * sin_w * cos_i;
2214 let r22 = -sin_o * sin_w + cos_o * cos_w * cos_i;
2215 let r31 = sin_w * sin_i;
2216 let r32 = cos_w * sin_i;
2217 [
2218 r11 * position[0] + r12 * position[1],
2219 r21 * position[0] + r22 * position[1],
2220 r31 * position[0] + r32 * position[1],
2221 ]
2222}
2223
2224#[derive(Debug, Clone, Copy)]
2225struct ClockSample {
2226 offset_s: f64,
2227 rate_s_s: f64,
2228}
2229
2230#[derive(Debug, Clone, Copy)]
2231struct ClockSynth {
2232 model: ScenarioClockModel,
2233 rng: SplitMix64,
2234 last_offset_s: Option<f64>,
2235 phase_noise_s: f64,
2236 frequency_noise: f64,
2237 flicker_frequency: f64,
2238}
2239
2240impl ClockSynth {
2241 fn new(model: ScenarioClockModel, seed: u64) -> Self {
2242 let mut rng = SplitMix64::new(seed);
2243 let flicker = if model.enabled {
2244 let coeff = coefficient(model, PowerLawNoiseType::FlickerFM);
2245 coeff.sqrt() * rng.standard_normal()
2246 } else {
2247 0.0
2248 };
2249 Self {
2250 model,
2251 rng,
2252 last_offset_s: None,
2253 phase_noise_s: 0.0,
2254 frequency_noise: 0.0,
2255 flicker_frequency: flicker,
2256 }
2257 }
2258
2259 fn sample(&mut self, offset_s: f64) -> ClockSample {
2260 if !self.model.enabled {
2261 return ClockSample {
2262 offset_s: 0.0,
2263 rate_s_s: 0.0,
2264 };
2265 }
2266 let dt_s = self
2267 .last_offset_s
2268 .map_or(0.0, |previous| (offset_s - previous).max(0.0));
2269 self.last_offset_s = Some(offset_s);
2270 let mut rate_s_s = self.model.drift_s_s + self.flicker_frequency + self.frequency_noise;
2271 if dt_s > 0.0 {
2272 let rw_fm = coefficient(self.model, PowerLawNoiseType::RandomWalkFM);
2273 if rw_fm > 0.0 {
2274 self.frequency_noise += (rw_fm * dt_s).sqrt() * self.rng.standard_normal();
2275 }
2276 rate_s_s = self.model.drift_s_s + self.flicker_frequency + self.frequency_noise;
2277 let white_fm = coefficient(self.model, PowerLawNoiseType::WhiteFM);
2278 if white_fm > 0.0 {
2279 let phase_step_s = (white_fm * dt_s).sqrt() * self.rng.standard_normal();
2280 self.phase_noise_s += phase_step_s;
2281 rate_s_s += phase_step_s / dt_s;
2282 }
2283 self.phase_noise_s += self.frequency_noise * dt_s;
2284 }
2285 let pm_dt = dt_s.max(1.0);
2286 let flicker_pm = coefficient(self.model, PowerLawNoiseType::FlickerPM);
2287 let white_pm = coefficient(self.model, PowerLawNoiseType::WhitePM);
2288 let pm_noise = if flicker_pm == 0.0 && white_pm == 0.0 {
2289 0.0
2290 } else {
2291 (flicker_pm / pm_dt).sqrt() * self.rng.standard_normal()
2292 + (white_pm / (pm_dt * pm_dt)).sqrt() * self.rng.standard_normal()
2293 };
2294 let offset_s = self.model.bias_s
2295 + self.model.drift_s_s * offset_s
2296 + self.flicker_frequency * offset_s
2297 + self.phase_noise_s
2298 + pm_noise;
2299 ClockSample { offset_s, rate_s_s }
2300 }
2301}
2302
2303fn coefficient(model: ScenarioClockModel, noise_type: PowerLawNoiseType) -> f64 {
2304 model.power_law_coefficients[noise_type.coefficient_index()]
2305}
2306
2307#[derive(Debug, Clone, Copy, PartialEq)]
2308struct SplitMix64 {
2309 state: u64,
2310 spare_normal: Option<f64>,
2311}
2312
2313impl SplitMix64 {
2314 const fn new(seed: u64) -> Self {
2315 Self {
2316 state: seed,
2317 spare_normal: None,
2318 }
2319 }
2320
2321 fn next_u64(&mut self) -> u64 {
2322 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
2323 let mut z = self.state;
2324 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
2325 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
2326 z ^ (z >> 31)
2327 }
2328
2329 fn unit_f64(&mut self) -> f64 {
2330 let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
2331 f64::from_bits(bits) - 1.0
2332 }
2333
2334 fn standard_normal(&mut self) -> f64 {
2335 if let Some(value) = self.spare_normal.take() {
2336 return value;
2337 }
2338 loop {
2339 let u = 2.0 * self.unit_f64() - 1.0;
2340 let v = 2.0 * self.unit_f64() - 1.0;
2341 let s = u * u + v * v;
2342 if s > 0.0 && s < 1.0 {
2343 let scale = (-2.0 * libm::log(s) / s).sqrt();
2344 self.spare_normal = Some(v * scale);
2345 return u * scale;
2346 }
2347 }
2348 }
2349}
2350
2351fn thermal_noise_m(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
2352 if model.enabled && model.pseudorange_sigma_m > 0.0 {
2353 model.pseudorange_sigma_m * rng.standard_normal()
2354 } else {
2355 0.0
2356 }
2357}
2358
2359fn thermal_phase_noise_m(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
2360 if model.enabled && model.carrier_phase_sigma_m > 0.0 {
2361 model.carrier_phase_sigma_m * rng.standard_normal()
2362 } else {
2363 0.0
2364 }
2365}
2366
2367fn thermal_doppler_noise_hz(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
2368 if model.enabled && model.doppler_sigma_hz > 0.0 {
2369 model.doppler_sigma_hz * rng.standard_normal()
2370 } else {
2371 0.0
2372 }
2373}
2374
2375fn multipath_m(model: ScenarioSpecularMultipath, elevation_rad: f64, carrier_hz: f64) -> f64 {
2376 if !model.enabled || model.amplitude_m == 0.0 {
2377 return 0.0;
2378 }
2379 let lambda = wavelength_m(carrier_hz);
2380 let phase = 4.0 * core::f64::consts::PI * model.reflector_height_m * libm::sin(elevation_rad)
2381 / lambda
2382 + model.phase_rad;
2383 model.amplitude_m * libm::cos(phase)
2384}
2385
2386fn wavelength_m(carrier_hz: f64) -> f64 {
2387 C_M_S / carrier_hz
2388}
2389
2390fn round_rinex(value: f64) -> f64 {
2391 (value * RINEX_QUANTIZATION).round() / RINEX_QUANTIZATION
2392}
2393
2394fn obs_epoch_time(t_j2000_s: f64) -> ObsEpochTime {
2395 let whole = t_j2000_s.floor() as i64;
2396 let frac = t_j2000_s - whole as f64;
2397 let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole);
2398 ObsEpochTime {
2399 year: year as i32,
2400 month: month as u8,
2401 day: day as u8,
2402 hour: hour as u8,
2403 minute: minute as u8,
2404 second: second as f64 + frac,
2405 }
2406}
2407
2408fn signal_map(signals: &[ScenarioSignal]) -> BTreeMap<GnssSystem, Vec<ScenarioSignal>> {
2409 let mut out: BTreeMap<GnssSystem, Vec<ScenarioSignal>> = BTreeMap::new();
2410 for signal in signals {
2411 out.entry(signal.system).or_default().push(signal.clone());
2412 }
2413 out
2414}
2415
2416fn validate_identity(
2417 field: &'static str,
2418 expected: &ScenarioExternalProduct,
2419 actual: &ScenarioExternalProduct,
2420) -> Result<(), ScenarioError> {
2421 if expected == actual {
2422 Ok(())
2423 } else {
2424 Err(ScenarioError::ExternalSourceMismatch {
2425 field,
2426 expected: expected.label(),
2427 actual: actual.label(),
2428 })
2429 }
2430}
2431
2432fn receiver_geodetic(position_ecef_m: [f64; 3]) -> Result<Wgs84Geodetic, ScenarioError> {
2433 let position = ItrfPositionM::new(position_ecef_m[0], position_ecef_m[1], position_ecef_m[2])
2434 .map_err(|error| ScenarioError::Frame(error.to_string()))?;
2435 itrf_to_geodetic(position).map_err(|error| ScenarioError::Frame(error.to_string()))
2436}
2437
2438fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ScenarioError> {
2439 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_field)?;
2440 let rounded = t_rx_j2000_s.round();
2441 if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
2442 return Err(invalid("t_rx_j2000_s", "must round to i64 seconds"));
2443 }
2444 Ok(rounded as i64)
2445}
2446
2447fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
2448 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
2449}
2450
2451fn set_obs_value(values: &mut [ObsValue], codes: &[String], code: &str, value: f64) {
2452 if let Some(index) = codes.iter().position(|candidate| candidate == code) {
2453 values[index] = ObsValue {
2454 value: Some(value),
2455 lli: None,
2456 ssi: None,
2457 };
2458 }
2459}
2460
2461fn push_unique_code(codes: &mut Vec<String>, code: &str) {
2462 if !codes.iter().any(|candidate| candidate == code) {
2463 codes.push(code.to_string());
2464 }
2465}
2466
2467fn validate_code(code: &str, field: &'static str, first: u8) -> Result<(), ScenarioError> {
2468 if code.len() != 3 || code.as_bytes().first().copied() != Some(first) {
2469 return Err(invalid(field, "must be a three-character RINEX observable"));
2470 }
2471 if !code.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
2472 return Err(invalid(field, "must be ASCII alphanumeric"));
2473 }
2474 Ok(())
2475}
2476
2477fn map_field(error: validate::FieldError) -> ScenarioError {
2478 ScenarioError::InvalidInput {
2479 field: error.field(),
2480 reason: error.reason(),
2481 }
2482}
2483
2484fn invalid(field: &'static str, reason: &'static str) -> ScenarioError {
2485 ScenarioError::InvalidInput { field, reason }
2486}
2487
2488fn mix_seed(seed: u64, stream: u64) -> u64 {
2489 let mut value = seed ^ stream;
2490 value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
2491 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
2492 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
2493 value ^ (value >> 31)
2494}
2495
2496fn satellite_hash(sat: GnssSatelliteId) -> u64 {
2497 ((sat.system.letter() as u64) << 8) | u64::from(sat.prn)
2498}
2499
2500fn hash_u64(hash: &mut u64, value: u64) {
2501 *hash ^= value;
2502 *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2503}
2504
2505fn hash_str(hash: &mut u64, value: &str) {
2506 hash_u64(hash, value.len() as u64);
2507 for byte in value.as_bytes() {
2508 hash_u64(hash, u64::from(*byte));
2509 }
2510}
2511
2512fn fingerprint_label(hash: u64) -> String {
2513 format!("sidereon-fnv64:{hash:016x}")
2514}
2515
2516fn hash_f64(hash: &mut u64, value: f64) {
2517 hash_u64(hash, value.to_bits());
2518}
2519
2520#[cfg(test)]
2521mod tests {
2522 use super::*;
2531 use crate::astro::time::civil::j2000_seconds;
2532 use crate::atmosphere::TecGridSamples;
2533 use crate::clock_stability::{allan_deviation_power_law_slope, overlapping_adev, AllanSeries};
2534 use crate::positioning::{solve, SolveInputs};
2535 use crate::rinex::observations::{observation_values, ObservationFilter, ObservationKind};
2536
2537 fn product(
2538 kind: ScenarioExternalProductKind,
2539 id: &str,
2540 digest: &str,
2541 ) -> ScenarioExternalProduct {
2542 ScenarioExternalProduct {
2543 kind,
2544 product_id: id.to_string(),
2545 content_digest: digest.to_string(),
2546 }
2547 }
2548
2549 fn instant_from_j2000(seconds: i64) -> crate::astro::time::model::Instant {
2550 let (jd_whole, fraction) =
2551 crate::astro::time::civil::split_julian_date_from_j2000_seconds(seconds);
2552 crate::astro::time::model::Instant::from_julian_date(
2553 TimeScale::Gpst,
2554 crate::astro::time::model::JulianDateSplit::new(jd_whole, fraction)
2555 .expect("valid split Julian date"),
2556 )
2557 }
2558
2559 fn constant_ionex(epoch_j2000_s: i64, tecu: f64) -> Ionex {
2560 let map = vec![
2561 vec![tecu, tecu, tecu],
2562 vec![tecu, tecu, tecu],
2563 vec![tecu, tecu, tecu],
2564 ];
2565 Ionex::from_samples(TecGridSamples {
2566 map_epochs: vec![instant_from_j2000(epoch_j2000_s)],
2567 lat_nodes_deg: vec![90.0, 0.0, -90.0],
2568 lon_nodes_deg: vec![-180.0, 0.0, 180.0],
2569 dlat_deg: -90.0,
2570 dlon_deg: 180.0,
2571 shell_height_km: 450.0,
2572 base_radius_km: 6371.0,
2573 exponent: 0,
2574 tec_maps: vec![map],
2575 rms_maps: Vec::new(),
2576 })
2577 .expect("valid IONEX samples")
2578 }
2579
2580 fn base_scenario() -> Scenario {
2581 let start_j2000_s = j2000_seconds(2026, 1, 1, 0, 0, 0.0);
2582 Scenario {
2583 schema_version: SCENARIO_SCHEMA_VERSION,
2584 seed: DEFAULT_SCENARIO_SEED,
2585 epochs: ScenarioEpochRange {
2586 start_j2000_s,
2587 count: 2,
2588 cadence_s: 30.0,
2589 },
2590 receiver: ScenarioReceiver::StaticGeodetic {
2591 position: ScenarioGeodeticPosition {
2592 lat_rad: 0.0,
2593 lon_rad: 0.0,
2594 height_m: 0.0,
2595 },
2596 },
2597 constellation: ScenarioConstellation::SyntheticKeplerian {
2598 satellites: gps_anchor_orbits(start_j2000_s),
2599 },
2600 signals: vec![ScenarioSignal::l1_ca(GnssSystem::Gps)],
2601 error_budget: ScenarioErrorBudget {
2602 elevation_mask_deg: -5.0,
2603 ..ScenarioErrorBudget::default()
2604 },
2605 }
2606 }
2607
2608 fn gps_anchor_orbits(epoch_j2000_s: f64) -> Vec<SyntheticKeplerOrbit> {
2609 let a = 26_560_000.0;
2610 let u60 = core::f64::consts::PI / 3.0;
2611 [
2612 (1, 0.0, 0.0, 0.0),
2613 (2, 0.0, 0.0, u60),
2614 (3, 0.0, 0.0, -u60),
2615 (4, 0.0, core::f64::consts::FRAC_PI_2, u60),
2616 (5, 0.0, core::f64::consts::FRAC_PI_2, -u60),
2617 ]
2618 .into_iter()
2619 .map(
2620 |(prn, raan_rad, inclination_rad, mean_anomaly_rad)| SyntheticKeplerOrbit {
2621 satellite_id: GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN"),
2622 semi_major_axis_m: a,
2623 eccentricity: 0.0,
2624 inclination_rad,
2625 raan_rad,
2626 arg_perigee_rad: 0.0,
2627 mean_anomaly_rad,
2628 epoch_j2000_s,
2629 clock_bias_s: 0.0,
2630 clock_drift_s_s: 0.0,
2631 },
2632 )
2633 .collect()
2634 }
2635
2636 #[test]
2637 fn schema_round_trips_through_json() {
2638 let mut scenario = base_scenario();
2639 scenario.error_budget.ionosphere = ScenarioIonosphereModel::SuppliedIonex {
2640 source: product(
2641 ScenarioExternalProductKind::Ionex,
2642 "fixture.ionex",
2643 "sha256:ionex-fixture",
2644 ),
2645 };
2646 let json = serde_json::to_string(&scenario).expect("serialize scenario");
2647 let reparsed: Scenario = serde_json::from_str(&json).expect("parse scenario");
2648 assert_eq!(reparsed, scenario);
2649 reparsed.validate().expect("valid scenario");
2650 }
2651
2652 #[test]
2653 fn deterministic_runs_match_and_terms_sum_to_composite_bits() {
2654 let mut scenario = base_scenario();
2655 scenario.error_budget.receiver_clock = ScenarioClockModel {
2656 enabled: true,
2657 bias_s: 1.0e-7,
2658 drift_s_s: 1.0e-10,
2659 power_law_coefficients: [1.0e-24, 1.0e-26, 1.0e-22, 1.0e-26, 1.0e-28],
2660 };
2661 scenario.error_budget.thermal_noise = ScenarioThermalNoise {
2662 enabled: true,
2663 pseudorange_sigma_m: 0.25,
2664 carrier_phase_sigma_m: 0.002,
2665 doppler_sigma_hz: 0.02,
2666 };
2667 scenario.error_budget.multipath = ScenarioSpecularMultipath {
2668 enabled: true,
2669 amplitude_m: 0.15,
2670 reflector_height_m: 1.25,
2671 phase_rad: 0.3,
2672 };
2673
2674 let first = simulate_scenario(&scenario).expect("simulate first");
2675 let second = simulate_scenario(&scenario).expect("simulate second");
2676 assert_eq!(
2677 first.determinism_fingerprint(),
2678 second.determinism_fingerprint()
2679 );
2680 assert_eq!(first, second);
2681
2682 for index in 0..first.observation_count() {
2683 let sum = first
2684 .truth_terms
2685 .pseudorange_sum_m(index)
2686 .expect("term index");
2687 assert_eq!(
2688 sum.to_bits(),
2689 first.observations.pseudorange_m[index].to_bits(),
2690 "term sum at observation {index}"
2691 );
2692 let sum = first
2693 .truth_terms
2694 .carrier_phase_sum_cycles(index)
2695 .expect("phase term index");
2696 assert_eq!(
2697 sum.to_bits(),
2698 first.observations.carrier_phase_cycles[index].to_bits(),
2699 "phase term sum at observation {index}"
2700 );
2701 let sum = first
2702 .truth_terms
2703 .doppler_sum_hz(index)
2704 .expect("doppler term index");
2705 assert_eq!(
2706 sum.to_bits(),
2707 first.observations.doppler_hz[index].to_bits(),
2708 "doppler term sum at observation {index}"
2709 );
2710 }
2711 }
2712
2713 #[test]
2714 fn scenario_clock_white_fm_matches_in_repo_power_law_oracle() {
2715 let mut clock = ClockSynth::new(
2716 ScenarioClockModel {
2717 enabled: true,
2718 bias_s: 0.0,
2719 drift_s_s: 0.0,
2720 power_law_coefficients: [0.0, 0.0, 1.0e-20, 0.0, 0.0],
2721 },
2722 mix_seed(DEFAULT_SCENARIO_SEED, 0x1139),
2723 );
2724 let mut frequency = Vec::with_capacity(4096);
2725 for index in 0..4096 {
2726 frequency.push(clock.sample(index as f64).rate_s_s);
2727 }
2728 let factors = [1, 2, 4, 8, 16, 32, 64, 128];
2729 let adev = overlapping_adev(AllanSeries::FractionalFrequency(&frequency), 1.0, &factors)
2730 .expect("overlapping ADEV");
2731 let slope = (adev.deviation[5].ln() - adev.deviation[1].ln())
2732 / (adev.tau_s[5].ln() - adev.tau_s[1].ln());
2733 assert!(
2734 (slope - allan_deviation_power_law_slope(PowerLawNoiseType::WhiteFM)).abs() < 0.25,
2735 "white-FM Allan deviation slope {slope:e}"
2736 );
2737 let expected_tau1 = (1.0e-20_f64 / (2.0 * adev.tau_s[0])).sqrt();
2738 let ratio = adev.deviation[0] / expected_tau1;
2739 assert!(
2740 (0.5..1.5).contains(&ratio),
2741 "white-FM tau-1 Allan deviation ratio {ratio:e}"
2742 );
2743 }
2744
2745 #[test]
2746 fn rinex_export_reparses_to_same_observables() {
2747 let set = simulate_scenario(&base_scenario()).expect("simulate");
2748 let rinex = set.to_rinex_observation_file();
2749 let text = rinex.to_rinex_string();
2750 let reparsed = RinexObs::parse(&text).expect("parse generated RINEX");
2751 assert_eq!(reparsed, rinex);
2752 let sat = set.observations.satellite_id[0];
2753 let codes = rinex.header.obs_codes.get(&sat.system).expect("codes");
2754 let code_index = codes
2755 .iter()
2756 .position(|code| code == &set.observations.code_observable[0])
2757 .expect("code index");
2758 let exported = rinex.epochs[0].sats[&sat][code_index]
2759 .value
2760 .expect("exported value");
2761 assert_eq!(
2762 exported.to_bits(),
2763 round_rinex(set.observations.pseudorange_m[0]).to_bits()
2764 );
2765
2766 let rows = observation_values(&reparsed, &reparsed.epochs()[0], &ObservationFilter::all())
2767 .expect("observation rows");
2768 let pseudorange_count = rows
2769 .iter()
2770 .flat_map(|(_, rows)| rows)
2771 .filter(|row| row.kind == ObservationKind::Pseudorange && row.value.is_some())
2772 .count();
2773 assert_eq!(
2774 pseudorange_count,
2775 set.observations.epoch_offsets[1] - set.observations.epoch_offsets[0]
2776 );
2777 }
2778
2779 #[test]
2780 fn multiple_signals_per_constellation_survive_arrays_and_rinex() {
2781 let mut scenario = base_scenario();
2782 scenario.epochs.count = 1;
2783 scenario.signals.push(ScenarioSignal {
2784 system: GnssSystem::Gps,
2785 code_observable: "C2W".to_string(),
2786 phase_observable: "L2W".to_string(),
2787 doppler_observable: "D2W".to_string(),
2788 carrier_hz: 1_227_600_000.0,
2789 carrier_phase_bias_cycles: 12.0,
2790 });
2791 let set = simulate_scenario(&scenario).expect("simulate");
2792 assert_eq!(set.observation_count(), scenario.satellites().len() * 2);
2793
2794 let rinex = set.to_rinex_observation_file();
2795 let gps_codes = rinex
2796 .header
2797 .obs_codes
2798 .get(&GnssSystem::Gps)
2799 .expect("GPS codes");
2800 assert!(gps_codes.iter().any(|code| code == "C1C"));
2801 assert!(gps_codes.iter().any(|code| code == "C2W"));
2802 assert_eq!(rinex.epochs[0].sats.len(), scenario.satellites().len());
2803 for values in rinex.epochs[0].sats.values() {
2804 let filled_pseudorange = gps_codes
2805 .iter()
2806 .zip(values.iter())
2807 .filter(|(code, value)| code.starts_with('C') && value.value.is_some())
2808 .count();
2809 assert_eq!(filled_pseudorange, 2);
2810 }
2811 }
2812
2813 #[test]
2814 fn kinematic_receiver_velocity_contributes_to_doppler_terms() {
2815 let mut moving = base_scenario();
2816 moving.epochs.count = 1;
2817 let position = ScenarioGeodeticPosition {
2818 lat_rad: 0.0,
2819 lon_rad: 0.0,
2820 height_m: 0.0,
2821 };
2822 moving.receiver = ScenarioReceiver::KinematicWaypoints {
2823 waypoints: vec![
2824 ScenarioReceiverWaypoint {
2825 offset_s: 0.0,
2826 position,
2827 velocity_ecef_m_s: Some([100.0, 0.0, 0.0]),
2828 },
2829 ScenarioReceiverWaypoint {
2830 offset_s: 30.0,
2831 position,
2832 velocity_ecef_m_s: Some([100.0, 0.0, 0.0]),
2833 },
2834 ],
2835 };
2836 let mut static_scenario = base_scenario();
2837 static_scenario.epochs.count = 1;
2838
2839 let moving_set = simulate_scenario(&moving).expect("simulate moving");
2840 let static_set = simulate_scenario(&static_scenario).expect("simulate static");
2841 let delta_hz =
2842 moving_set.observations.doppler_hz[0] - static_set.observations.doppler_hz[0];
2843 assert_eq!(
2844 delta_hz.to_bits(),
2845 moving_set.truth_terms.doppler_receiver_motion_hz[0].to_bits()
2846 );
2847 assert!(delta_hz.abs() > 100.0);
2848 }
2849
2850 #[test]
2851 fn receiver_clock_drift_contributes_to_doppler_terms() {
2852 let mut scenario = base_scenario();
2853 scenario.epochs.count = 1;
2854 scenario.error_budget.receiver_clock = ScenarioClockModel {
2855 enabled: true,
2856 bias_s: 0.0,
2857 drift_s_s: 1.0e-10,
2858 power_law_coefficients: [0.0; 5],
2859 };
2860 let set = simulate_scenario(&scenario).expect("simulate");
2861 let expected = -1.0e-10 * F_L1_HZ;
2862 assert_eq!(
2863 set.truth_terms.doppler_receiver_clock_hz[0].to_bits(),
2864 expected.to_bits()
2865 );
2866 assert_eq!(
2867 set.truth_terms
2868 .doppler_sum_hz(0)
2869 .expect("doppler sum")
2870 .to_bits(),
2871 set.observations.doppler_hz[0].to_bits()
2872 );
2873 }
2874
2875 #[test]
2876 fn external_source_identity_is_checked() {
2877 let start_j2000_s = j2000_seconds(2026, 1, 1, 0, 0, 0.0);
2878 let satellites = gps_anchor_orbits(start_j2000_s);
2879 let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
2880 let mut identity = product(ScenarioExternalProductKind::Sp3, "synthetic-sp3", "pending");
2881 let mut scenario = base_scenario();
2882 scenario.constellation = ScenarioConstellation::ExternalProducts {
2883 source: identity.clone(),
2884 satellites: satellites.iter().map(|sat| sat.satellite_id).collect(),
2885 };
2886 let declared = DeclaredScenarioSource::new(&source, identity.clone());
2887 identity.content_digest = scenario_source_transcript_fingerprint(
2888 &scenario,
2889 &declared,
2890 &ScenarioMediaSources::default(),
2891 )
2892 .expect("source fingerprint");
2893 scenario.constellation = ScenarioConstellation::ExternalProducts {
2894 source: identity.clone(),
2895 satellites: satellites.iter().map(|sat| sat.satellite_id).collect(),
2896 };
2897 let declared = DeclaredScenarioSource::new(&source, identity.clone());
2898 simulate_scenario_with_source(&scenario, &declared).expect("matching identity");
2899
2900 let mut changed_satellites = satellites.clone();
2901 changed_satellites[0].semi_major_axis_m += 10.0;
2902 let changed_source = SyntheticKeplerSource::new(changed_satellites).expect("source");
2903 let mismatched_data = DeclaredScenarioSource::new(&changed_source, identity.clone());
2904 let err =
2905 simulate_scenario_with_source(&scenario, &mismatched_data).expect_err("data mismatch");
2906 assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
2907
2908 let wrong = DeclaredScenarioSource::new(
2909 &source,
2910 product(
2911 ScenarioExternalProductKind::Broadcast,
2912 "other",
2913 "sha256:other",
2914 ),
2915 );
2916 let err = simulate_scenario_with_source(&scenario, &wrong).expect_err("mismatch");
2917 assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
2918 }
2919
2920 #[test]
2921 fn supplied_ionex_requires_matching_media_and_contributes_terms() {
2922 let mut scenario = base_scenario();
2923 scenario.epochs.count = 1;
2924 let epoch_s = scenario.epochs.start_j2000_s.round() as i64;
2925 let ionex = constant_ionex(epoch_s, 12.0);
2926 let identity = product(
2927 ScenarioExternalProductKind::Ionex,
2928 "synthetic-ionex",
2929 &ionex_content_fingerprint(&ionex),
2930 );
2931 scenario.error_budget.ionosphere = ScenarioIonosphereModel::SuppliedIonex {
2932 source: identity.clone(),
2933 };
2934
2935 let missing = simulate_scenario(&scenario).expect_err("IONEX media required");
2936 assert!(matches!(missing, ScenarioError::ExternalIonosphereRequired));
2937
2938 let media = ScenarioMediaSources {
2939 ionex: Some(DeclaredIonexSource::new(&ionex, &identity)),
2940 };
2941 let set = simulate_scenario_with_media(&scenario, &media).expect("simulate with IONEX");
2942 assert!(set.truth_terms.ionosphere_m[0] > 0.0);
2943 assert!(
2944 set.truth_terms.carrier_phase_ionosphere_cycles[0] < 0.0,
2945 "carrier phase ionosphere has opposite sign"
2946 );
2947
2948 let wrong_identity = product(ScenarioExternalProductKind::Ionex, "other", "sha256:other");
2949 let wrong_media = ScenarioMediaSources {
2950 ionex: Some(DeclaredIonexSource::new(&ionex, &wrong_identity)),
2951 };
2952 let err = simulate_scenario_with_media(&scenario, &wrong_media).expect_err("mismatch");
2953 assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
2954 }
2955
2956 #[test]
2957 fn synthetic_kepler_source_has_fixed_geometry_anchor() {
2958 let scenario = base_scenario();
2959 let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation
2960 else {
2961 panic!("synthetic scenario expected");
2962 };
2963 let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
2964 let sat = satellites[0].satellite_id;
2965 let state = source
2966 .state_at_j2000_s(sat, scenario.epochs.start_j2000_s)
2967 .expect("state");
2968 assert!((state.position_ecef_m[0] - 26_560_000.0).abs() < 1.0e-8);
2969 assert!(state.position_ecef_m[1].abs() < 1.0e-8);
2970 assert!(state.position_ecef_m[2].abs() < 1.0e-8);
2971
2972 let receiver = geodetic_to_itrf(
2973 ScenarioGeodeticPosition {
2974 lat_rad: 0.0,
2975 lon_rad: 0.0,
2976 height_m: 0.0,
2977 }
2978 .to_wgs84()
2979 .expect("geodetic"),
2980 )
2981 .expect("ecef")
2982 .as_array();
2983 let geometric = norm3(sub3(state.position_ecef_m, receiver));
2984 assert!((geometric - 20_181_863.0).abs() < 1.0e-6);
2985 }
2986
2987 #[test]
2988 fn clean_scenario_spp_recovers_truth_to_numerical_precision() {
2989 let scenario = base_scenario();
2990 let set = simulate_scenario(&scenario).expect("simulate");
2991 let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation
2992 else {
2993 panic!("synthetic scenario expected");
2994 };
2995 let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
2996 let truth = set.receiver_truth[0];
2997 let inputs = SolveInputs {
2998 observations: set.spp_observations_for_epoch(0),
2999 t_rx_j2000_s: truth.t_rx_j2000_s,
3000 t_rx_second_of_day_s: 0.0,
3001 day_of_year: 1.0,
3002 initial_guess: [
3003 truth.position_ecef_m[0],
3004 truth.position_ecef_m[1],
3005 truth.position_ecef_m[2],
3006 truth.clock_m,
3007 ],
3008 corrections: Corrections::NONE,
3009 ..SolveInputs::default()
3010 };
3011 let solved = solve(&source, &inputs, false).expect("SPP solution");
3012 let delta = norm3(sub3(solved.position.as_array(), truth.position_ecef_m));
3013 assert!(
3014 delta < 1.0e-5,
3015 "closed-loop consistency position delta {delta}"
3016 );
3017 assert!(
3018 (solved.rx_clock_s - truth.clock_m / C_M_S).abs() < 1.0e-12,
3019 "closed-loop consistency clock"
3020 );
3021 }
3022}