use std::cell::Cell;
use std::collections::BTreeMap;
use crate::astro::constants::earth::GM_EARTH_M3_S2;
use crate::astro::math::vec3::{norm3, sub3};
use crate::astro::time::civil::{civil_from_j2000_seconds, day_of_year, second_of_day};
use crate::astro::time::model::TimeScale;
use crate::atmosphere::{ionex_slant_delay, Ionex};
use crate::clock_stability::PowerLawNoiseType;
use crate::constants::{C_M_S, F_L1_HZ, OMEGA_E_DOT_RAD_S};
use crate::frame::{geodetic_to_itrf, itrf_to_geodetic, ItrfPositionM, Wgs84Geodetic};
use crate::id::{GnssSatelliteId, GnssSystem};
use crate::observables::{predict, ObservableEphemerisSource, ObservableState, ObservablesError};
use crate::rinex_obs::{ObsEpoch, ObsEpochTime, ObsHeader, ObsValue, PgmRunByDate, RinexObs};
use crate::spp::{
sat_model, Corrections, EphemerisSource, KlobucharCoeffs, SatModelEnv, SppIonosphere,
SppModelRecipe, SurfaceMet,
};
use crate::validate;
pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
pub const SCENARIO_ENGINE_VERSION: &str =
concat!(env!("CARGO_PKG_VERSION"), ":scenario-observables-v1");
pub const DEFAULT_SCENARIO_SEED: u64 = 0x515c_1e7e_0b5e_a11d;
const RINEX_QUANTIZATION: f64 = 1000.0;
const FIXED_POINT_ITERS: usize = 8;
const NOISE_STREAM_OBSERVABLE: u64 = 0x0b5e_a11d_f00d_0001;
const NOISE_STREAM_RECEIVER_CLOCK: u64 = 0x0b5e_a11d_f00d_0002;
const NOISE_STREAM_SAT_CLOCK: u64 = 0x0b5e_a11d_f00d_0003;
#[derive(Debug, thiserror::Error)]
pub enum ScenarioError {
#[error("invalid scenario {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
#[error("scenario requires an external ephemeris source")]
ExternalSourceRequired,
#[error("external source identity mismatch for {field}: expected {expected}, got {actual}")]
ExternalSourceMismatch {
field: &'static str,
expected: String,
actual: String,
},
#[error("scenario requires a declared IONEX source")]
ExternalIonosphereRequired,
#[error("ionosphere evaluation failed: {0}")]
Ionosphere(String),
#[error("no ephemeris state for {satellite}")]
NoEphemeris {
satellite: GnssSatelliteId,
},
#[error("observable prediction failed: {0}")]
Observable(ObservablesError),
#[error("frame conversion failed: {0}")]
Frame(String),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Scenario {
pub schema_version: u32,
pub seed: u64,
pub epochs: ScenarioEpochRange,
pub receiver: ScenarioReceiver,
pub constellation: ScenarioConstellation,
pub signals: Vec<ScenarioSignal>,
pub error_budget: ScenarioErrorBudget,
}
impl Scenario {
pub fn validate(&self) -> Result<(), ScenarioError> {
if self.schema_version != SCENARIO_SCHEMA_VERSION {
return Err(invalid("schema_version", "unsupported schema version"));
}
self.epochs.validate()?;
self.receiver.validate()?;
self.constellation.validate()?;
if self.signals.is_empty() {
return Err(invalid("signals", "must not be empty"));
}
for signal in &self.signals {
signal.validate()?;
}
self.error_budget.validate()?;
Ok(())
}
pub fn satellites(&self) -> Vec<GnssSatelliteId> {
match &self.constellation {
ScenarioConstellation::ExternalProducts { satellites, .. } => satellites.clone(),
ScenarioConstellation::SyntheticKeplerian { satellites } => {
satellites.iter().map(|sat| sat.satellite_id).collect()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioEpochRange {
pub start_j2000_s: f64,
pub count: usize,
pub cadence_s: f64,
}
impl ScenarioEpochRange {
pub fn validate(&self) -> Result<(), ScenarioError> {
validate::finite(self.start_j2000_s, "epochs.start_j2000_s").map_err(map_field)?;
if self.count == 0 {
return Err(invalid("epochs.count", "must be positive"));
}
validate::finite_positive(self.cadence_s, "epochs.cadence_s").map_err(map_field)?;
Ok(())
}
pub fn epochs_j2000_s(&self) -> Vec<f64> {
(0..self.count)
.map(|idx| self.start_j2000_s + self.cadence_s * idx as f64)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScenarioReceiver {
StaticGeodetic {
position: ScenarioGeodeticPosition,
},
KinematicWaypoints {
waypoints: Vec<ScenarioReceiverWaypoint>,
},
}
impl ScenarioReceiver {
pub fn validate(&self) -> Result<(), ScenarioError> {
match self {
Self::StaticGeodetic { position } => position.validate(),
Self::KinematicWaypoints { waypoints } => {
if waypoints.len() < 2 {
return Err(invalid("receiver.waypoints", "must contain at least two"));
}
let mut previous = None;
for waypoint in waypoints {
waypoint.validate()?;
if previous.is_some_and(|t| waypoint.offset_s <= t) {
return Err(invalid(
"receiver.waypoints.offset_s",
"must increase strictly",
));
}
previous = Some(waypoint.offset_s);
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioGeodeticPosition {
pub lat_rad: f64,
pub lon_rad: f64,
pub height_m: f64,
}
impl ScenarioGeodeticPosition {
pub fn to_wgs84(self) -> Result<Wgs84Geodetic, ScenarioError> {
Wgs84Geodetic::new(self.lat_rad, self.lon_rad, self.height_m)
.map_err(|error| ScenarioError::Frame(error.to_string()))
}
pub fn validate(&self) -> Result<(), ScenarioError> {
self.to_wgs84().map(|_| ())
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioReceiverWaypoint {
pub offset_s: f64,
pub position: ScenarioGeodeticPosition,
pub velocity_ecef_m_s: Option<[f64; 3]>,
}
impl ScenarioReceiverWaypoint {
pub fn validate(&self) -> Result<(), ScenarioError> {
validate::finite(self.offset_s, "receiver.waypoint.offset_s").map_err(map_field)?;
self.position.validate()?;
if let Some(velocity) = self.velocity_ecef_m_s {
validate::finite_vec3(velocity, "receiver.waypoint.velocity_ecef_m_s")
.map_err(map_field)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScenarioExternalProductKind {
Sp3,
Broadcast,
Tle,
Ionex,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioExternalProduct {
pub kind: ScenarioExternalProductKind,
pub product_id: String,
pub content_digest: String,
}
impl ScenarioExternalProduct {
pub fn validate(&self, field: &'static str) -> Result<(), ScenarioError> {
if self.product_id.is_empty() {
return Err(invalid(field, "product_id must not be empty"));
}
if self.content_digest.is_empty() {
return Err(invalid(field, "content_digest must not be empty"));
}
if !self.product_id.is_ascii() || !self.content_digest.is_ascii() {
return Err(invalid(field, "identity fields must be ASCII"));
}
Ok(())
}
fn label(&self) -> String {
format!(
"{:?}:{}:{}",
self.kind, self.product_id, self.content_digest
)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScenarioConstellation {
ExternalProducts {
source: ScenarioExternalProduct,
satellites: Vec<GnssSatelliteId>,
},
SyntheticKeplerian {
satellites: Vec<SyntheticKeplerOrbit>,
},
}
impl ScenarioConstellation {
pub fn validate(&self) -> Result<(), ScenarioError> {
match self {
Self::ExternalProducts { source, satellites } => {
source.validate("constellation.source")?;
if source.kind == ScenarioExternalProductKind::Ionex {
return Err(invalid(
"constellation.source.kind",
"must be sp3, broadcast, or tle",
));
}
if satellites.is_empty() {
return Err(invalid("constellation.satellites", "must not be empty"));
}
Ok(())
}
Self::SyntheticKeplerian { satellites } => {
if satellites.is_empty() {
return Err(invalid("constellation.satellites", "must not be empty"));
}
for satellite in satellites {
satellite.validate()?;
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SyntheticKeplerOrbit {
pub satellite_id: GnssSatelliteId,
pub semi_major_axis_m: f64,
pub eccentricity: f64,
pub inclination_rad: f64,
pub raan_rad: f64,
pub arg_perigee_rad: f64,
pub mean_anomaly_rad: f64,
pub epoch_j2000_s: f64,
pub clock_bias_s: f64,
pub clock_drift_s_s: f64,
}
impl SyntheticKeplerOrbit {
pub fn validate(&self) -> Result<(), ScenarioError> {
validate::finite_positive(self.semi_major_axis_m, "orbit.semi_major_axis_m")
.map_err(map_field)?;
validate::finite(self.eccentricity, "orbit.eccentricity").map_err(map_field)?;
if !(0.0..1.0).contains(&self.eccentricity) {
return Err(invalid("orbit.eccentricity", "must be in [0, 1)"));
}
for (field, value) in [
("orbit.inclination_rad", self.inclination_rad),
("orbit.raan_rad", self.raan_rad),
("orbit.arg_perigee_rad", self.arg_perigee_rad),
("orbit.mean_anomaly_rad", self.mean_anomaly_rad),
("orbit.epoch_j2000_s", self.epoch_j2000_s),
("orbit.clock_bias_s", self.clock_bias_s),
("orbit.clock_drift_s_s", self.clock_drift_s_s),
] {
validate::finite(value, field).map_err(map_field)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioSignal {
pub system: GnssSystem,
pub code_observable: String,
pub phase_observable: String,
pub doppler_observable: String,
pub carrier_hz: f64,
pub carrier_phase_bias_cycles: f64,
}
impl ScenarioSignal {
pub fn l1_ca(system: GnssSystem) -> Self {
Self {
system,
code_observable: "C1C".to_string(),
phase_observable: "L1C".to_string(),
doppler_observable: "D1C".to_string(),
carrier_hz: F_L1_HZ,
carrier_phase_bias_cycles: 0.0,
}
}
pub fn validate(&self) -> Result<(), ScenarioError> {
validate_code(&self.code_observable, "signal.code_observable", b'C')?;
validate_code(&self.phase_observable, "signal.phase_observable", b'L')?;
validate_code(&self.doppler_observable, "signal.doppler_observable", b'D')?;
validate::finite_positive(self.carrier_hz, "signal.carrier_hz").map_err(map_field)?;
validate::finite(
self.carrier_phase_bias_cycles,
"signal.carrier_phase_bias_cycles",
)
.map_err(map_field)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioErrorBudget {
pub receiver_clock: ScenarioClockModel,
pub satellite_clock: ScenarioClockModel,
pub ionosphere: ScenarioIonosphereModel,
pub troposphere: ScenarioTroposphereModel,
pub thermal_noise: ScenarioThermalNoise,
pub multipath: ScenarioSpecularMultipath,
pub elevation_mask_deg: f64,
}
impl Default for ScenarioErrorBudget {
fn default() -> Self {
Self {
receiver_clock: ScenarioClockModel::disabled(),
satellite_clock: ScenarioClockModel::disabled(),
ionosphere: ScenarioIonosphereModel::Off,
troposphere: ScenarioTroposphereModel::Off,
thermal_noise: ScenarioThermalNoise::disabled(),
multipath: ScenarioSpecularMultipath::disabled(),
elevation_mask_deg: 0.0,
}
}
}
impl ScenarioErrorBudget {
pub fn validate(&self) -> Result<(), ScenarioError> {
self.receiver_clock
.validate("error_budget.receiver_clock")?;
self.satellite_clock
.validate("error_budget.satellite_clock")?;
self.ionosphere.validate()?;
self.troposphere.validate()?;
self.thermal_noise.validate()?;
self.multipath.validate()?;
validate::finite(self.elevation_mask_deg, "error_budget.elevation_mask_deg")
.map_err(map_field)?;
if !(-90.0..=90.0).contains(&self.elevation_mask_deg) {
return Err(invalid(
"error_budget.elevation_mask_deg",
"must be in [-90, 90]",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioClockModel {
pub enabled: bool,
pub bias_s: f64,
pub drift_s_s: f64,
pub power_law_coefficients: [f64; 5],
}
impl ScenarioClockModel {
pub const fn disabled() -> Self {
Self {
enabled: false,
bias_s: 0.0,
drift_s_s: 0.0,
power_law_coefficients: [0.0; 5],
}
}
pub fn validate(&self, prefix: &'static str) -> Result<(), ScenarioError> {
validate::finite(self.bias_s, prefix).map_err(map_field)?;
validate::finite(self.drift_s_s, prefix).map_err(map_field)?;
for &coefficient in &self.power_law_coefficients {
validate::finite(coefficient, prefix).map_err(map_field)?;
if coefficient < 0.0 {
return Err(invalid(
prefix,
"power-law coefficients must be non-negative",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScenarioIonosphereModel {
Off,
Klobuchar {
alpha: [f64; 4],
beta: [f64; 4],
},
SuppliedIonex {
source: ScenarioExternalProduct,
},
}
impl ScenarioIonosphereModel {
pub fn validate(&self) -> Result<(), ScenarioError> {
match self {
Self::Off => Ok(()),
Self::Klobuchar { alpha, beta } => {
validate::finite_slice(alpha, "error_budget.ionosphere.alpha")
.map_err(map_field)?;
validate::finite_slice(beta, "error_budget.ionosphere.beta").map_err(map_field)?;
Ok(())
}
Self::SuppliedIonex { source } => {
source.validate("error_budget.ionosphere.source")?;
if source.kind != ScenarioExternalProductKind::Ionex {
return Err(invalid(
"error_budget.ionosphere.source.kind",
"must be ionex",
));
}
Ok(())
}
}
}
fn corrections(&self) -> Corrections {
Corrections {
ionosphere: matches!(self, Self::Klobuchar { .. }),
troposphere: false,
}
}
fn coefficients(&self) -> KlobucharCoeffs {
match self {
Self::Off => KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
},
Self::Klobuchar { alpha, beta } => KlobucharCoeffs {
alpha: *alpha,
beta: *beta,
},
Self::SuppliedIonex { .. } => KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScenarioTroposphereModel {
Off,
SaastamoinenNiell {
pressure_hpa: f64,
temperature_k: f64,
relative_humidity: f64,
},
}
impl ScenarioTroposphereModel {
pub fn validate(&self) -> Result<(), ScenarioError> {
match self {
Self::Off => Ok(()),
Self::SaastamoinenNiell {
pressure_hpa,
temperature_k,
relative_humidity,
} => {
validate::finite_positive(*pressure_hpa, "error_budget.troposphere.pressure_hpa")
.map_err(map_field)?;
validate::finite_positive(*temperature_k, "error_budget.troposphere.temperature_k")
.map_err(map_field)?;
validate::finite(
*relative_humidity,
"error_budget.troposphere.relative_humidity",
)
.map_err(map_field)?;
if !(0.0..=1.0).contains(relative_humidity) {
return Err(invalid(
"error_budget.troposphere.relative_humidity",
"must be in [0, 1]",
));
}
Ok(())
}
}
}
fn corrections(self) -> Corrections {
Corrections {
ionosphere: false,
troposphere: matches!(self, Self::SaastamoinenNiell { .. }),
}
}
fn surface_met(self) -> SurfaceMet {
match self {
Self::Off => SurfaceMet::default(),
Self::SaastamoinenNiell {
pressure_hpa,
temperature_k,
relative_humidity,
} => SurfaceMet {
pressure_hpa,
temperature_k,
relative_humidity,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioThermalNoise {
pub enabled: bool,
pub pseudorange_sigma_m: f64,
pub carrier_phase_sigma_m: f64,
pub doppler_sigma_hz: f64,
}
impl ScenarioThermalNoise {
pub const fn disabled() -> Self {
Self {
enabled: false,
pseudorange_sigma_m: 0.0,
carrier_phase_sigma_m: 0.0,
doppler_sigma_hz: 0.0,
}
}
pub fn validate(&self) -> Result<(), ScenarioError> {
for (field, value) in [
(
"error_budget.thermal_noise.pseudorange_sigma_m",
self.pseudorange_sigma_m,
),
(
"error_budget.thermal_noise.carrier_phase_sigma_m",
self.carrier_phase_sigma_m,
),
(
"error_budget.thermal_noise.doppler_sigma_hz",
self.doppler_sigma_hz,
),
] {
validate::finite(value, field).map_err(map_field)?;
if value < 0.0 {
return Err(invalid(field, "must be non-negative"));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScenarioSpecularMultipath {
pub enabled: bool,
pub amplitude_m: f64,
pub reflector_height_m: f64,
pub phase_rad: f64,
}
impl ScenarioSpecularMultipath {
pub const fn disabled() -> Self {
Self {
enabled: false,
amplitude_m: 0.0,
reflector_height_m: 0.0,
phase_rad: 0.0,
}
}
pub fn validate(&self) -> Result<(), ScenarioError> {
for (field, value) in [
("error_budget.multipath.amplitude_m", self.amplitude_m),
(
"error_budget.multipath.reflector_height_m",
self.reflector_height_m,
),
("error_budget.multipath.phase_rad", self.phase_rad),
] {
validate::finite(value, field).map_err(map_field)?;
}
if self.amplitude_m < 0.0 || self.reflector_height_m < 0.0 {
return Err(invalid(
"error_budget.multipath",
"amplitude and reflector height must be non-negative",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SyntheticReceiverTruth {
pub t_rx_j2000_s: f64,
pub position_ecef_m: [f64; 3],
pub velocity_ecef_m_s: [f64; 3],
pub clock_m: f64,
pub clock_rate_m_s: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SyntheticObservableArrays {
pub epoch_offsets: Vec<usize>,
pub epoch_index: Vec<usize>,
pub satellite_id: Vec<GnssSatelliteId>,
pub code_observable: Vec<String>,
pub phase_observable: Vec<String>,
pub doppler_observable: Vec<String>,
pub carrier_hz: Vec<f64>,
pub pseudorange_m: Vec<f64>,
pub carrier_phase_cycles: Vec<f64>,
pub doppler_hz: Vec<f64>,
}
impl SyntheticObservableArrays {
fn new(epoch_count: usize) -> Self {
Self {
epoch_offsets: Vec::with_capacity(epoch_count + 1),
epoch_index: Vec::new(),
satellite_id: Vec::new(),
code_observable: Vec::new(),
phase_observable: Vec::new(),
doppler_observable: Vec::new(),
carrier_hz: Vec::new(),
pseudorange_m: Vec::new(),
carrier_phase_cycles: Vec::new(),
doppler_hz: Vec::new(),
}
}
fn len(&self) -> usize {
self.pseudorange_m.len()
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SyntheticTermArrays {
pub geometric_range_m: Vec<f64>,
pub satellite_clock_m: Vec<f64>,
pub receiver_clock_m: Vec<f64>,
pub satellite_clock_error_m: Vec<f64>,
pub ionosphere_m: Vec<f64>,
pub troposphere_m: Vec<f64>,
pub thermal_noise_m: Vec<f64>,
pub multipath_m: Vec<f64>,
pub quantization_m: Vec<f64>,
pub carrier_phase_geometric_cycles: Vec<f64>,
pub carrier_phase_receiver_clock_cycles: Vec<f64>,
pub carrier_phase_satellite_clock_cycles: Vec<f64>,
pub carrier_phase_satellite_clock_error_cycles: Vec<f64>,
pub carrier_phase_ionosphere_cycles: Vec<f64>,
pub carrier_phase_troposphere_cycles: Vec<f64>,
pub carrier_phase_thermal_noise_cycles: Vec<f64>,
pub carrier_phase_bias_cycles: Vec<f64>,
pub carrier_phase_quantization_cycles: Vec<f64>,
pub doppler_satellite_motion_hz: Vec<f64>,
pub doppler_receiver_motion_hz: Vec<f64>,
pub doppler_satellite_clock_hz: Vec<f64>,
pub doppler_receiver_clock_hz: Vec<f64>,
pub doppler_satellite_clock_error_hz: Vec<f64>,
pub doppler_thermal_noise_hz: Vec<f64>,
pub doppler_quantization_hz: Vec<f64>,
}
impl SyntheticTermArrays {
fn new() -> Self {
Self {
geometric_range_m: Vec::new(),
satellite_clock_m: Vec::new(),
receiver_clock_m: Vec::new(),
satellite_clock_error_m: Vec::new(),
ionosphere_m: Vec::new(),
troposphere_m: Vec::new(),
thermal_noise_m: Vec::new(),
multipath_m: Vec::new(),
quantization_m: Vec::new(),
carrier_phase_geometric_cycles: Vec::new(),
carrier_phase_receiver_clock_cycles: Vec::new(),
carrier_phase_satellite_clock_cycles: Vec::new(),
carrier_phase_satellite_clock_error_cycles: Vec::new(),
carrier_phase_ionosphere_cycles: Vec::new(),
carrier_phase_troposphere_cycles: Vec::new(),
carrier_phase_thermal_noise_cycles: Vec::new(),
carrier_phase_bias_cycles: Vec::new(),
carrier_phase_quantization_cycles: Vec::new(),
doppler_satellite_motion_hz: Vec::new(),
doppler_receiver_motion_hz: Vec::new(),
doppler_satellite_clock_hz: Vec::new(),
doppler_receiver_clock_hz: Vec::new(),
doppler_satellite_clock_error_hz: Vec::new(),
doppler_thermal_noise_hz: Vec::new(),
doppler_quantization_hz: Vec::new(),
}
}
fn push(&mut self, terms: ObservationTerms) {
self.geometric_range_m.push(terms.geometric_range_m);
self.satellite_clock_m.push(terms.satellite_clock_m);
self.receiver_clock_m.push(terms.receiver_clock_m);
self.satellite_clock_error_m
.push(terms.satellite_clock_error_m);
self.ionosphere_m.push(terms.ionosphere_m);
self.troposphere_m.push(terms.troposphere_m);
self.thermal_noise_m.push(terms.thermal_noise_m);
self.multipath_m.push(terms.multipath_m);
self.quantization_m.push(terms.quantization_m);
self.carrier_phase_geometric_cycles
.push(terms.carrier_phase_geometric_cycles);
self.carrier_phase_receiver_clock_cycles
.push(terms.carrier_phase_receiver_clock_cycles);
self.carrier_phase_satellite_clock_cycles
.push(terms.carrier_phase_satellite_clock_cycles);
self.carrier_phase_satellite_clock_error_cycles
.push(terms.carrier_phase_satellite_clock_error_cycles);
self.carrier_phase_ionosphere_cycles
.push(terms.carrier_phase_ionosphere_cycles);
self.carrier_phase_troposphere_cycles
.push(terms.carrier_phase_troposphere_cycles);
self.carrier_phase_thermal_noise_cycles
.push(terms.carrier_phase_thermal_noise_cycles);
self.carrier_phase_bias_cycles
.push(terms.carrier_phase_bias_cycles);
self.carrier_phase_quantization_cycles
.push(terms.carrier_phase_quantization_cycles);
self.doppler_satellite_motion_hz
.push(terms.doppler_satellite_motion_hz);
self.doppler_receiver_motion_hz
.push(terms.doppler_receiver_motion_hz);
self.doppler_satellite_clock_hz
.push(terms.doppler_satellite_clock_hz);
self.doppler_receiver_clock_hz
.push(terms.doppler_receiver_clock_hz);
self.doppler_satellite_clock_error_hz
.push(terms.doppler_satellite_clock_error_hz);
self.doppler_thermal_noise_hz
.push(terms.doppler_thermal_noise_hz);
self.doppler_quantization_hz
.push(terms.doppler_quantization_hz);
}
pub fn pseudorange_sum_m(&self, index: usize) -> Option<f64> {
let mut value = *self.geometric_range_m.get(index)?;
value += *self.receiver_clock_m.get(index)?;
value += *self.satellite_clock_m.get(index)?;
value += *self.satellite_clock_error_m.get(index)?;
value += *self.ionosphere_m.get(index)?;
value += *self.troposphere_m.get(index)?;
value += *self.thermal_noise_m.get(index)?;
value += *self.multipath_m.get(index)?;
value += *self.quantization_m.get(index)?;
Some(value)
}
pub fn carrier_phase_sum_cycles(&self, index: usize) -> Option<f64> {
let mut value = *self.carrier_phase_geometric_cycles.get(index)?;
value += *self.carrier_phase_receiver_clock_cycles.get(index)?;
value += *self.carrier_phase_satellite_clock_cycles.get(index)?;
value += *self.carrier_phase_satellite_clock_error_cycles.get(index)?;
value += *self.carrier_phase_ionosphere_cycles.get(index)?;
value += *self.carrier_phase_troposphere_cycles.get(index)?;
value += *self.carrier_phase_thermal_noise_cycles.get(index)?;
value += *self.carrier_phase_bias_cycles.get(index)?;
value += *self.carrier_phase_quantization_cycles.get(index)?;
Some(value)
}
pub fn doppler_sum_hz(&self, index: usize) -> Option<f64> {
let mut value = *self.doppler_satellite_motion_hz.get(index)?;
value += *self.doppler_receiver_motion_hz.get(index)?;
value += *self.doppler_satellite_clock_hz.get(index)?;
value += *self.doppler_receiver_clock_hz.get(index)?;
value += *self.doppler_satellite_clock_error_hz.get(index)?;
value += *self.doppler_thermal_noise_hz.get(index)?;
value += *self.doppler_quantization_hz.get(index)?;
Some(value)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SyntheticObservationSet {
pub schema_version: u32,
pub engine_version: String,
pub seed: u64,
pub receiver_truth: Vec<SyntheticReceiverTruth>,
pub observations: SyntheticObservableArrays,
pub truth_terms: SyntheticTermArrays,
}
impl SyntheticObservationSet {
pub fn observation_count(&self) -> usize {
self.observations.len()
}
pub fn determinism_fingerprint(&self) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
hash_u64(&mut hash, self.schema_version as u64);
hash_u64(&mut hash, self.seed);
for byte in self.engine_version.as_bytes() {
hash_u64(&mut hash, u64::from(*byte));
}
for state in &self.receiver_truth {
hash_f64(&mut hash, state.t_rx_j2000_s);
for value in state.position_ecef_m {
hash_f64(&mut hash, value);
}
for value in state.velocity_ecef_m_s {
hash_f64(&mut hash, value);
}
hash_f64(&mut hash, state.clock_m);
hash_f64(&mut hash, state.clock_rate_m_s);
}
for &epoch_offset in &self.observations.epoch_offsets {
hash_u64(&mut hash, epoch_offset as u64);
}
for index in 0..self.observation_count() {
hash_u64(&mut hash, self.observations.epoch_index[index] as u64);
hash_u64(
&mut hash,
satellite_hash(self.observations.satellite_id[index]),
);
hash_str(&mut hash, &self.observations.code_observable[index]);
hash_str(&mut hash, &self.observations.phase_observable[index]);
hash_str(&mut hash, &self.observations.doppler_observable[index]);
hash_f64(&mut hash, self.observations.carrier_hz[index]);
hash_f64(&mut hash, self.observations.pseudorange_m[index]);
hash_f64(&mut hash, self.observations.carrier_phase_cycles[index]);
hash_f64(&mut hash, self.observations.doppler_hz[index]);
hash_f64(&mut hash, self.truth_terms.geometric_range_m[index]);
hash_f64(&mut hash, self.truth_terms.satellite_clock_m[index]);
hash_f64(&mut hash, self.truth_terms.receiver_clock_m[index]);
hash_f64(&mut hash, self.truth_terms.satellite_clock_error_m[index]);
hash_f64(&mut hash, self.truth_terms.ionosphere_m[index]);
hash_f64(&mut hash, self.truth_terms.troposphere_m[index]);
hash_f64(&mut hash, self.truth_terms.thermal_noise_m[index]);
hash_f64(&mut hash, self.truth_terms.multipath_m[index]);
hash_f64(&mut hash, self.truth_terms.quantization_m[index]);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_geometric_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_receiver_clock_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_satellite_clock_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_satellite_clock_error_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_ionosphere_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_troposphere_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_thermal_noise_cycles[index],
);
hash_f64(&mut hash, self.truth_terms.carrier_phase_bias_cycles[index]);
hash_f64(
&mut hash,
self.truth_terms.carrier_phase_quantization_cycles[index],
);
hash_f64(
&mut hash,
self.truth_terms.doppler_satellite_motion_hz[index],
);
hash_f64(
&mut hash,
self.truth_terms.doppler_receiver_motion_hz[index],
);
hash_f64(
&mut hash,
self.truth_terms.doppler_satellite_clock_hz[index],
);
hash_f64(&mut hash, self.truth_terms.doppler_receiver_clock_hz[index]);
hash_f64(
&mut hash,
self.truth_terms.doppler_satellite_clock_error_hz[index],
);
hash_f64(&mut hash, self.truth_terms.doppler_thermal_noise_hz[index]);
hash_f64(&mut hash, self.truth_terms.doppler_quantization_hz[index]);
}
hash
}
pub fn to_rinex_observation_file(&self) -> RinexObs {
let obs_codes = self.rinex_obs_codes();
let mut epochs = Vec::with_capacity(self.receiver_truth.len());
for epoch_index in 0..self.receiver_truth.len() {
let start = self.observations.epoch_offsets[epoch_index];
let end = self.observations.epoch_offsets[epoch_index + 1];
let mut sats = BTreeMap::new();
for obs_index in start..end {
let sat = self.observations.satellite_id[obs_index];
let codes = obs_codes.get(&sat.system).expect("system code list exists");
let values = sats.entry(sat).or_insert_with(|| {
vec![
ObsValue {
value: None,
lli: None,
ssi: None,
};
codes.len()
]
});
set_obs_value(
values,
codes,
&self.observations.code_observable[obs_index],
round_rinex(self.observations.pseudorange_m[obs_index]),
);
set_obs_value(
values,
codes,
&self.observations.phase_observable[obs_index],
round_rinex(self.observations.carrier_phase_cycles[obs_index]),
);
set_obs_value(
values,
codes,
&self.observations.doppler_observable[obs_index],
round_rinex(self.observations.doppler_hz[obs_index]),
);
}
epochs.push(ObsEpoch {
epoch: obs_epoch_time(self.receiver_truth[epoch_index].t_rx_j2000_s),
flag: 0,
rcv_clock_offset_s: None,
epoch_picoseconds: None,
declared_record_count: sats.len(),
special_record_count: 0,
sats,
});
}
let first = self
.receiver_truth
.first()
.map(|state| (obs_epoch_time(state.t_rx_j2000_s), TimeScale::Gpst));
let last = self
.receiver_truth
.last()
.map(|state| (obs_epoch_time(state.t_rx_j2000_s), TimeScale::Gpst));
let approx_position_m = self
.receiver_truth
.first()
.map(|state| state.position_ecef_m);
RinexObs {
header: ObsHeader {
version: 3.05,
approx_position_m,
antenna_delta_hen_m: None,
obs_codes,
program_run_by_date: Some(PgmRunByDate {
program: "SIDEREON-SCENARIO".to_string(),
run_by: "sidereon-core".to_string(),
date: "SCENARIO-V1".to_string(),
}),
comments: vec![
"Synthetic observations from sidereon-core scenario engine".to_string()
],
marker_number: None,
marker_type: Some("SIMULATED".to_string()),
observer: None,
agency: None,
receiver: None,
antenna: None,
interval_s: self
.receiver_truth
.windows(2)
.next()
.map(|pair| pair[1].t_rx_j2000_s - pair[0].t_rx_j2000_s),
time_of_first_obs: first,
time_of_last_obs: last,
n_satellites: Some(
self.observations
.satellite_id
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>()
.len(),
),
prn_obs_counts: BTreeMap::new(),
phase_shifts: Vec::new(),
scale_factors: Vec::new(),
glonass_slots: BTreeMap::new(),
glonass_cod_phs_bis: None,
signal_strength_unit: None,
leap_seconds: None,
marker_name: Some("SYNTHETIC".to_string()),
unretained_header_labels: Vec::new(),
},
epochs,
skipped_records: 0,
}
}
pub fn to_rinex_string(&self) -> String {
self.to_rinex_observation_file().to_rinex_string()
}
pub fn spp_observations_for_epoch(&self, epoch_index: usize) -> Vec<crate::spp::Observation> {
let Some((&start, &end)) = self
.observations
.epoch_offsets
.get(epoch_index)
.zip(self.observations.epoch_offsets.get(epoch_index + 1))
else {
return Vec::new();
};
let mut by_sat = BTreeMap::new();
for index in start..end {
by_sat
.entry(self.observations.satellite_id[index])
.or_insert(self.observations.pseudorange_m[index]);
}
by_sat
.into_iter()
.map(|(satellite_id, pseudorange_m)| crate::spp::Observation {
satellite_id,
pseudorange_m,
})
.collect()
}
fn rinex_obs_codes(&self) -> BTreeMap<GnssSystem, Vec<String>> {
let mut codes: BTreeMap<GnssSystem, Vec<String>> = BTreeMap::new();
for index in 0..self.observation_count() {
let system = self.observations.satellite_id[index].system;
let list = codes.entry(system).or_default();
push_unique_code(list, &self.observations.code_observable[index]);
push_unique_code(list, &self.observations.phase_observable[index]);
push_unique_code(list, &self.observations.doppler_observable[index]);
}
codes
}
}
#[derive(Debug)]
pub struct DeclaredScenarioSource<'a, E: ?Sized> {
source: &'a E,
identity: ScenarioExternalProduct,
}
impl<'a, E: ?Sized> DeclaredScenarioSource<'a, E> {
pub fn new(source: &'a E, identity: ScenarioExternalProduct) -> Self {
Self { source, identity }
}
pub fn identity(&self) -> &ScenarioExternalProduct {
&self.identity
}
pub fn source(&self) -> &'a E {
self.source
}
}
impl<E: EphemerisSource + ?Sized> EphemerisSource for DeclaredScenarioSource<'_, E> {
fn position_clock_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
self.source.position_clock_at_j2000_s(sat, t_j2000_s)
}
}
impl<E: ObservableEphemerisSource + ?Sized> ObservableEphemerisSource
for DeclaredScenarioSource<'_, E>
{
fn observable_state_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Result<ObservableState, ObservablesError> {
self.source.observable_state_at_j2000_s(sat, t_j2000_s)
}
}
#[derive(Debug)]
struct SourceTranscript<'a, E> {
source: &'a E,
hash: Cell<u64>,
}
impl<'a, E> SourceTranscript<'a, E> {
fn new(source: &'a E) -> Self {
Self {
source,
hash: Cell::new(0xcbf2_9ce4_8422_2325),
}
}
fn digest(&self) -> String {
fingerprint_label(self.hash.get())
}
fn hash_query(&self, tag: u64, sat: GnssSatelliteId, t_j2000_s: f64) -> u64 {
let mut hash = self.hash.get();
hash_u64(&mut hash, tag);
hash_u64(&mut hash, satellite_hash(sat));
hash_f64(&mut hash, t_j2000_s);
hash
}
fn store_hash(&self, hash: u64) {
self.hash.set(hash);
}
}
impl<E: EphemerisSource> EphemerisSource for SourceTranscript<'_, E> {
fn position_clock_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
let result = self.source.position_clock_at_j2000_s(sat, t_j2000_s);
let mut hash = self.hash_query(0x4550_4845_4d45_5249, sat, t_j2000_s);
match result {
Some((position, clock)) => {
hash_u64(&mut hash, 1);
for value in position {
hash_f64(&mut hash, value);
}
hash_f64(&mut hash, clock);
}
None => hash_u64(&mut hash, 0),
}
self.store_hash(hash);
result
}
}
impl<E: ObservableEphemerisSource> ObservableEphemerisSource for SourceTranscript<'_, E> {
fn observable_state_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Result<ObservableState, ObservablesError> {
let result = self.source.observable_state_at_j2000_s(sat, t_j2000_s);
let mut hash = self.hash_query(0x4f42_5345_5256_4552, sat, t_j2000_s);
match &result {
Ok(state) => {
hash_u64(&mut hash, 1);
for value in state.position_ecef_m {
hash_f64(&mut hash, value);
}
match state.clock_s {
Some(clock) => {
hash_u64(&mut hash, 1);
hash_f64(&mut hash, clock);
}
None => hash_u64(&mut hash, 0),
}
}
Err(_) => hash_u64(&mut hash, 0),
}
self.store_hash(hash);
result
}
}
#[derive(Debug, Clone, Copy)]
pub struct DeclaredIonexSource<'a> {
product: &'a Ionex,
identity: &'a ScenarioExternalProduct,
}
impl<'a> DeclaredIonexSource<'a> {
pub const fn new(product: &'a Ionex, identity: &'a ScenarioExternalProduct) -> Self {
Self { product, identity }
}
pub const fn identity(&self) -> &'a ScenarioExternalProduct {
self.identity
}
pub const fn product(&self) -> &'a Ionex {
self.product
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ScenarioMediaSources<'a> {
pub ionex: Option<DeclaredIonexSource<'a>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SyntheticKeplerSource {
satellites: Vec<SyntheticKeplerOrbit>,
}
impl SyntheticKeplerSource {
pub fn new(mut satellites: Vec<SyntheticKeplerOrbit>) -> Result<Self, ScenarioError> {
if satellites.is_empty() {
return Err(invalid("satellites", "must not be empty"));
}
satellites.sort_by_key(|orbit| orbit.satellite_id);
for satellite in &satellites {
satellite.validate()?;
}
Ok(Self { satellites })
}
pub fn satellites(&self) -> &[SyntheticKeplerOrbit] {
&self.satellites
}
pub fn state_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<ObservableState> {
let orbit = self
.satellites
.iter()
.find(|orbit| orbit.satellite_id == sat)?;
Some(kepler_state(*orbit, t_j2000_s))
}
}
impl EphemerisSource for SyntheticKeplerSource {
fn position_clock_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
let state = self.state_at_j2000_s(sat, t_j2000_s)?;
Some((state.position_ecef_m, state.clock_s.unwrap_or(0.0)))
}
}
impl ObservableEphemerisSource for SyntheticKeplerSource {
fn observable_state_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Result<ObservableState, ObservablesError> {
self.state_at_j2000_s(sat, t_j2000_s)
.ok_or(ObservablesError::NoEphemeris)
}
}
pub fn simulate_scenario(scenario: &Scenario) -> Result<SyntheticObservationSet, ScenarioError> {
simulate_scenario_with_media(scenario, &ScenarioMediaSources::default())
}
pub fn simulate_scenario_with_media(
scenario: &Scenario,
media: &ScenarioMediaSources<'_>,
) -> Result<SyntheticObservationSet, ScenarioError> {
scenario.validate()?;
let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation else {
return Err(ScenarioError::ExternalSourceRequired);
};
let source = SyntheticKeplerSource::new(satellites.clone())?;
simulate_resolved_scenario(scenario, &source, media)
}
pub fn simulate_scenario_with_source<E>(
scenario: &Scenario,
source: &DeclaredScenarioSource<'_, E>,
) -> Result<SyntheticObservationSet, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
simulate_scenario_with_source_and_media(scenario, source, &ScenarioMediaSources::default())
}
pub fn simulate_scenario_with_source_and_media<E>(
scenario: &Scenario,
source: &DeclaredScenarioSource<'_, E>,
media: &ScenarioMediaSources<'_>,
) -> Result<SyntheticObservationSet, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
scenario.validate()?;
let ScenarioConstellation::ExternalProducts {
source: expected, ..
} = &scenario.constellation
else {
return Err(invalid(
"constellation.kind",
"external source supplied for synthetic constellation",
));
};
validate_identity("constellation.source", expected, source.identity())?;
let transcript = SourceTranscript::new(source.source());
let set = simulate_resolved_scenario(scenario, &transcript, media)?;
let actual = transcript.digest();
if actual != expected.content_digest {
return Err(ScenarioError::ExternalSourceMismatch {
field: "constellation.source.content_digest",
expected: expected.content_digest.clone(),
actual,
});
}
Ok(set)
}
pub fn scenario_source_transcript_fingerprint<E>(
scenario: &Scenario,
source: &DeclaredScenarioSource<'_, E>,
media: &ScenarioMediaSources<'_>,
) -> Result<String, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
scenario.validate()?;
let ScenarioConstellation::ExternalProducts {
source: expected, ..
} = &scenario.constellation
else {
return Err(invalid(
"constellation.kind",
"external source fingerprint requires external products",
));
};
if expected.kind != source.identity().kind
|| expected.product_id != source.identity().product_id
{
return Err(ScenarioError::ExternalSourceMismatch {
field: "constellation.source",
expected: expected.label(),
actual: source.identity().label(),
});
}
let transcript = SourceTranscript::new(source.source());
let _ = simulate_resolved_scenario(scenario, &transcript, media)?;
Ok(transcript.digest())
}
pub fn ionex_content_fingerprint(ionex: &Ionex) -> String {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
hash_str(&mut hash, &ionex.to_ionex_string());
fingerprint_label(hash)
}
fn simulate_resolved_scenario<E>(
scenario: &Scenario,
source: &E,
media: &ScenarioMediaSources<'_>,
) -> Result<SyntheticObservationSet, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
let satellites = scenario.satellites();
let signal_by_system = signal_map(&scenario.signals);
let epochs = scenario.epochs.epochs_j2000_s();
let mut receiver_clock = ClockSynth::new(
scenario.error_budget.receiver_clock,
mix_seed(scenario.seed, NOISE_STREAM_RECEIVER_CLOCK),
);
let mut satellite_clocks: BTreeMap<GnssSatelliteId, ClockSynth> = satellites
.iter()
.copied()
.map(|sat| {
(
sat,
ClockSynth::new(
scenario.error_budget.satellite_clock,
mix_seed(scenario.seed ^ satellite_hash(sat), NOISE_STREAM_SAT_CLOCK),
),
)
})
.collect();
let mut noise = SplitMix64::new(mix_seed(scenario.seed, NOISE_STREAM_OBSERVABLE));
let mut receiver_truth = Vec::with_capacity(epochs.len());
let mut observations = SyntheticObservableArrays::new(epochs.len());
let mut truth_terms = SyntheticTermArrays::new();
for (epoch_index, &t_rx_j2000_s) in epochs.iter().enumerate() {
let receiver = receiver_state(scenario, t_rx_j2000_s, &mut receiver_clock)?;
receiver_truth.push(receiver);
observations.epoch_offsets.push(observations.len());
let epoch_context = EpochContext::new(scenario, t_rx_j2000_s);
for &sat in &satellites {
let Some(signals) = signal_by_system.get(&sat.system) else {
continue;
};
let Some(sat_clock) = satellite_clocks.get_mut(&sat) else {
continue;
};
let sat_clock_error = sat_clock.sample(t_rx_j2000_s - scenario.epochs.start_j2000_s);
for signal in signals {
let thermal_code_m =
thermal_noise_m(scenario.error_budget.thermal_noise, &mut noise);
let thermal_phase_m =
thermal_phase_noise_m(scenario.error_budget.thermal_noise, &mut noise);
let thermal_doppler_hz =
thermal_doppler_noise_hz(scenario.error_budget.thermal_noise, &mut noise);
let Some((pseudorange_m, mut terms)) = synthesize_pseudorange(
source,
PseudorangeRequest {
sat,
receiver,
signal,
scenario,
epoch_context: &epoch_context,
media,
sat_clock_error_s: sat_clock_error.offset_s,
thermal_noise_m: thermal_code_m,
},
)?
else {
continue;
};
let phase_cycles = synthesize_phase_terms(&mut terms, signal, thermal_phase_m);
let doppler_hz = synthesize_doppler_terms(
source,
sat,
receiver,
signal.carrier_hz,
sat_clock_error.rate_s_s,
thermal_doppler_hz,
&mut terms,
)?;
observations.epoch_index.push(epoch_index);
observations.satellite_id.push(sat);
observations
.code_observable
.push(signal.code_observable.clone());
observations
.phase_observable
.push(signal.phase_observable.clone());
observations
.doppler_observable
.push(signal.doppler_observable.clone());
observations.carrier_hz.push(signal.carrier_hz);
observations.pseudorange_m.push(pseudorange_m);
observations.carrier_phase_cycles.push(phase_cycles);
observations.doppler_hz.push(doppler_hz);
truth_terms.push(terms);
}
}
}
observations.epoch_offsets.push(observations.len());
Ok(SyntheticObservationSet {
schema_version: scenario.schema_version,
engine_version: SCENARIO_ENGINE_VERSION.to_string(),
seed: scenario.seed,
receiver_truth,
observations,
truth_terms,
})
}
#[derive(Debug, Clone, Copy)]
struct EpochContext {
t_rx_second_of_day_s: f64,
day_of_year: f64,
corrections: Corrections,
klobuchar: KlobucharCoeffs,
met: SurfaceMet,
}
impl EpochContext {
fn new(scenario: &Scenario, t_rx_j2000_s: f64) -> Self {
let time = obs_epoch_time(t_rx_j2000_s);
let corrections = Corrections {
ionosphere: scenario.error_budget.ionosphere.corrections().ionosphere,
troposphere: scenario.error_budget.troposphere.corrections().troposphere,
};
Self {
t_rx_second_of_day_s: second_of_day(
i32::from(time.hour),
i32::from(time.minute),
time.second,
),
day_of_year: day_of_year(
time.year,
i32::from(time.month),
i32::from(time.day),
i32::from(time.hour),
i32::from(time.minute),
time.second,
),
corrections,
klobuchar: scenario.error_budget.ionosphere.coefficients(),
met: scenario.error_budget.troposphere.surface_met(),
}
}
}
#[derive(Debug, Clone, Copy)]
struct ObservationTerms {
geometric_range_m: f64,
satellite_clock_m: f64,
receiver_clock_m: f64,
satellite_clock_error_m: f64,
ionosphere_m: f64,
troposphere_m: f64,
thermal_noise_m: f64,
multipath_m: f64,
quantization_m: f64,
carrier_phase_geometric_cycles: f64,
carrier_phase_receiver_clock_cycles: f64,
carrier_phase_satellite_clock_cycles: f64,
carrier_phase_satellite_clock_error_cycles: f64,
carrier_phase_ionosphere_cycles: f64,
carrier_phase_troposphere_cycles: f64,
carrier_phase_thermal_noise_cycles: f64,
carrier_phase_bias_cycles: f64,
carrier_phase_quantization_cycles: f64,
doppler_satellite_motion_hz: f64,
doppler_receiver_motion_hz: f64,
doppler_satellite_clock_hz: f64,
doppler_receiver_clock_hz: f64,
doppler_satellite_clock_error_hz: f64,
doppler_thermal_noise_hz: f64,
doppler_quantization_hz: f64,
}
#[derive(Debug, Clone, Copy)]
struct PseudorangeRequest<'a, 'm> {
sat: GnssSatelliteId,
receiver: SyntheticReceiverTruth,
signal: &'a ScenarioSignal,
scenario: &'a Scenario,
epoch_context: &'a EpochContext,
media: &'a ScenarioMediaSources<'m>,
sat_clock_error_s: f64,
thermal_noise_m: f64,
}
fn synthesize_pseudorange<E>(
source: &E,
request: PseudorangeRequest<'_, '_>,
) -> Result<Option<(f64, ObservationTerms)>, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
let PseudorangeRequest {
sat,
receiver,
signal,
scenario,
epoch_context,
media,
sat_clock_error_s,
thermal_noise_m,
} = request;
let mask_rad = scenario.error_budget.elevation_mask_deg.to_radians();
let mut p_meas_m = rough_range(source, sat, receiver.position_ecef_m, receiver.t_rx_j2000_s)?;
let mut out = None;
for _ in 0..FIXED_POINT_ITERS {
let model = spp_model(source, sat, receiver, p_meas_m, scenario, epoch_context)?;
if model.el_rad < mask_rad {
return Ok(None);
}
let ionosphere_m =
ionosphere_delay_m(source, sat, receiver, signal, scenario, media, model.iono_m)?;
let multipath_m = multipath_m(
scenario.error_budget.multipath,
model.el_rad,
signal.carrier_hz,
);
let satellite_clock_error_m = -C_M_S * sat_clock_error_s;
let mut unrounded = model.rho_m;
unrounded += receiver.clock_m;
unrounded += -C_M_S * model.dt_sat_s;
unrounded += satellite_clock_error_m;
unrounded += ionosphere_m;
unrounded += model.tropo_m;
unrounded += thermal_noise_m;
unrounded += multipath_m;
let terms = ObservationTerms {
geometric_range_m: model.rho_m,
satellite_clock_m: -C_M_S * model.dt_sat_s,
receiver_clock_m: receiver.clock_m,
satellite_clock_error_m,
ionosphere_m,
troposphere_m: model.tropo_m,
thermal_noise_m,
multipath_m,
quantization_m: 0.0,
carrier_phase_geometric_cycles: 0.0,
carrier_phase_receiver_clock_cycles: 0.0,
carrier_phase_satellite_clock_cycles: 0.0,
carrier_phase_satellite_clock_error_cycles: 0.0,
carrier_phase_ionosphere_cycles: 0.0,
carrier_phase_troposphere_cycles: 0.0,
carrier_phase_thermal_noise_cycles: 0.0,
carrier_phase_bias_cycles: 0.0,
carrier_phase_quantization_cycles: 0.0,
doppler_satellite_motion_hz: 0.0,
doppler_receiver_motion_hz: 0.0,
doppler_satellite_clock_hz: 0.0,
doppler_receiver_clock_hz: 0.0,
doppler_satellite_clock_error_hz: 0.0,
doppler_thermal_noise_hz: 0.0,
doppler_quantization_hz: 0.0,
};
p_meas_m = unrounded;
out = Some((unrounded, terms, model.el_rad));
}
Ok(out.map(|(pseudorange_m, terms, _)| (pseudorange_m, terms)))
}
fn spp_model<E>(
source: &E,
sat: GnssSatelliteId,
receiver: SyntheticReceiverTruth,
p_meas_m: f64,
scenario: &Scenario,
epoch_context: &EpochContext,
) -> Result<crate::spp::SatModel, ScenarioError>
where
E: EphemerisSource,
{
let glonass_channels = BTreeMap::new();
let env = SatModelEnv {
eph: source,
t_rx_j2000_s: receiver.t_rx_j2000_s,
t_rx_second_of_day_s: epoch_context.t_rx_second_of_day_s,
day_of_year: epoch_context.day_of_year,
corrections: epoch_context.corrections,
met: &epoch_context.met,
glonass_channels: &glonass_channels,
model: SppModelRecipe::reference(),
};
let ionosphere = match &scenario.error_budget.ionosphere {
ScenarioIonosphereModel::Off => SppIonosphere::Klobuchar(KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
}),
ScenarioIonosphereModel::Klobuchar { .. } => {
SppIonosphere::Klobuchar(epoch_context.klobuchar)
}
ScenarioIonosphereModel::SuppliedIonex { .. } => {
SppIonosphere::Klobuchar(KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
})
}
};
sat_model(
&env,
sat,
receiver.position_ecef_m,
receiver.clock_m,
p_meas_m,
ionosphere,
)
.ok_or(ScenarioError::NoEphemeris { satellite: sat })
}
fn rough_range<E>(
source: &E,
sat: GnssSatelliteId,
receiver_ecef_m: [f64; 3],
t_rx_j2000_s: f64,
) -> Result<f64, ScenarioError>
where
E: EphemerisSource,
{
let (sat_pos, sat_clock_s) = source
.position_clock_at_j2000_s(sat, t_rx_j2000_s)
.ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
Ok(norm3(sub3(sat_pos, receiver_ecef_m)) - C_M_S * sat_clock_s)
}
fn source_clock_rate_s_s<E>(
source: &E,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Result<f64, ScenarioError>
where
E: EphemerisSource,
{
let (_, plus) = source
.position_clock_at_j2000_s(sat, t_j2000_s + 0.5)
.ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
let (_, minus) = source
.position_clock_at_j2000_s(sat, t_j2000_s - 0.5)
.ok_or(ScenarioError::NoEphemeris { satellite: sat })?;
Ok((plus - minus) / 1.0)
}
fn ionosphere_delay_m<E>(
source: &E,
sat: GnssSatelliteId,
receiver: SyntheticReceiverTruth,
signal: &ScenarioSignal,
scenario: &Scenario,
media: &ScenarioMediaSources<'_>,
klobuchar_iono_m: f64,
) -> Result<f64, ScenarioError>
where
E: ObservableEphemerisSource,
{
let ScenarioIonosphereModel::SuppliedIonex { source: expected } =
&scenario.error_budget.ionosphere
else {
return Ok(klobuchar_iono_m);
};
let Some(ionex) = media.ionex else {
return Err(ScenarioError::ExternalIonosphereRequired);
};
validate_identity("error_budget.ionosphere.source", expected, ionex.identity())?;
let actual = ionex_content_fingerprint(ionex.product());
if actual != expected.content_digest {
return Err(ScenarioError::ExternalSourceMismatch {
field: "error_budget.ionosphere.source.content_digest",
expected: expected.content_digest.clone(),
actual,
});
}
let prediction = predict(
source,
sat,
receiver.position_ecef_m,
receiver.t_rx_j2000_s,
crate::observables::PredictOptions {
carrier_hz: signal.carrier_hz,
..crate::observables::PredictOptions::default()
},
)
.map_err(ScenarioError::Observable)?;
let receiver_geodetic = receiver_geodetic(receiver.position_ecef_m)?;
let epoch_j2000_s = rounded_j2000_seconds(receiver.t_rx_j2000_s)?;
ionex_slant_delay(
ionex.product(),
receiver_geodetic,
prediction.elevation_deg.to_radians(),
prediction.azimuth_deg.to_radians(),
epoch_j2000_s,
signal.carrier_hz,
)
.map_err(|error| ScenarioError::Ionosphere(error.to_string()))
}
fn synthesize_phase_terms(
terms: &mut ObservationTerms,
signal: &ScenarioSignal,
thermal_phase_m: f64,
) -> f64 {
let lambda = wavelength_m(signal.carrier_hz);
let mut phase_cycles = terms.geometric_range_m / lambda;
terms.carrier_phase_geometric_cycles = phase_cycles;
let value = terms.receiver_clock_m / lambda;
terms.carrier_phase_receiver_clock_cycles = value;
phase_cycles += value;
let value = terms.satellite_clock_m / lambda;
terms.carrier_phase_satellite_clock_cycles = value;
phase_cycles += value;
let value = terms.satellite_clock_error_m / lambda;
terms.carrier_phase_satellite_clock_error_cycles = value;
phase_cycles += value;
let value = -terms.ionosphere_m / lambda;
terms.carrier_phase_ionosphere_cycles = value;
phase_cycles += value;
let value = terms.troposphere_m / lambda;
terms.carrier_phase_troposphere_cycles = value;
phase_cycles += value;
let value = thermal_phase_m / lambda;
terms.carrier_phase_thermal_noise_cycles = value;
phase_cycles += value;
terms.carrier_phase_bias_cycles = signal.carrier_phase_bias_cycles;
phase_cycles += signal.carrier_phase_bias_cycles;
terms.carrier_phase_quantization_cycles = 0.0;
phase_cycles
}
fn synthesize_doppler_terms<E>(
source: &E,
sat: GnssSatelliteId,
receiver: SyntheticReceiverTruth,
carrier_hz: f64,
sat_clock_error_rate_s_s: f64,
thermal_doppler_hz: f64,
terms: &mut ObservationTerms,
) -> Result<f64, ScenarioError>
where
E: EphemerisSource + ObservableEphemerisSource,
{
let prediction = predict(
source,
sat,
receiver.position_ecef_m,
receiver.t_rx_j2000_s,
crate::observables::PredictOptions {
carrier_hz,
..crate::observables::PredictOptions::default()
},
)
.map_err(ScenarioError::Observable)?;
let receiver_range_rate = dot3(prediction.los_unit, receiver.velocity_ecef_m_s);
let mut doppler_hz = prediction.doppler_hz;
terms.doppler_satellite_motion_hz = doppler_hz;
let value = receiver_range_rate * carrier_hz / C_M_S;
terms.doppler_receiver_motion_hz = value;
doppler_hz += value;
let value = source_clock_rate_s_s(source, sat, receiver.t_rx_j2000_s)? * carrier_hz;
terms.doppler_satellite_clock_hz = value;
doppler_hz += value;
let value = -receiver.clock_rate_m_s * carrier_hz / C_M_S;
terms.doppler_receiver_clock_hz = value;
doppler_hz += value;
let value = sat_clock_error_rate_s_s * carrier_hz;
terms.doppler_satellite_clock_error_hz = value;
doppler_hz += value;
terms.doppler_thermal_noise_hz = thermal_doppler_hz;
doppler_hz += thermal_doppler_hz;
terms.doppler_quantization_hz = 0.0;
Ok(doppler_hz)
}
fn receiver_state(
scenario: &Scenario,
t_rx_j2000_s: f64,
clock: &mut ClockSynth,
) -> Result<SyntheticReceiverTruth, ScenarioError> {
let offset_s = t_rx_j2000_s - scenario.epochs.start_j2000_s;
let (position_ecef_m, velocity_ecef_m_s) = match &scenario.receiver {
ScenarioReceiver::StaticGeodetic { position } => {
let ecef = geodetic_to_itrf(position.to_wgs84()?)
.map_err(|error| ScenarioError::Frame(error.to_string()))?
.as_array();
(ecef, [0.0; 3])
}
ScenarioReceiver::KinematicWaypoints { waypoints } => {
interpolate_receiver_waypoints(waypoints, offset_s)?
}
};
let clock_sample = clock.sample(offset_s);
Ok(SyntheticReceiverTruth {
t_rx_j2000_s,
position_ecef_m,
velocity_ecef_m_s,
clock_m: C_M_S * clock_sample.offset_s,
clock_rate_m_s: C_M_S * clock_sample.rate_s_s,
})
}
fn interpolate_receiver_waypoints(
waypoints: &[ScenarioReceiverWaypoint],
offset_s: f64,
) -> Result<([f64; 3], [f64; 3]), ScenarioError> {
let mut segment = waypoints
.windows(2)
.last()
.expect("validated waypoint count");
for pair in waypoints.windows(2) {
if offset_s >= pair[0].offset_s && offset_s <= pair[1].offset_s {
segment = pair;
break;
}
}
let start = segment[0];
let end = segment[1];
let p0 = geodetic_to_itrf(start.position.to_wgs84()?)
.map_err(|error| ScenarioError::Frame(error.to_string()))?
.as_array();
let p1 = geodetic_to_itrf(end.position.to_wgs84()?)
.map_err(|error| ScenarioError::Frame(error.to_string()))?
.as_array();
let dt = end.offset_s - start.offset_s;
let u = ((offset_s - start.offset_s) / dt).clamp(0.0, 1.0);
let position = [
p0[0] + (p1[0] - p0[0]) * u,
p0[1] + (p1[1] - p0[1]) * u,
p0[2] + (p1[2] - p0[2]) * u,
];
let velocity = start.velocity_ecef_m_s.unwrap_or([
(p1[0] - p0[0]) / dt,
(p1[1] - p0[1]) / dt,
(p1[2] - p0[2]) / dt,
]);
Ok((position, velocity))
}
fn kepler_state(orbit: SyntheticKeplerOrbit, t_j2000_s: f64) -> ObservableState {
let dt = t_j2000_s - orbit.epoch_j2000_s;
let n_rad_s = (GM_EARTH_M3_S2
/ (orbit.semi_major_axis_m * orbit.semi_major_axis_m * orbit.semi_major_axis_m))
.sqrt();
let mean_anomaly = orbit.mean_anomaly_rad + n_rad_s * dt;
let eccentric_anomaly = solve_kepler(mean_anomaly, orbit.eccentricity);
let cos_e = libm::cos(eccentric_anomaly);
let sin_e = libm::sin(eccentric_anomaly);
let a = orbit.semi_major_axis_m;
let e = orbit.eccentricity;
let one_minus_e2 = 1.0 - e * e;
let x_orb = a * (cos_e - e);
let y_orb = a * one_minus_e2.sqrt() * sin_e;
let eci = perifocal_to_inertial(
[x_orb, y_orb, 0.0],
orbit.raan_rad,
orbit.inclination_rad,
orbit.arg_perigee_rad,
);
let theta = OMEGA_E_DOT_RAD_S * dt;
let cos_t = libm::cos(theta);
let sin_t = libm::sin(theta);
let ecef = [
cos_t * eci[0] + sin_t * eci[1],
-sin_t * eci[0] + cos_t * eci[1],
eci[2],
];
ObservableState {
position_ecef_m: ecef,
clock_s: Some(orbit.clock_bias_s + orbit.clock_drift_s_s * dt),
}
}
fn solve_kepler(mean_anomaly_rad: f64, eccentricity: f64) -> f64 {
let mut e_anomaly = mean_anomaly_rad;
for _ in 0..16 {
let sin_e = libm::sin(e_anomaly);
let cos_e = libm::cos(e_anomaly);
let f = e_anomaly - eccentricity * sin_e - mean_anomaly_rad;
let fp = 1.0 - eccentricity * cos_e;
e_anomaly -= f / fp;
}
e_anomaly
}
fn perifocal_to_inertial(
position: [f64; 3],
raan_rad: f64,
inclination_rad: f64,
arg_perigee_rad: f64,
) -> [f64; 3] {
let cos_o = libm::cos(raan_rad);
let sin_o = libm::sin(raan_rad);
let cos_i = libm::cos(inclination_rad);
let sin_i = libm::sin(inclination_rad);
let cos_w = libm::cos(arg_perigee_rad);
let sin_w = libm::sin(arg_perigee_rad);
let r11 = cos_o * cos_w - sin_o * sin_w * cos_i;
let r12 = -cos_o * sin_w - sin_o * cos_w * cos_i;
let r21 = sin_o * cos_w + cos_o * sin_w * cos_i;
let r22 = -sin_o * sin_w + cos_o * cos_w * cos_i;
let r31 = sin_w * sin_i;
let r32 = cos_w * sin_i;
[
r11 * position[0] + r12 * position[1],
r21 * position[0] + r22 * position[1],
r31 * position[0] + r32 * position[1],
]
}
#[derive(Debug, Clone, Copy)]
struct ClockSample {
offset_s: f64,
rate_s_s: f64,
}
#[derive(Debug, Clone, Copy)]
struct ClockSynth {
model: ScenarioClockModel,
rng: SplitMix64,
last_offset_s: Option<f64>,
phase_noise_s: f64,
frequency_noise: f64,
flicker_frequency: f64,
}
impl ClockSynth {
fn new(model: ScenarioClockModel, seed: u64) -> Self {
let mut rng = SplitMix64::new(seed);
let flicker = if model.enabled {
let coeff = coefficient(model, PowerLawNoiseType::FlickerFM);
coeff.sqrt() * rng.standard_normal()
} else {
0.0
};
Self {
model,
rng,
last_offset_s: None,
phase_noise_s: 0.0,
frequency_noise: 0.0,
flicker_frequency: flicker,
}
}
fn sample(&mut self, offset_s: f64) -> ClockSample {
if !self.model.enabled {
return ClockSample {
offset_s: 0.0,
rate_s_s: 0.0,
};
}
let dt_s = self
.last_offset_s
.map_or(0.0, |previous| (offset_s - previous).max(0.0));
self.last_offset_s = Some(offset_s);
let mut rate_s_s = self.model.drift_s_s + self.flicker_frequency + self.frequency_noise;
if dt_s > 0.0 {
let rw_fm = coefficient(self.model, PowerLawNoiseType::RandomWalkFM);
if rw_fm > 0.0 {
self.frequency_noise += (rw_fm * dt_s).sqrt() * self.rng.standard_normal();
}
rate_s_s = self.model.drift_s_s + self.flicker_frequency + self.frequency_noise;
let white_fm = coefficient(self.model, PowerLawNoiseType::WhiteFM);
if white_fm > 0.0 {
let phase_step_s = (white_fm * dt_s).sqrt() * self.rng.standard_normal();
self.phase_noise_s += phase_step_s;
rate_s_s += phase_step_s / dt_s;
}
self.phase_noise_s += self.frequency_noise * dt_s;
}
let pm_dt = dt_s.max(1.0);
let flicker_pm = coefficient(self.model, PowerLawNoiseType::FlickerPM);
let white_pm = coefficient(self.model, PowerLawNoiseType::WhitePM);
let pm_noise = if flicker_pm == 0.0 && white_pm == 0.0 {
0.0
} else {
(flicker_pm / pm_dt).sqrt() * self.rng.standard_normal()
+ (white_pm / (pm_dt * pm_dt)).sqrt() * self.rng.standard_normal()
};
let offset_s = self.model.bias_s
+ self.model.drift_s_s * offset_s
+ self.flicker_frequency * offset_s
+ self.phase_noise_s
+ pm_noise;
ClockSample { offset_s, rate_s_s }
}
}
fn coefficient(model: ScenarioClockModel, noise_type: PowerLawNoiseType) -> f64 {
model.power_law_coefficients[noise_type.coefficient_index()]
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct SplitMix64 {
state: u64,
spare_normal: Option<f64>,
}
impl SplitMix64 {
const fn new(seed: u64) -> Self {
Self {
state: seed,
spare_normal: None,
}
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn unit_f64(&mut self) -> f64 {
let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
f64::from_bits(bits) - 1.0
}
fn standard_normal(&mut self) -> f64 {
if let Some(value) = self.spare_normal.take() {
return value;
}
loop {
let u = 2.0 * self.unit_f64() - 1.0;
let v = 2.0 * self.unit_f64() - 1.0;
let s = u * u + v * v;
if s > 0.0 && s < 1.0 {
let scale = (-2.0 * libm::log(s) / s).sqrt();
self.spare_normal = Some(v * scale);
return u * scale;
}
}
}
}
fn thermal_noise_m(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
if model.enabled && model.pseudorange_sigma_m > 0.0 {
model.pseudorange_sigma_m * rng.standard_normal()
} else {
0.0
}
}
fn thermal_phase_noise_m(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
if model.enabled && model.carrier_phase_sigma_m > 0.0 {
model.carrier_phase_sigma_m * rng.standard_normal()
} else {
0.0
}
}
fn thermal_doppler_noise_hz(model: ScenarioThermalNoise, rng: &mut SplitMix64) -> f64 {
if model.enabled && model.doppler_sigma_hz > 0.0 {
model.doppler_sigma_hz * rng.standard_normal()
} else {
0.0
}
}
fn multipath_m(model: ScenarioSpecularMultipath, elevation_rad: f64, carrier_hz: f64) -> f64 {
if !model.enabled || model.amplitude_m == 0.0 {
return 0.0;
}
let lambda = wavelength_m(carrier_hz);
let phase = 4.0 * core::f64::consts::PI * model.reflector_height_m * libm::sin(elevation_rad)
/ lambda
+ model.phase_rad;
model.amplitude_m * libm::cos(phase)
}
fn wavelength_m(carrier_hz: f64) -> f64 {
C_M_S / carrier_hz
}
fn round_rinex(value: f64) -> f64 {
(value * RINEX_QUANTIZATION).round() / RINEX_QUANTIZATION
}
fn obs_epoch_time(t_j2000_s: f64) -> ObsEpochTime {
let whole = t_j2000_s.floor() as i64;
let frac = t_j2000_s - whole as f64;
let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole);
ObsEpochTime {
year: year as i32,
month: month as u8,
day: day as u8,
hour: hour as u8,
minute: minute as u8,
second: second as f64 + frac,
}
}
fn signal_map(signals: &[ScenarioSignal]) -> BTreeMap<GnssSystem, Vec<ScenarioSignal>> {
let mut out: BTreeMap<GnssSystem, Vec<ScenarioSignal>> = BTreeMap::new();
for signal in signals {
out.entry(signal.system).or_default().push(signal.clone());
}
out
}
fn validate_identity(
field: &'static str,
expected: &ScenarioExternalProduct,
actual: &ScenarioExternalProduct,
) -> Result<(), ScenarioError> {
if expected == actual {
Ok(())
} else {
Err(ScenarioError::ExternalSourceMismatch {
field,
expected: expected.label(),
actual: actual.label(),
})
}
}
fn receiver_geodetic(position_ecef_m: [f64; 3]) -> Result<Wgs84Geodetic, ScenarioError> {
let position = ItrfPositionM::new(position_ecef_m[0], position_ecef_m[1], position_ecef_m[2])
.map_err(|error| ScenarioError::Frame(error.to_string()))?;
itrf_to_geodetic(position).map_err(|error| ScenarioError::Frame(error.to_string()))
}
fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ScenarioError> {
validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_field)?;
let rounded = t_rx_j2000_s.round();
if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
return Err(invalid("t_rx_j2000_s", "must round to i64 seconds"));
}
Ok(rounded as i64)
}
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn set_obs_value(values: &mut [ObsValue], codes: &[String], code: &str, value: f64) {
if let Some(index) = codes.iter().position(|candidate| candidate == code) {
values[index] = ObsValue {
value: Some(value),
lli: None,
ssi: None,
};
}
}
fn push_unique_code(codes: &mut Vec<String>, code: &str) {
if !codes.iter().any(|candidate| candidate == code) {
codes.push(code.to_string());
}
}
fn validate_code(code: &str, field: &'static str, first: u8) -> Result<(), ScenarioError> {
if code.len() != 3 || code.as_bytes().first().copied() != Some(first) {
return Err(invalid(field, "must be a three-character RINEX observable"));
}
if !code.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
return Err(invalid(field, "must be ASCII alphanumeric"));
}
Ok(())
}
fn map_field(error: validate::FieldError) -> ScenarioError {
ScenarioError::InvalidInput {
field: error.field(),
reason: error.reason(),
}
}
fn invalid(field: &'static str, reason: &'static str) -> ScenarioError {
ScenarioError::InvalidInput { field, reason }
}
fn mix_seed(seed: u64, stream: u64) -> u64 {
let mut value = seed ^ stream;
value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
fn satellite_hash(sat: GnssSatelliteId) -> u64 {
((sat.system.letter() as u64) << 8) | u64::from(sat.prn)
}
fn hash_u64(hash: &mut u64, value: u64) {
*hash ^= value;
*hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
fn hash_str(hash: &mut u64, value: &str) {
hash_u64(hash, value.len() as u64);
for byte in value.as_bytes() {
hash_u64(hash, u64::from(*byte));
}
}
fn fingerprint_label(hash: u64) -> String {
format!("sidereon-fnv64:{hash:016x}")
}
fn hash_f64(hash: &mut u64, value: f64) {
hash_u64(hash, value.to_bits());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::time::civil::j2000_seconds;
use crate::atmosphere::TecGridSamples;
use crate::clock_stability::{allan_deviation_power_law_slope, overlapping_adev, AllanSeries};
use crate::positioning::{solve, SolveInputs};
use crate::rinex::observations::{observation_values, ObservationFilter, ObservationKind};
fn product(
kind: ScenarioExternalProductKind,
id: &str,
digest: &str,
) -> ScenarioExternalProduct {
ScenarioExternalProduct {
kind,
product_id: id.to_string(),
content_digest: digest.to_string(),
}
}
fn instant_from_j2000(seconds: i64) -> crate::astro::time::model::Instant {
let (jd_whole, fraction) =
crate::astro::time::civil::split_julian_date_from_j2000_seconds(seconds);
crate::astro::time::model::Instant::from_julian_date(
TimeScale::Gpst,
crate::astro::time::model::JulianDateSplit::new(jd_whole, fraction)
.expect("valid split Julian date"),
)
}
fn constant_ionex(epoch_j2000_s: i64, tecu: f64) -> Ionex {
let map = vec![
vec![tecu, tecu, tecu],
vec![tecu, tecu, tecu],
vec![tecu, tecu, tecu],
];
Ionex::from_samples(TecGridSamples {
map_epochs: vec![instant_from_j2000(epoch_j2000_s)],
lat_nodes_deg: vec![90.0, 0.0, -90.0],
lon_nodes_deg: vec![-180.0, 0.0, 180.0],
dlat_deg: -90.0,
dlon_deg: 180.0,
shell_height_km: 450.0,
base_radius_km: 6371.0,
exponent: 0,
tec_maps: vec![map],
rms_maps: Vec::new(),
})
.expect("valid IONEX samples")
}
fn base_scenario() -> Scenario {
let start_j2000_s = j2000_seconds(2026, 1, 1, 0, 0, 0.0);
Scenario {
schema_version: SCENARIO_SCHEMA_VERSION,
seed: DEFAULT_SCENARIO_SEED,
epochs: ScenarioEpochRange {
start_j2000_s,
count: 2,
cadence_s: 30.0,
},
receiver: ScenarioReceiver::StaticGeodetic {
position: ScenarioGeodeticPosition {
lat_rad: 0.0,
lon_rad: 0.0,
height_m: 0.0,
},
},
constellation: ScenarioConstellation::SyntheticKeplerian {
satellites: gps_anchor_orbits(start_j2000_s),
},
signals: vec![ScenarioSignal::l1_ca(GnssSystem::Gps)],
error_budget: ScenarioErrorBudget {
elevation_mask_deg: -5.0,
..ScenarioErrorBudget::default()
},
}
}
fn gps_anchor_orbits(epoch_j2000_s: f64) -> Vec<SyntheticKeplerOrbit> {
let a = 26_560_000.0;
let u60 = core::f64::consts::PI / 3.0;
[
(1, 0.0, 0.0, 0.0),
(2, 0.0, 0.0, u60),
(3, 0.0, 0.0, -u60),
(4, 0.0, core::f64::consts::FRAC_PI_2, u60),
(5, 0.0, core::f64::consts::FRAC_PI_2, -u60),
]
.into_iter()
.map(
|(prn, raan_rad, inclination_rad, mean_anomaly_rad)| SyntheticKeplerOrbit {
satellite_id: GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN"),
semi_major_axis_m: a,
eccentricity: 0.0,
inclination_rad,
raan_rad,
arg_perigee_rad: 0.0,
mean_anomaly_rad,
epoch_j2000_s,
clock_bias_s: 0.0,
clock_drift_s_s: 0.0,
},
)
.collect()
}
#[test]
fn schema_round_trips_through_json() {
let mut scenario = base_scenario();
scenario.error_budget.ionosphere = ScenarioIonosphereModel::SuppliedIonex {
source: product(
ScenarioExternalProductKind::Ionex,
"fixture.ionex",
"sha256:ionex-fixture",
),
};
let json = serde_json::to_string(&scenario).expect("serialize scenario");
let reparsed: Scenario = serde_json::from_str(&json).expect("parse scenario");
assert_eq!(reparsed, scenario);
reparsed.validate().expect("valid scenario");
}
#[test]
fn deterministic_runs_match_and_terms_sum_to_composite_bits() {
let mut scenario = base_scenario();
scenario.error_budget.receiver_clock = ScenarioClockModel {
enabled: true,
bias_s: 1.0e-7,
drift_s_s: 1.0e-10,
power_law_coefficients: [1.0e-24, 1.0e-26, 1.0e-22, 1.0e-26, 1.0e-28],
};
scenario.error_budget.thermal_noise = ScenarioThermalNoise {
enabled: true,
pseudorange_sigma_m: 0.25,
carrier_phase_sigma_m: 0.002,
doppler_sigma_hz: 0.02,
};
scenario.error_budget.multipath = ScenarioSpecularMultipath {
enabled: true,
amplitude_m: 0.15,
reflector_height_m: 1.25,
phase_rad: 0.3,
};
let first = simulate_scenario(&scenario).expect("simulate first");
let second = simulate_scenario(&scenario).expect("simulate second");
assert_eq!(
first.determinism_fingerprint(),
second.determinism_fingerprint()
);
assert_eq!(first, second);
for index in 0..first.observation_count() {
let sum = first
.truth_terms
.pseudorange_sum_m(index)
.expect("term index");
assert_eq!(
sum.to_bits(),
first.observations.pseudorange_m[index].to_bits(),
"term sum at observation {index}"
);
let sum = first
.truth_terms
.carrier_phase_sum_cycles(index)
.expect("phase term index");
assert_eq!(
sum.to_bits(),
first.observations.carrier_phase_cycles[index].to_bits(),
"phase term sum at observation {index}"
);
let sum = first
.truth_terms
.doppler_sum_hz(index)
.expect("doppler term index");
assert_eq!(
sum.to_bits(),
first.observations.doppler_hz[index].to_bits(),
"doppler term sum at observation {index}"
);
}
}
#[test]
fn scenario_clock_white_fm_matches_in_repo_power_law_oracle() {
let mut clock = ClockSynth::new(
ScenarioClockModel {
enabled: true,
bias_s: 0.0,
drift_s_s: 0.0,
power_law_coefficients: [0.0, 0.0, 1.0e-20, 0.0, 0.0],
},
mix_seed(DEFAULT_SCENARIO_SEED, 0x1139),
);
let mut frequency = Vec::with_capacity(4096);
for index in 0..4096 {
frequency.push(clock.sample(index as f64).rate_s_s);
}
let factors = [1, 2, 4, 8, 16, 32, 64, 128];
let adev = overlapping_adev(AllanSeries::FractionalFrequency(&frequency), 1.0, &factors)
.expect("overlapping ADEV");
let slope = (adev.deviation[5].ln() - adev.deviation[1].ln())
/ (adev.tau_s[5].ln() - adev.tau_s[1].ln());
assert!(
(slope - allan_deviation_power_law_slope(PowerLawNoiseType::WhiteFM)).abs() < 0.25,
"white-FM Allan deviation slope {slope:e}"
);
let expected_tau1 = (1.0e-20_f64 / (2.0 * adev.tau_s[0])).sqrt();
let ratio = adev.deviation[0] / expected_tau1;
assert!(
(0.5..1.5).contains(&ratio),
"white-FM tau-1 Allan deviation ratio {ratio:e}"
);
}
#[test]
fn rinex_export_reparses_to_same_observables() {
let set = simulate_scenario(&base_scenario()).expect("simulate");
let rinex = set.to_rinex_observation_file();
let text = rinex.to_rinex_string();
let reparsed = RinexObs::parse(&text).expect("parse generated RINEX");
assert_eq!(reparsed, rinex);
let sat = set.observations.satellite_id[0];
let codes = rinex.header.obs_codes.get(&sat.system).expect("codes");
let code_index = codes
.iter()
.position(|code| code == &set.observations.code_observable[0])
.expect("code index");
let exported = rinex.epochs[0].sats[&sat][code_index]
.value
.expect("exported value");
assert_eq!(
exported.to_bits(),
round_rinex(set.observations.pseudorange_m[0]).to_bits()
);
let rows = observation_values(&reparsed, &reparsed.epochs()[0], &ObservationFilter::all())
.expect("observation rows");
let pseudorange_count = rows
.iter()
.flat_map(|(_, rows)| rows)
.filter(|row| row.kind == ObservationKind::Pseudorange && row.value.is_some())
.count();
assert_eq!(
pseudorange_count,
set.observations.epoch_offsets[1] - set.observations.epoch_offsets[0]
);
}
#[test]
fn multiple_signals_per_constellation_survive_arrays_and_rinex() {
let mut scenario = base_scenario();
scenario.epochs.count = 1;
scenario.signals.push(ScenarioSignal {
system: GnssSystem::Gps,
code_observable: "C2W".to_string(),
phase_observable: "L2W".to_string(),
doppler_observable: "D2W".to_string(),
carrier_hz: 1_227_600_000.0,
carrier_phase_bias_cycles: 12.0,
});
let set = simulate_scenario(&scenario).expect("simulate");
assert_eq!(set.observation_count(), scenario.satellites().len() * 2);
let rinex = set.to_rinex_observation_file();
let gps_codes = rinex
.header
.obs_codes
.get(&GnssSystem::Gps)
.expect("GPS codes");
assert!(gps_codes.iter().any(|code| code == "C1C"));
assert!(gps_codes.iter().any(|code| code == "C2W"));
assert_eq!(rinex.epochs[0].sats.len(), scenario.satellites().len());
for values in rinex.epochs[0].sats.values() {
let filled_pseudorange = gps_codes
.iter()
.zip(values.iter())
.filter(|(code, value)| code.starts_with('C') && value.value.is_some())
.count();
assert_eq!(filled_pseudorange, 2);
}
}
#[test]
fn kinematic_receiver_velocity_contributes_to_doppler_terms() {
let mut moving = base_scenario();
moving.epochs.count = 1;
let position = ScenarioGeodeticPosition {
lat_rad: 0.0,
lon_rad: 0.0,
height_m: 0.0,
};
moving.receiver = ScenarioReceiver::KinematicWaypoints {
waypoints: vec![
ScenarioReceiverWaypoint {
offset_s: 0.0,
position,
velocity_ecef_m_s: Some([100.0, 0.0, 0.0]),
},
ScenarioReceiverWaypoint {
offset_s: 30.0,
position,
velocity_ecef_m_s: Some([100.0, 0.0, 0.0]),
},
],
};
let mut static_scenario = base_scenario();
static_scenario.epochs.count = 1;
let moving_set = simulate_scenario(&moving).expect("simulate moving");
let static_set = simulate_scenario(&static_scenario).expect("simulate static");
let delta_hz =
moving_set.observations.doppler_hz[0] - static_set.observations.doppler_hz[0];
assert_eq!(
delta_hz.to_bits(),
moving_set.truth_terms.doppler_receiver_motion_hz[0].to_bits()
);
assert!(delta_hz.abs() > 100.0);
}
#[test]
fn receiver_clock_drift_contributes_to_doppler_terms() {
let mut scenario = base_scenario();
scenario.epochs.count = 1;
scenario.error_budget.receiver_clock = ScenarioClockModel {
enabled: true,
bias_s: 0.0,
drift_s_s: 1.0e-10,
power_law_coefficients: [0.0; 5],
};
let set = simulate_scenario(&scenario).expect("simulate");
let expected = -1.0e-10 * F_L1_HZ;
assert_eq!(
set.truth_terms.doppler_receiver_clock_hz[0].to_bits(),
expected.to_bits()
);
assert_eq!(
set.truth_terms
.doppler_sum_hz(0)
.expect("doppler sum")
.to_bits(),
set.observations.doppler_hz[0].to_bits()
);
}
#[test]
fn external_source_identity_is_checked() {
let start_j2000_s = j2000_seconds(2026, 1, 1, 0, 0, 0.0);
let satellites = gps_anchor_orbits(start_j2000_s);
let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
let mut identity = product(ScenarioExternalProductKind::Sp3, "synthetic-sp3", "pending");
let mut scenario = base_scenario();
scenario.constellation = ScenarioConstellation::ExternalProducts {
source: identity.clone(),
satellites: satellites.iter().map(|sat| sat.satellite_id).collect(),
};
let declared = DeclaredScenarioSource::new(&source, identity.clone());
identity.content_digest = scenario_source_transcript_fingerprint(
&scenario,
&declared,
&ScenarioMediaSources::default(),
)
.expect("source fingerprint");
scenario.constellation = ScenarioConstellation::ExternalProducts {
source: identity.clone(),
satellites: satellites.iter().map(|sat| sat.satellite_id).collect(),
};
let declared = DeclaredScenarioSource::new(&source, identity.clone());
simulate_scenario_with_source(&scenario, &declared).expect("matching identity");
let mut changed_satellites = satellites.clone();
changed_satellites[0].semi_major_axis_m += 10.0;
let changed_source = SyntheticKeplerSource::new(changed_satellites).expect("source");
let mismatched_data = DeclaredScenarioSource::new(&changed_source, identity.clone());
let err =
simulate_scenario_with_source(&scenario, &mismatched_data).expect_err("data mismatch");
assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
let wrong = DeclaredScenarioSource::new(
&source,
product(
ScenarioExternalProductKind::Broadcast,
"other",
"sha256:other",
),
);
let err = simulate_scenario_with_source(&scenario, &wrong).expect_err("mismatch");
assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
}
#[test]
fn supplied_ionex_requires_matching_media_and_contributes_terms() {
let mut scenario = base_scenario();
scenario.epochs.count = 1;
let epoch_s = scenario.epochs.start_j2000_s.round() as i64;
let ionex = constant_ionex(epoch_s, 12.0);
let identity = product(
ScenarioExternalProductKind::Ionex,
"synthetic-ionex",
&ionex_content_fingerprint(&ionex),
);
scenario.error_budget.ionosphere = ScenarioIonosphereModel::SuppliedIonex {
source: identity.clone(),
};
let missing = simulate_scenario(&scenario).expect_err("IONEX media required");
assert!(matches!(missing, ScenarioError::ExternalIonosphereRequired));
let media = ScenarioMediaSources {
ionex: Some(DeclaredIonexSource::new(&ionex, &identity)),
};
let set = simulate_scenario_with_media(&scenario, &media).expect("simulate with IONEX");
assert!(set.truth_terms.ionosphere_m[0] > 0.0);
assert!(
set.truth_terms.carrier_phase_ionosphere_cycles[0] < 0.0,
"carrier phase ionosphere has opposite sign"
);
let wrong_identity = product(ScenarioExternalProductKind::Ionex, "other", "sha256:other");
let wrong_media = ScenarioMediaSources {
ionex: Some(DeclaredIonexSource::new(&ionex, &wrong_identity)),
};
let err = simulate_scenario_with_media(&scenario, &wrong_media).expect_err("mismatch");
assert!(matches!(err, ScenarioError::ExternalSourceMismatch { .. }));
}
#[test]
fn synthetic_kepler_source_has_fixed_geometry_anchor() {
let scenario = base_scenario();
let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation
else {
panic!("synthetic scenario expected");
};
let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
let sat = satellites[0].satellite_id;
let state = source
.state_at_j2000_s(sat, scenario.epochs.start_j2000_s)
.expect("state");
assert!((state.position_ecef_m[0] - 26_560_000.0).abs() < 1.0e-8);
assert!(state.position_ecef_m[1].abs() < 1.0e-8);
assert!(state.position_ecef_m[2].abs() < 1.0e-8);
let receiver = geodetic_to_itrf(
ScenarioGeodeticPosition {
lat_rad: 0.0,
lon_rad: 0.0,
height_m: 0.0,
}
.to_wgs84()
.expect("geodetic"),
)
.expect("ecef")
.as_array();
let geometric = norm3(sub3(state.position_ecef_m, receiver));
assert!((geometric - 20_181_863.0).abs() < 1.0e-6);
}
#[test]
fn clean_scenario_spp_recovers_truth_to_numerical_precision() {
let scenario = base_scenario();
let set = simulate_scenario(&scenario).expect("simulate");
let ScenarioConstellation::SyntheticKeplerian { satellites } = &scenario.constellation
else {
panic!("synthetic scenario expected");
};
let source = SyntheticKeplerSource::new(satellites.clone()).expect("source");
let truth = set.receiver_truth[0];
let inputs = SolveInputs {
observations: set.spp_observations_for_epoch(0),
t_rx_j2000_s: truth.t_rx_j2000_s,
t_rx_second_of_day_s: 0.0,
day_of_year: 1.0,
initial_guess: [
truth.position_ecef_m[0],
truth.position_ecef_m[1],
truth.position_ecef_m[2],
truth.clock_m,
],
corrections: Corrections::NONE,
..SolveInputs::default()
};
let solved = solve(&source, &inputs, false).expect("SPP solution");
let delta = norm3(sub3(solved.position.as_array(), truth.position_ecef_m));
assert!(
delta < 1.0e-5,
"closed-loop consistency position delta {delta}"
);
assert!(
(solved.rx_clock_s - truth.clock_m / C_M_S).abs() < 1.0e-12,
"closed-loop consistency clock"
);
}
}