Skip to main content

sidereon_core/
scenario.rs

1//! Scenario-driven synthetic GNSS observable generation.
2//!
3//! The module is sans I/O: callers pass a typed scenario and, when needed, an
4//! already loaded ephemeris source. The output is a set of contiguous arrays
5//! plus a ground-truth term ledger. RINEX OBS text is an explicit serialization
6//! step through the existing in-core RINEX observation writer.
7
8use 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
28/// Version of the serialized scenario schema accepted by this module.
29pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
30
31/// Engine version string that participates in the determinism contract.
32pub const SCENARIO_ENGINE_VERSION: &str =
33    concat!(env!("CARGO_PKG_VERSION"), ":scenario-observables-v1");
34
35/// Default deterministic seed for examples and tests.
36pub 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/// Error returned by scenario validation or generation.
45#[derive(Debug, thiserror::Error)]
46pub enum ScenarioError {
47    /// A scenario field was malformed, non-finite, or outside its domain.
48    #[error("invalid scenario {field}: {reason}")]
49    InvalidInput {
50        /// The invalid field name.
51        field: &'static str,
52        /// The validation failure reason.
53        reason: &'static str,
54    },
55    /// The scenario names external products but no source was supplied.
56    #[error("scenario requires an external ephemeris source")]
57    ExternalSourceRequired,
58    /// The supplied external source identity does not match the scenario.
59    #[error("external source identity mismatch for {field}: expected {expected}, got {actual}")]
60    ExternalSourceMismatch {
61        /// Field carrying the mismatched source identity.
62        field: &'static str,
63        /// Identity declared by the scenario.
64        expected: String,
65        /// Identity supplied with the loaded source.
66        actual: String,
67    },
68    /// The scenario names an external ionosphere product but none was supplied.
69    #[error("scenario requires a declared IONEX source")]
70    ExternalIonosphereRequired,
71    /// Supplied ionosphere product evaluation failed.
72    #[error("ionosphere evaluation failed: {0}")]
73    Ionosphere(String),
74    /// The ephemeris source had no usable state for the satellite.
75    #[error("no ephemeris state for {satellite}")]
76    NoEphemeris {
77        /// Satellite whose state was unavailable.
78        satellite: GnssSatelliteId,
79    },
80    /// Observable prediction failed while forming Doppler or geometry metadata.
81    #[error("observable prediction failed: {0}")]
82    Observable(ObservablesError),
83    /// Receiver frame conversion failed.
84    #[error("frame conversion failed: {0}")]
85    Frame(String),
86}
87
88/// Versioned synthetic-observable scenario.
89#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct Scenario {
91    /// Schema version. Must equal [`SCENARIO_SCHEMA_VERSION`].
92    pub schema_version: u32,
93    /// Seed for every deterministic random stream.
94    pub seed: u64,
95    /// Epoch grid to simulate.
96    pub epochs: ScenarioEpochRange,
97    /// Receiver truth model.
98    pub receiver: ScenarioReceiver,
99    /// Satellite source selection.
100    pub constellation: ScenarioConstellation,
101    /// Per-constellation observable and carrier selection.
102    pub signals: Vec<ScenarioSignal>,
103    /// Independent error-budget switches and parameters.
104    pub error_budget: ScenarioErrorBudget,
105}
106
107impl Scenario {
108    /// Validate the schema and numeric domains.
109    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    /// Satellites named by the scenario, in scenario order.
127    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/// Inclusive-start regular epoch grid.
138#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
139pub struct ScenarioEpochRange {
140    /// First receive epoch, seconds since J2000 in the selected GNSS time scale.
141    pub start_j2000_s: f64,
142    /// Number of epochs to generate.
143    pub count: usize,
144    /// Spacing between epochs in seconds.
145    pub cadence_s: f64,
146}
147
148impl ScenarioEpochRange {
149    /// Validate finite positive cadence and non-empty count.
150    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    /// Epoch seconds since J2000 in generation order.
160    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/// Receiver truth model.
168#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
169#[serde(tag = "kind", rename_all = "snake_case")]
170pub enum ScenarioReceiver {
171    /// Receiver fixed at one WGS84 geodetic position.
172    StaticGeodetic {
173        /// Fixed geodetic position.
174        position: ScenarioGeodeticPosition,
175    },
176    /// Receiver position interpolated between geodetic waypoints.
177    KinematicWaypoints {
178        /// Waypoints keyed by offset from the first scenario epoch.
179        waypoints: Vec<ScenarioReceiverWaypoint>,
180    },
181}
182
183impl ScenarioReceiver {
184    /// Validate receiver fields.
185    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/// WGS84 geodetic position in radians and meters.
210#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
211pub struct ScenarioGeodeticPosition {
212    /// Geodetic latitude in radians.
213    pub lat_rad: f64,
214    /// Geodetic longitude in radians, positive east.
215    pub lon_rad: f64,
216    /// Ellipsoidal height in meters.
217    pub height_m: f64,
218}
219
220impl ScenarioGeodeticPosition {
221    /// Convert to the frame-typed geodetic value.
222    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    /// Validate the geodetic value.
228    pub fn validate(&self) -> Result<(), ScenarioError> {
229        self.to_wgs84().map(|_| ())
230    }
231}
232
233/// Kinematic receiver waypoint.
234#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
235pub struct ScenarioReceiverWaypoint {
236    /// Seconds after the scenario start epoch.
237    pub offset_s: f64,
238    /// Receiver position at this waypoint.
239    pub position: ScenarioGeodeticPosition,
240    /// Optional ECEF velocity to report at the waypoint.
241    pub velocity_ecef_m_s: Option<[f64; 3]>,
242}
243
244impl ScenarioReceiverWaypoint {
245    /// Validate finite time, position, and optional velocity.
246    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/// Product class for a declared external scenario input.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum ScenarioExternalProductKind {
261    /// Precise SP3 orbit and clock product.
262    Sp3,
263    /// Broadcast navigation product.
264    Broadcast,
265    /// Two-line element product evaluated through the crate's TLE path.
266    Tle,
267    /// IONEX vertical-TEC grid product.
268    Ionex,
269}
270
271/// Stable identity of an external product named by a scenario.
272#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
273pub struct ScenarioExternalProduct {
274    /// External product class.
275    pub kind: ScenarioExternalProductKind,
276    /// Caller-controlled stable product identifier, such as a catalog path or name.
277    pub product_id: String,
278    /// Core-verified content fingerprint string for the supplied product.
279    pub content_digest: String,
280}
281
282impl ScenarioExternalProduct {
283    /// Validate non-empty product identity fields.
284    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/// Satellite source selection for a scenario.
306#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
307#[serde(tag = "kind", rename_all = "snake_case")]
308pub enum ScenarioConstellation {
309    /// Satellites are evaluated from an external loaded source.
310    ExternalProducts {
311        /// Declared external orbit or navigation product identity.
312        source: ScenarioExternalProduct,
313        /// Satellites to request from the external source.
314        satellites: Vec<GnssSatelliteId>,
315    },
316    /// Satellites are evaluated from in-scenario Keplerian elements.
317    SyntheticKeplerian {
318        /// Synthetic Keplerian orbit records.
319        satellites: Vec<SyntheticKeplerOrbit>,
320    },
321}
322
323impl ScenarioConstellation {
324    /// Validate that at least one satellite is present and all orbit records are valid.
325    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/// One synthetic two-body Keplerian satellite.
354#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
355pub struct SyntheticKeplerOrbit {
356    /// Satellite identifier used in output arrays and RINEX.
357    pub satellite_id: GnssSatelliteId,
358    /// Semi-major axis in meters.
359    pub semi_major_axis_m: f64,
360    /// Eccentricity, in `[0, 1)`.
361    pub eccentricity: f64,
362    /// Inclination in radians.
363    pub inclination_rad: f64,
364    /// Right ascension of ascending node in radians.
365    pub raan_rad: f64,
366    /// Argument of perigee in radians.
367    pub arg_perigee_rad: f64,
368    /// Mean anomaly at `epoch_j2000_s`, radians.
369    pub mean_anomaly_rad: f64,
370    /// Element epoch, seconds since J2000.
371    pub epoch_j2000_s: f64,
372    /// Nominal satellite clock offset at the element epoch, seconds.
373    pub clock_bias_s: f64,
374    /// Nominal satellite clock drift, seconds per second.
375    pub clock_drift_s_s: f64,
376}
377
378impl SyntheticKeplerOrbit {
379    /// Validate orbit fields.
380    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/// Per-constellation observable and carrier selection.
403#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
404pub struct ScenarioSignal {
405    /// Constellation this row applies to.
406    pub system: GnssSystem,
407    /// RINEX code observable, for example `C1C`.
408    pub code_observable: String,
409    /// RINEX carrier phase observable, for example `L1C`.
410    pub phase_observable: String,
411    /// RINEX Doppler observable, for example `D1C`.
412    pub doppler_observable: String,
413    /// Carrier frequency in hertz.
414    pub carrier_hz: f64,
415    /// Constant carrier-phase ambiguity in cycles.
416    pub carrier_phase_bias_cycles: f64,
417}
418
419impl ScenarioSignal {
420    /// Build a GPS L1 C/A style signal row for one constellation.
421    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    /// Validate the signal row.
433    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/// Independent error-budget switches and parameters.
448#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
449pub struct ScenarioErrorBudget {
450    /// Receiver clock model. Its output contributes with positive range sign.
451    pub receiver_clock: ScenarioClockModel,
452    /// Satellite clock model. Its output contributes with negative range sign.
453    pub satellite_clock: ScenarioClockModel,
454    /// Ionosphere range-delay model.
455    pub ionosphere: ScenarioIonosphereModel,
456    /// Troposphere range-delay model.
457    pub troposphere: ScenarioTroposphereModel,
458    /// Thermal noise by observable.
459    pub thermal_noise: ScenarioThermalNoise,
460    /// One-path specular multipath model.
461    pub multipath: ScenarioSpecularMultipath,
462    /// Minimum topocentric elevation in degrees.
463    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    /// Validate all budget fields.
482    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/// Receiver or satellite clock model with IEEE-1139 coefficient order.
504#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
505pub struct ScenarioClockModel {
506    /// Whether this clock term is active.
507    pub enabled: bool,
508    /// Constant clock offset, seconds.
509    pub bias_s: f64,
510    /// Linear clock drift, seconds per second.
511    pub drift_s_s: f64,
512    /// Power-law coefficients `[h_-2, h_-1, h_0, h_1, h_2]`.
513    pub power_law_coefficients: [f64; 5],
514}
515
516impl ScenarioClockModel {
517    /// Disabled zero clock model.
518    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    /// Validate finite non-negative coefficient fields.
528    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/// Ionosphere model selection.
545#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
546#[serde(tag = "kind", rename_all = "snake_case")]
547pub enum ScenarioIonosphereModel {
548    /// No ionosphere delay.
549    Off,
550    /// Broadcast Klobuchar model, in the SPP operation order.
551    Klobuchar {
552        /// Alpha coefficients.
553        alpha: [f64; 4],
554        /// Beta coefficients.
555        beta: [f64; 4],
556    },
557    /// Supplied IONEX grid product evaluated by the existing IONEX machinery.
558    SuppliedIonex {
559        /// Declared IONEX product identity.
560        source: ScenarioExternalProduct,
561    },
562}
563
564impl ScenarioIonosphereModel {
565    /// Validate model coefficients.
566    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/// Troposphere model selection.
614#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
615#[serde(tag = "kind", rename_all = "snake_case")]
616pub enum ScenarioTroposphereModel {
617    /// No troposphere delay.
618    Off,
619    /// Saastamoinen zenith delay with Niell mapping through the SPP path.
620    SaastamoinenNiell {
621        /// Surface pressure in hPa.
622        pressure_hpa: f64,
623        /// Surface temperature in kelvin.
624        temperature_k: f64,
625        /// Relative humidity fraction in `[0, 1]`.
626        relative_humidity: f64,
627    },
628}
629
630impl ScenarioTroposphereModel {
631    /// Validate model parameters.
632    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/// Thermal noise standard deviations.
684#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
685pub struct ScenarioThermalNoise {
686    /// Whether thermal noise is active.
687    pub enabled: bool,
688    /// Pseudorange standard deviation in meters.
689    pub pseudorange_sigma_m: f64,
690    /// Carrier phase standard deviation in meters.
691    pub carrier_phase_sigma_m: f64,
692    /// Doppler standard deviation in hertz.
693    pub doppler_sigma_hz: f64,
694}
695
696impl ScenarioThermalNoise {
697    /// Disabled zero-noise model.
698    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    /// Validate non-negative finite standard deviations.
708    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/// One-path specular multipath model.
733#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
734pub struct ScenarioSpecularMultipath {
735    /// Whether multipath is active.
736    pub enabled: bool,
737    /// Code-range amplitude in meters.
738    pub amplitude_m: f64,
739    /// Reflector height above the antenna phase center, meters.
740    pub reflector_height_m: f64,
741    /// Phase offset in radians.
742    pub phase_rad: f64,
743}
744
745impl ScenarioSpecularMultipath {
746    /// Disabled zero-multipath model.
747    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    /// Validate finite non-negative amplitude and height.
757    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/// Receiver truth state for one epoch.
779#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
780pub struct SyntheticReceiverTruth {
781    /// Receive epoch, seconds since J2000.
782    pub t_rx_j2000_s: f64,
783    /// Receiver ECEF position in meters.
784    pub position_ecef_m: [f64; 3],
785    /// Receiver ECEF velocity in meters per second.
786    pub velocity_ecef_m_s: [f64; 3],
787    /// Receiver clock contribution in meters.
788    pub clock_m: f64,
789    /// Receiver clock range-rate contribution in meters per second.
790    pub clock_rate_m_s: f64,
791}
792
793/// Contiguous synthetic observable arrays.
794#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
795pub struct SyntheticObservableArrays {
796    /// Start index of each epoch in every per-observation array.
797    pub epoch_offsets: Vec<usize>,
798    /// Epoch index for each observation.
799    pub epoch_index: Vec<usize>,
800    /// Satellite id for each observation.
801    pub satellite_id: Vec<GnssSatelliteId>,
802    /// Code observable label for each observation.
803    pub code_observable: Vec<String>,
804    /// Carrier phase observable label for each observation.
805    pub phase_observable: Vec<String>,
806    /// Doppler observable label for each observation.
807    pub doppler_observable: Vec<String>,
808    /// Carrier frequency in hertz for each observation.
809    pub carrier_hz: Vec<f64>,
810    /// Synthetic code pseudorange in meters.
811    pub pseudorange_m: Vec<f64>,
812    /// Synthetic carrier phase in cycles.
813    pub carrier_phase_cycles: Vec<f64>,
814    /// Synthetic Doppler shift in hertz.
815    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/// Per-observation ground-truth term arrays.
840#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
841pub struct SyntheticTermArrays {
842    /// Geometric range in meters.
843    pub geometric_range_m: Vec<f64>,
844    /// Nominal ephemeris satellite-clock contribution in meters.
845    pub satellite_clock_m: Vec<f64>,
846    /// Receiver-clock contribution in meters.
847    pub receiver_clock_m: Vec<f64>,
848    /// Injected satellite-clock contribution in meters.
849    pub satellite_clock_error_m: Vec<f64>,
850    /// Ionospheric code delay in meters.
851    pub ionosphere_m: Vec<f64>,
852    /// Tropospheric delay in meters.
853    pub troposphere_m: Vec<f64>,
854    /// Thermal code noise in meters.
855    pub thermal_noise_m: Vec<f64>,
856    /// Specular code multipath in meters.
857    pub multipath_m: Vec<f64>,
858    /// Core quantization contribution in meters. This is zero before explicit serialization.
859    pub quantization_m: Vec<f64>,
860    /// Carrier geometric range contribution in cycles.
861    pub carrier_phase_geometric_cycles: Vec<f64>,
862    /// Carrier receiver-clock contribution in cycles.
863    pub carrier_phase_receiver_clock_cycles: Vec<f64>,
864    /// Carrier nominal satellite-clock contribution in cycles.
865    pub carrier_phase_satellite_clock_cycles: Vec<f64>,
866    /// Carrier injected satellite-clock contribution in cycles.
867    pub carrier_phase_satellite_clock_error_cycles: Vec<f64>,
868    /// Carrier ionosphere contribution in cycles.
869    pub carrier_phase_ionosphere_cycles: Vec<f64>,
870    /// Carrier troposphere contribution in cycles.
871    pub carrier_phase_troposphere_cycles: Vec<f64>,
872    /// Carrier thermal-noise contribution in cycles.
873    pub carrier_phase_thermal_noise_cycles: Vec<f64>,
874    /// Constant carrier-phase ambiguity contribution in cycles.
875    pub carrier_phase_bias_cycles: Vec<f64>,
876    /// Core carrier quantization contribution in cycles. This is zero before explicit serialization.
877    pub carrier_phase_quantization_cycles: Vec<f64>,
878    /// Doppler contribution from satellite line-of-sight motion in hertz.
879    pub doppler_satellite_motion_hz: Vec<f64>,
880    /// Doppler contribution from receiver line-of-sight motion in hertz.
881    pub doppler_receiver_motion_hz: Vec<f64>,
882    /// Doppler contribution from nominal ephemeris satellite-clock rate in hertz.
883    pub doppler_satellite_clock_hz: Vec<f64>,
884    /// Doppler contribution from receiver-clock rate in hertz.
885    pub doppler_receiver_clock_hz: Vec<f64>,
886    /// Doppler contribution from injected satellite-clock rate in hertz.
887    pub doppler_satellite_clock_error_hz: Vec<f64>,
888    /// Doppler thermal-noise contribution in hertz.
889    pub doppler_thermal_noise_hz: Vec<f64>,
890    /// Core Doppler quantization contribution in hertz. This is zero before explicit serialization.
891    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    /// Sum the pseudorange terms for one observation in the generator order.
971    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    /// Sum the carrier-phase terms for one observation in the generator order.
985    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    /// Sum the Doppler terms for one observation in the generator order.
999    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/// Complete synthetic observation output.
1012#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1013pub struct SyntheticObservationSet {
1014    /// Scenario schema version used to produce this output.
1015    pub schema_version: u32,
1016    /// Engine version used to produce this output.
1017    pub engine_version: String,
1018    /// Seed used to produce this output.
1019    pub seed: u64,
1020    /// Receiver truth, one row per epoch.
1021    pub receiver_truth: Vec<SyntheticReceiverTruth>,
1022    /// Contiguous observable arrays.
1023    pub observations: SyntheticObservableArrays,
1024    /// Per-observation term decomposition.
1025    pub truth_terms: SyntheticTermArrays,
1026}
1027
1028impl SyntheticObservationSet {
1029    /// Number of synthetic observations.
1030    pub fn observation_count(&self) -> usize {
1031        self.observations.len()
1032    }
1033
1034    /// Deterministic FNV-1a fingerprint over output bits.
1035    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    /// Build an in-core RINEX observation product from the synthetic arrays.
1135    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    /// Serialize the synthetic observations to RINEX OBS text.
1249    pub fn to_rinex_string(&self) -> String {
1250        self.to_rinex_observation_file().to_rinex_string()
1251    }
1252
1253    /// Build SPP observations for one epoch from the pseudorange arrays.
1254    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/// Already loaded ephemeris source paired with its scenario identity.
1292#[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    /// Pair an in-memory ephemeris source with the identity declared by a scenario.
1300    pub fn new(source: &'a E, identity: ScenarioExternalProduct) -> Self {
1301        Self { source, identity }
1302    }
1303
1304    /// Declared identity for this source.
1305    pub fn identity(&self) -> &ScenarioExternalProduct {
1306        &self.identity
1307    }
1308
1309    /// Wrapped ephemeris source.
1310    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/// Parsed IONEX product paired with its scenario identity.
1421#[derive(Debug, Clone, Copy)]
1422pub struct DeclaredIonexSource<'a> {
1423    product: &'a Ionex,
1424    identity: &'a ScenarioExternalProduct,
1425}
1426
1427impl<'a> DeclaredIonexSource<'a> {
1428    /// Pair an in-memory IONEX product with the identity declared by a scenario.
1429    pub const fn new(product: &'a Ionex, identity: &'a ScenarioExternalProduct) -> Self {
1430        Self { product, identity }
1431    }
1432
1433    /// Declared identity for this product.
1434    pub const fn identity(&self) -> &'a ScenarioExternalProduct {
1435        self.identity
1436    }
1437
1438    /// Wrapped IONEX product.
1439    pub const fn product(&self) -> &'a Ionex {
1440        self.product
1441    }
1442}
1443
1444/// Optional external media products needed by a scenario.
1445#[derive(Debug, Clone, Copy, Default)]
1446pub struct ScenarioMediaSources<'a> {
1447    /// Declared IONEX grid, required when the ionosphere model is `supplied_ionex`.
1448    pub ionex: Option<DeclaredIonexSource<'a>>,
1449}
1450
1451/// Synthetic Keplerian ephemeris source.
1452#[derive(Debug, Clone, PartialEq)]
1453pub struct SyntheticKeplerSource {
1454    satellites: Vec<SyntheticKeplerOrbit>,
1455}
1456
1457impl SyntheticKeplerSource {
1458    /// Build a source from validated orbit records.
1459    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    /// Orbit records in source order.
1471    pub fn satellites(&self) -> &[SyntheticKeplerOrbit] {
1472        &self.satellites
1473    }
1474
1475    /// Evaluate one satellite state at seconds since J2000.
1476    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
1511/// Simulate a scenario that carries synthetic Keplerian satellite records.
1512pub fn simulate_scenario(scenario: &Scenario) -> Result<SyntheticObservationSet, ScenarioError> {
1513    simulate_scenario_with_media(scenario, &ScenarioMediaSources::default())
1514}
1515
1516/// Simulate a synthetic-Keplerian scenario with declared external media products.
1517pub 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
1529/// Simulate an external-product scenario against a declared loaded ephemeris source.
1530pub 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
1540/// Simulate an external-product scenario with declared ephemeris and media sources.
1541pub 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
1573/// Compute the core-verified ephemeris transcript fingerprint for a scenario and source.
1574pub 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
1606/// Compute the core-verified fingerprint for a parsed IONEX product.
1607pub 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    //! Provenance: scenario observation equations use the standard GNSS code
2523    //! range model `P = rho + c(dt_r - dt_s) + I + T + errors` described in
2524    //! Kaplan and Hegarty plus Misra and Enge. Clock-noise coefficient labels
2525    //! follow IEEE Std 1139-2008 through the in-crate clock-stability module.
2526    //! The specular multipath fixture uses the standard one-reflector sinusoid
2527    //! in carrier wavelength and elevation. The closed-loop tests are
2528    //! consistency checks against this crate's SPP model, not truth validation.
2529
2530    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}