use crate::astro::angles::rad_to_deg_ref;
use crate::astro::math::least_squares::{
self, solve_trf_with, LeastSquaresProblem, SolveOptions, Status, TrustRegionSolve,
};
use nalgebra::DVector;
use std::collections::BTreeMap;
mod config;
mod fallback;
mod source;
use crate::astro::math::robust::{huber_weight, mad_scale, RobustError};
pub use config::{
DEFAULT_HUBER_K, DEFAULT_ROBUST_MAX_OUTER, DEFAULT_ROBUST_OUTER_TOL_M,
DEFAULT_ROBUST_SCALE_FLOOR_M, ELEVATION_MASK_RAD, SIGMA0_M, TRANSMIT_TIME_ITERATIONS,
};
pub use fallback::{
solve_broadcast, solve_with_fallback, BroadcastReason, FallbackError, FixSource,
SourcedSolution,
};
pub use source::EphemerisSource;
pub use crate::constants::{C_M_S, F_L1_HZ, OMEGA_E_DOT_RAD_S};
use crate::dop::{dop, dop_multi, Dop, LineOfSight};
use crate::estimation::recipe::{
EstimationRecipe, FrameRecipe, RangeRecipe, SagnacRecipe, SolverRecipe,
};
use crate::estimation::substrate::frames::{az_el_from_ecef, geodetic_from_ecef};
use crate::estimation::substrate::parameters::ParameterLayout;
use crate::estimation::substrate::range::{geometric_range, rotate_transmit_satellite};
use crate::frame::{ItrfPositionM, Wgs84Geodetic};
use crate::frequencies;
use crate::id::{GnssSatelliteId, GnssSystem};
pub use crate::ionex::GalileoNequickCoeffs;
use crate::ionex::{
galileo_nequick_g_native_unchecked, klobuchar_native_unchecked, GalileoNequickEval,
KlobucharParams,
};
use crate::quality::{
validate_receiver_solution, SolutionValidationError, SolutionValidationOptions,
};
use crate::sbas::SbasIonoGrid;
use crate::tropo::slant_components;
use crate::validate;
pub(crate) const fn carrier_frequency_hz(system: GnssSystem) -> Option<f64> {
match system {
GnssSystem::Sbas => Some(F_L1_HZ),
_ => frequencies::default_spp_frequency_hz(system),
}
}
pub(crate) fn spp_iono_frequency_hz(
sat: GnssSatelliteId,
glonass_channels: &BTreeMap<u8, i8>,
) -> Option<f64> {
match sat.system {
GnssSystem::Glonass => glonass_channels
.get(&sat.prn)
.copied()
.filter(|&k| crate::rinex_nav::valid_glonass_frequency_channel(i32::from(k)))
.map(frequencies::glonass_g1_frequency_hz),
_ => carrier_frequency_hz(sat.system),
}
}
use crate::constants::MEAN_EARTH_RADIUS_M;
const PI: f64 = std::f64::consts::PI;
const CANONICAL_LIGHT_TIME_TOL_S: f64 = 1.0e-13;
const CANONICAL_LIGHT_TIME_MAX_ITERS: usize = 10;
const SPP_SOLVER_GTOL: f64 = 1e-14;
const SPP_SOLVER_FTOL: f64 = 1e-15;
const SPP_SOLVER_XTOL: f64 = 1e-14;
const SPP_SOLVER_MAX_NFEV: usize = 400;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Observation {
pub satellite_id: GnssSatelliteId,
pub pseudorange_m: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RejectionReason {
NoEphemeris,
LowElevation,
SbasWithdrawn,
SbasIonoUncovered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RejectedSat {
pub satellite_id: GnssSatelliteId,
pub reason: RejectionReason,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SolutionMetadata {
pub iterations: usize,
pub converged: bool,
pub status: Status,
pub ionosphere_applied: bool,
pub troposphere_applied: bool,
pub outer_iterations: usize,
pub final_robust_scale_m: Option<f64>,
pub used_count: usize,
pub systems: Vec<GnssSystem>,
pub redundancy: isize,
pub raim_checkable: bool,
}
#[derive(Debug, Clone)]
pub struct ReceiverSolution {
pub position: ItrfPositionM,
pub geodetic: Option<Wgs84Geodetic>,
pub rx_clock_s: f64,
pub system_clocks_s: Vec<(GnssSystem, f64)>,
pub dop: Option<Dop>,
pub system_tdops: Vec<(GnssSystem, f64)>,
pub residuals_m: Vec<f64>,
pub used_sats: Vec<GnssSatelliteId>,
pub rejected_sats: Vec<RejectedSat>,
pub metadata: SolutionMetadata,
}
impl ReceiverSolution {
pub fn residual_rms_m(&self) -> f64 {
residual_rms(&self.residuals_m)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Corrections {
pub ionosphere: bool,
pub troposphere: bool,
}
impl Corrections {
pub const NONE: Self = Self {
ionosphere: false,
troposphere: false,
};
pub const IONO: Self = Self {
ionosphere: true,
troposphere: false,
};
pub const IONO_TROPO: Self = Self {
ionosphere: true,
troposphere: true,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KlobucharCoeffs {
pub alpha: [f64; 4],
pub beta: [f64; 4],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceMet {
pub pressure_hpa: f64,
pub temperature_k: f64,
pub relative_humidity: f64,
}
impl Default for SurfaceMet {
fn default() -> Self {
Self {
pressure_hpa: 1013.25,
temperature_k: 288.15,
relative_humidity: 0.5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RobustConfig {
pub huber_k: f64,
pub scale_floor_m: f64,
pub max_outer: usize,
pub outer_tol_m: f64,
}
impl Default for RobustConfig {
fn default() -> Self {
Self {
huber_k: DEFAULT_HUBER_K,
scale_floor_m: DEFAULT_ROBUST_SCALE_FLOOR_M,
max_outer: DEFAULT_ROBUST_MAX_OUTER,
outer_tol_m: DEFAULT_ROBUST_OUTER_TOL_M,
}
}
}
#[derive(Debug, Clone)]
pub struct SolveInputs {
pub observations: Vec<Observation>,
pub t_rx_j2000_s: f64,
pub t_rx_second_of_day_s: f64,
pub day_of_year: f64,
pub initial_guess: [f64; 4],
pub corrections: Corrections,
pub klobuchar: KlobucharCoeffs,
pub beidou_klobuchar: Option<KlobucharCoeffs>,
pub galileo_nequick: Option<GalileoNequickCoeffs>,
pub sbas_iono: Option<SbasIonoGrid>,
pub glonass_channels: BTreeMap<u8, i8>,
pub met: SurfaceMet,
pub robust: Option<RobustConfig>,
}
impl Default for SolveInputs {
fn default() -> Self {
Self {
observations: Vec::new(),
t_rx_j2000_s: 0.0,
t_rx_second_of_day_s: 0.0,
day_of_year: 1.0,
initial_guess: [0.0; 4],
corrections: Corrections::NONE,
klobuchar: KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
},
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: SurfaceMet::default(),
robust: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SppInputErrorKind {
NonFinite,
NotPositive,
Negative,
OutOfRange,
Missing,
FloatParse,
IntParse,
InvalidCivilDate,
InvalidCivilTime,
}
impl core::fmt::Display for SppInputErrorKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let label = match self {
Self::NonFinite => "not finite",
Self::NotPositive => "not positive",
Self::Negative => "negative",
Self::OutOfRange => "out of range",
Self::Missing => "missing",
Self::FloatParse => "invalid float",
Self::IntParse => "invalid integer",
Self::InvalidCivilDate => "invalid civil date",
Self::InvalidCivilTime => "invalid civil time",
};
f.write_str(label)
}
}
impl From<&validate::FieldError> for SppInputErrorKind {
fn from(error: &validate::FieldError) -> Self {
match error {
validate::FieldError::Missing { .. } => Self::Missing,
validate::FieldError::NonFinite { .. } => Self::NonFinite,
validate::FieldError::NotPositive { .. } => Self::NotPositive,
validate::FieldError::Negative { .. } => Self::Negative,
validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
validate::FieldError::FloatParse { .. } => Self::FloatParse,
validate::FieldError::IntParse { .. } => Self::IntParse,
validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
}
}
}
#[derive(Debug, Clone)]
pub enum SppError {
InvalidInput {
field: &'static str,
kind: SppInputErrorKind,
},
TooFewSatellites {
used: usize,
required: usize,
},
Singular(least_squares::SolveError),
DuplicateObservation {
satellite: GnssSatelliteId,
},
EphemerisLost {
satellite: GnssSatelliteId,
},
IonosphereUnsupported {
satellite: GnssSatelliteId,
},
}
impl core::fmt::Display for SppError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
SppError::InvalidInput { field, kind } => {
write!(f, "invalid SPP input {field}: {kind}")
}
SppError::TooFewSatellites { used, required } => write!(
f,
"only {used} usable satellites; need at least {required} \
(3 position + 1 clock per GNSS)"
),
SppError::Singular(e) => write!(f, "degenerate geometry: {e}"),
SppError::DuplicateObservation { satellite } => {
write!(f, "satellite {satellite} observed more than once")
}
SppError::EphemerisLost { satellite } => {
write!(f, "satellite {satellite} lost ephemeris during the solve")
}
SppError::IonosphereUnsupported { satellite } => write!(
f,
"ionosphere correction has no modeled carrier frequency for {satellite}"
),
}
}
}
impl std::error::Error for SppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SppError::Singular(error) => Some(error),
_ => None,
}
}
}
impl From<least_squares::SolveError> for SppError {
fn from(e: least_squares::SolveError) -> Self {
SppError::Singular(e)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SolvePolicy {
pub validation: SolutionValidationOptions,
pub coarse_search_seeds: Option<usize>,
}
#[derive(Debug, Clone)]
pub enum SolvePolicyError {
Solve(SppError),
Validation(SolutionValidationError),
NoCoarseSolution,
}
impl From<SppError> for SolvePolicyError {
fn from(error: SppError) -> Self {
Self::Solve(error)
}
}
impl From<SolutionValidationError> for SolvePolicyError {
fn from(error: SolutionValidationError) -> Self {
Self::Validation(error)
}
}
impl core::fmt::Display for SolvePolicyError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Solve(error) => write!(f, "SPP solve failed: {error}"),
Self::Validation(error) => write!(f, "SPP validation failed: {error}"),
Self::NoCoarseSolution => write!(f, "coarse search found no converged SPP solution"),
}
}
}
impl std::error::Error for SolvePolicyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Solve(error) => Some(error),
Self::Validation(error) => Some(error),
Self::NoCoarseSolution => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SppModelRecipe {
pub range: RangeRecipe,
pub sagnac: SagnacRecipe,
pub frame: FrameRecipe,
}
impl SppModelRecipe {
pub(crate) const fn from_recipe(recipe: &EstimationRecipe) -> Self {
Self {
range: recipe.range,
sagnac: recipe.sagnac,
frame: recipe.frame,
}
}
pub(crate) const fn reference() -> Self {
Self::from_recipe(&EstimationRecipe::spp())
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SatModel {
pub sat_rot_ecef_m: [f64; 3],
pub el_rad: f64,
pub p_hat_m: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub az_rad: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub tau_s: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub t_tx_j2000_s: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub sat_ecef_m: [f64; 3],
#[cfg(all(test, sidereon_repo_tests))]
pub dt_sat_s: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub theta_rad: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub rho_m: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub iono_m: f64,
#[cfg(all(test, sidereon_repo_tests))]
pub tropo_m: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum SppIonosphere<'a> {
Klobuchar(KlobucharCoeffs),
GalileoNequick(GalileoNequickCoeffs),
SbasGrid(&'a SbasIonoGrid),
}
fn ionosphere_for<'a>(system: GnssSystem, inputs: &'a SolveInputs) -> SppIonosphere<'a> {
if let Some(grid) = inputs
.sbas_iono
.as_ref()
.filter(|_| inputs.corrections.ionosphere)
{
return SppIonosphere::SbasGrid(grid);
}
match (system, inputs.galileo_nequick, inputs.beidou_klobuchar) {
(GnssSystem::Galileo, Some(gal), _) => SppIonosphere::GalileoNequick(gal),
(GnssSystem::BeiDou, _, Some(bds)) => SppIonosphere::Klobuchar(bds),
_ => SppIonosphere::Klobuchar(inputs.klobuchar),
}
}
pub(crate) struct SatModelEnv<'a> {
pub eph: &'a dyn EphemerisSource,
pub t_rx_j2000_s: f64,
pub t_rx_second_of_day_s: f64,
pub day_of_year: f64,
pub corrections: Corrections,
pub met: &'a SurfaceMet,
pub glonass_channels: &'a BTreeMap<u8, i8>,
pub model: SppModelRecipe,
}
pub(crate) fn sat_model(
env: &SatModelEnv,
sat: GnssSatelliteId,
rx_ecef_m: [f64; 3],
b_m: f64,
p_meas_m: f64,
ionosphere: SppIonosphere<'_>,
) -> Option<SatModel> {
let sagnac = env.model.sagnac;
let frame = env.model.frame;
let (sat_pos, dt_sat, tau) = match env.model.range {
RangeRecipe::SppMeasuredPseudorangeFixedIter => {
let mut tau = p_meas_m / C_M_S;
let mut t_tx = env.t_rx_j2000_s - tau;
let mut sat_pos = [0.0f64; 3];
let mut dt_sat = 0.0f64;
for _ in 0..TRANSMIT_TIME_ITERATIONS {
let (pos, clk) = env.eph.position_clock_at_j2000_s(sat, t_tx)?;
sat_pos = pos;
dt_sat = clk;
let rho0 = geometric_range(sagnac, sat_pos, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
tau = rho0 / C_M_S;
t_tx = env.t_rx_j2000_s - tau;
}
(sat_pos, dt_sat, tau)
}
RangeRecipe::CanonicalLightTimeClosedFormSagnac => {
let mut tau = p_meas_m / C_M_S;
let mut t_tx = env.t_rx_j2000_s - tau;
let mut sat_pos = [0.0f64; 3];
let mut dt_sat = 0.0f64;
let mut prev_tau = f64::INFINITY;
for _ in 0..CANONICAL_LIGHT_TIME_MAX_ITERS {
let (pos, clk) = env.eph.position_clock_at_j2000_s(sat, t_tx)?;
sat_pos = pos;
dt_sat = clk;
let rho0 = geometric_range(sagnac, sat_pos, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
tau = rho0 / C_M_S;
t_tx = env.t_rx_j2000_s - tau;
if (tau - prev_tau).abs() <= CANONICAL_LIGHT_TIME_TOL_S {
break;
}
prev_tau = tau;
}
(sat_pos, dt_sat, tau)
}
RangeRecipe::ObservableRoundedMicrosecondFixedIter
| RangeRecipe::RtkProvidedTxFirstOrderSagnac => unreachable!(
"the SPP measurement model runs only the measured-pseudorange or canonical light-time recipe"
),
};
let sat_rot = rotate_transmit_satellite(sagnac, sat_pos, tau, OMEGA_E_DOT_RAD_S);
let rho = geometric_range(sagnac, sat_rot, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
let g = az_el_from_ecef(frame, rx_ecef_m, sat_rot);
let mut iono_m = 0.0;
let mut tropo_m = 0.0;
if env.corrections.ionosphere {
let lat_deg = rad_to_deg_ref(g.geodetic.lat_rad);
let lon_deg = rad_to_deg_ref(g.geodetic.lon_rad);
let az_deg = rad_to_deg_ref(g.az_rad);
let el_deg = rad_to_deg_ref(g.el_rad);
let freq_hz = spp_iono_frequency_hz(sat, env.glonass_channels).unwrap_or(F_L1_HZ);
iono_m = match ionosphere {
SppIonosphere::Klobuchar(klobuchar) => klobuchar_native_unchecked(
&KlobucharParams {
alpha: klobuchar.alpha,
beta: klobuchar.beta,
},
lat_deg,
lon_deg,
az_deg,
el_deg,
env.t_rx_second_of_day_s,
freq_hz,
),
SppIonosphere::GalileoNequick(coeffs) => galileo_nequick_g_native_unchecked(
&coeffs,
GalileoNequickEval {
lat_deg,
lon_deg,
el_deg,
t_gal_s: env.t_rx_second_of_day_s,
day_of_year: env.day_of_year,
frequency_hz: freq_hz,
},
),
SppIonosphere::SbasGrid(grid) => {
grid.slant_delay_m(g.geodetic, g.el_rad, g.az_rad, freq_hz)?
}
};
}
if env.corrections.troposphere {
tropo_m = slant_components(
g.el_rad,
g.geodetic,
env.met.pressure_hpa,
env.met.temperature_k,
env.met.relative_humidity,
env.day_of_year,
)
.slant_m;
}
let p_hat = rho + b_m - C_M_S * dt_sat + iono_m + tropo_m;
Some(SatModel {
sat_rot_ecef_m: sat_rot,
el_rad: g.el_rad,
p_hat_m: p_hat,
#[cfg(all(test, sidereon_repo_tests))]
az_rad: g.az_rad,
#[cfg(all(test, sidereon_repo_tests))]
tau_s: tau,
#[cfg(all(test, sidereon_repo_tests))]
t_tx_j2000_s: env.t_rx_j2000_s - tau,
#[cfg(all(test, sidereon_repo_tests))]
sat_ecef_m: sat_pos,
#[cfg(all(test, sidereon_repo_tests))]
dt_sat_s: dt_sat,
#[cfg(all(test, sidereon_repo_tests))]
theta_rad: OMEGA_E_DOT_RAD_S * tau,
#[cfg(all(test, sidereon_repo_tests))]
rho_m: rho,
#[cfg(all(test, sidereon_repo_tests))]
iono_m,
#[cfg(all(test, sidereon_repo_tests))]
tropo_m,
})
}
pub(crate) struct Selection {
pub used: Vec<GnssSatelliteId>,
pub rejected: Vec<RejectedSat>,
pub weights: Vec<f64>,
}
pub(crate) fn select_sats(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
model: SppModelRecipe,
) -> Selection {
let rx0 = [
inputs.initial_guess[0],
inputs.initial_guess[1],
inputs.initial_guess[2],
];
let b0 = inputs.initial_guess[3];
let mut obs: Vec<&Observation> = inputs.observations.iter().collect();
obs.sort_by_key(|o| o.satellite_id);
let mut used = Vec::new();
let mut rejected = Vec::new();
let mut weights = Vec::new();
let env = SatModelEnv {
eph,
t_rx_j2000_s: inputs.t_rx_j2000_s,
t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
day_of_year: inputs.day_of_year,
corrections: inputs.corrections,
met: &inputs.met,
glonass_channels: &inputs.glonass_channels,
model,
};
for ob in obs {
let ionosphere = ionosphere_for(ob.satellite_id.system, inputs);
let uses_sbas_grid = matches!(ionosphere, SppIonosphere::SbasGrid(_));
let model = sat_model(&env, ob.satellite_id, rx0, b0, ob.pseudorange_m, ionosphere);
let Some(model) = model else {
rejected.push(RejectedSat {
satellite_id: ob.satellite_id,
reason: if uses_sbas_grid {
RejectionReason::SbasIonoUncovered
} else {
RejectionReason::NoEphemeris
},
});
continue;
};
if model.el_rad < ELEVATION_MASK_RAD {
rejected.push(RejectedSat {
satellite_id: ob.satellite_id,
reason: RejectionReason::LowElevation,
});
continue;
}
let sin_el = model.el_rad.sin();
let weight = (sin_el * sin_el) / (SIGMA0_M * SIGMA0_M);
used.push(ob.satellite_id);
weights.push(weight);
}
Selection {
used,
rejected,
weights,
}
}
pub(crate) fn clock_systems(used: &[GnssSatelliteId]) -> Vec<GnssSystem> {
let mut systems: Vec<GnssSystem> = used
.iter()
.map(|s| match s.system {
GnssSystem::Sbas => GnssSystem::Gps,
system => system,
})
.collect();
systems.sort_unstable();
systems.dedup();
systems
}
pub(crate) fn residual_unweighted(
eph: &dyn EphemerisSource,
used: &[GnssSatelliteId],
obs_by_id: &[(GnssSatelliteId, f64)],
x: &[f64],
inputs: &SolveInputs,
model: SppModelRecipe,
) -> Result<Vec<f64>, GnssSatelliteId> {
let rx = [x[0], x[1], x[2]];
let systems = clock_systems(used);
let env = SatModelEnv {
eph,
t_rx_j2000_s: inputs.t_rx_j2000_s,
t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
day_of_year: inputs.day_of_year,
corrections: inputs.corrections,
met: &inputs.met,
glonass_channels: &inputs.glonass_channels,
model,
};
let mut out = Vec::with_capacity(used.len());
for &sat in used {
let p_meas = obs_by_id
.iter()
.find(|(id, _)| *id == sat)
.map(|(_, p)| *p)
.ok_or(sat)?;
let sat_clock_system = match sat.system {
GnssSystem::Sbas => GnssSystem::Gps,
system => system,
};
let sys_idx = systems
.iter()
.position(|s| *s == sat_clock_system)
.unwrap_or(0);
let b = x[3 + sys_idx];
let m =
sat_model(&env, sat, rx, b, p_meas, ionosphere_for(sat.system, inputs)).ok_or(sat)?;
out.push(p_meas - m.p_hat_m);
}
Ok(out)
}
pub fn solve(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
) -> Result<ReceiverSolution, SppError> {
validate_solve_inputs(inputs)?;
solve_inner(
eph,
inputs,
with_geodetic,
SppModelRecipe::reference(),
TrustRegionSolve::NalgebraLu,
)
}
const fn trust_region_solve(solver: SolverRecipe) -> TrustRegionSolve {
match solver {
SolverRecipe::OwnedDeterministicTrf => TrustRegionSolve::OwnedGaussianFirstTie,
_ => TrustRegionSolve::NalgebraLu,
}
}
pub fn solve_with_solver(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
solver: SolverRecipe,
) -> Result<ReceiverSolution, SppError> {
validate_solve_inputs(inputs)?;
solve_inner(
eph,
inputs,
with_geodetic,
SppModelRecipe::reference(),
trust_region_solve(solver),
)
}
fn solve_inner(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
model: SppModelRecipe,
linear_solve: TrustRegionSolve,
) -> Result<ReceiverSolution, SppError> {
let mut ids: Vec<GnssSatelliteId> =
inputs.observations.iter().map(|o| o.satellite_id).collect();
ids.sort_unstable();
if let Some(w) = ids.windows(2).find(|w| w[0] == w[1]) {
return Err(SppError::DuplicateObservation { satellite: w[0] });
}
if inputs.corrections.ionosphere {
if let Some(sat) = ids
.iter()
.find(|s| spp_iono_frequency_hz(**s, &inputs.glonass_channels).is_none())
{
return Err(SppError::IonosphereUnsupported { satellite: *sat });
}
}
let sel = select_sats(eph, inputs, model);
let systems = clock_systems(&sel.used);
let n_clocks = systems.len();
let n_params = ParameterLayout::spp(n_clocks.max(1)).dim();
if sel.used.len() < n_params {
return Err(SppError::TooFewSatellites {
used: sel.used.len(),
required: n_params,
});
}
let obs_by_id: Vec<(GnssSatelliteId, f64)> = inputs
.observations
.iter()
.map(|o| (o.satellite_id, o.pseudorange_m))
.collect();
let used = sel.used.clone();
let inputs_ref = inputs.clone();
let obs_ref = obs_by_id.clone();
let eph_ref = eph;
let n_used = used.len();
let lost = std::rc::Rc::new(std::cell::Cell::new(None::<GnssSatelliteId>));
let lost_in = lost.clone();
let residual = move |x: &DVector<f64>| -> DVector<f64> {
match residual_unweighted(eph_ref, &used, &obs_ref, x.as_slice(), &inputs_ref, model) {
Ok(r) => DVector::from_vec(r),
Err(sat) => {
lost_in.set(Some(sat));
DVector::from_vec(vec![0.0; n_used])
}
}
};
let mut x0v = inputs.initial_guess.to_vec();
x0v.extend(std::iter::repeat_n(0.0, n_clocks - 1));
let x0 = DVector::from_vec(x0v);
let opts = SolveOptions {
gtol: SPP_SOLVER_GTOL,
ftol: SPP_SOLVER_FTOL,
xtol: SPP_SOLVER_XTOL,
max_nfev: SPP_SOLVER_MAX_NFEV,
};
let base_weights = DVector::from_row_slice(&sel.weights);
let problem = LeastSquaresProblem::with_weights(&residual, x0, base_weights);
let report_result = solve_trf_with(&problem, &opts, linear_solve);
if let Some(satellite) = lost.get() {
return Err(SppError::EphemerisLost { satellite });
}
let mut report = report_result?;
let mut outer_iterations = 0usize;
let mut final_robust_scale_m: Option<f64> = None;
if let Some(rc) = inputs.robust {
for _ in 0..rc.max_outer.saturating_sub(1) {
if lost.get().is_some() {
break;
}
let post = match residual_unweighted(
eph,
&sel.used,
&obs_by_id,
report.x.as_slice(),
inputs,
model,
) {
Ok(r) => r,
Err(satellite) => return Err(SppError::EphemerisLost { satellite }),
};
let scale = mad_scale(&post, rc.scale_floor_m).map_err(map_robust_error)?;
let eff: Vec<f64> = post
.iter()
.zip(sel.weights.iter())
.map(|(&r, &bw)| bw * huber_weight(r / scale, rc.huber_k))
.collect();
let eff_w = DVector::from_row_slice(&eff);
let x_prev = report.x.clone();
let problem = LeastSquaresProblem::with_weights(&residual, x_prev.clone(), eff_w);
let next = solve_trf_with(&problem, &opts, linear_solve);
if let Some(satellite) = lost.get() {
return Err(SppError::EphemerisLost { satellite });
}
report = next?;
outer_iterations += 1;
final_robust_scale_m = Some(scale);
let dpos = ((report.x[0] - x_prev[0]).powi(2)
+ (report.x[1] - x_prev[1]).powi(2)
+ (report.x[2] - x_prev[2]).powi(2))
.sqrt();
if dpos < rc.outer_tol_m {
break;
}
}
}
let xs = &report.x;
let position = ItrfPositionM::new(xs[0], xs[1], xs[2]).expect("valid ITRF position");
let rx_clock_s = xs[3] / C_M_S;
let system_clocks_s: Vec<(GnssSystem, f64)> = systems
.iter()
.enumerate()
.map(|(i, &sys)| (sys, xs[3 + i] / C_M_S))
.collect();
let geodetic = if with_geodetic {
Some(geodetic_from_ecef(model.frame, [xs[0], xs[1], xs[2]]))
} else {
None
};
let residuals_m = residual_unweighted(eph, &sel.used, &obs_by_id, xs.as_slice(), inputs, model)
.map_err(|satellite| SppError::EphemerisLost { satellite })?;
let rx_ecef = [xs[0], xs[1], xs[2]];
let geo = geodetic_from_ecef(model.frame, [xs[0], xs[1], xs[2]]);
let mut los = Vec::with_capacity(sel.used.len());
let mut clock_index = Vec::with_capacity(sel.used.len());
let env = SatModelEnv {
eph,
t_rx_j2000_s: inputs.t_rx_j2000_s,
t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
day_of_year: inputs.day_of_year,
corrections: inputs.corrections,
met: &inputs.met,
glonass_channels: &inputs.glonass_channels,
model,
};
for &sat in &sel.used {
let p_meas = obs_by_id
.iter()
.find(|(id, _)| *id == sat)
.map(|(_, p)| *p)
.ok_or(SppError::EphemerisLost { satellite: sat })?;
let m = sat_model(
&env,
sat,
rx_ecef,
xs[3],
p_meas,
ionosphere_for(sat.system, inputs),
)
.ok_or(SppError::EphemerisLost { satellite: sat })?;
let dx = m.sat_rot_ecef_m[0] - rx_ecef[0];
let dy = m.sat_rot_ecef_m[1] - rx_ecef[1];
let dz = m.sat_rot_ecef_m[2] - rx_ecef[2];
let n = (dx * dx + dy * dy + dz * dz).sqrt();
los.push(LineOfSight::new(dx / n, dy / n, dz / n));
let idx = systems.iter().position(|s| *s == sat.system).unwrap_or(0);
clock_index.push(idx);
}
let dop_result = if n_clocks == 1 {
dop(&los, &sel.weights, geo).ok().map(|mut d| {
d.system_tdops = vec![(systems[0], d.tdop)];
d
})
} else {
dop_multi(&los, &clock_index, &systems, n_clocks, &sel.weights, geo).ok()
};
let system_tdops: Vec<(GnssSystem, f64)> = dop_result
.as_ref()
.map(|d| d.system_tdops.clone())
.unwrap_or_default();
let converged = matches!(
report.status,
Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
);
let metadata_used_count = sel.used.len();
let metadata_redundancy = redundancy(&systems, metadata_used_count);
Ok(ReceiverSolution {
position,
geodetic,
rx_clock_s,
system_clocks_s,
dop: dop_result,
system_tdops,
residuals_m,
used_sats: sel.used,
rejected_sats: sel.rejected,
metadata: SolutionMetadata {
iterations: report.iterations,
converged,
status: report.status,
ionosphere_applied: inputs.corrections.ionosphere,
troposphere_applied: inputs.corrections.troposphere,
outer_iterations,
final_robust_scale_m,
used_count: metadata_used_count,
systems,
redundancy: metadata_redundancy,
raim_checkable: metadata_redundancy >= 1,
},
})
}
pub fn solve_with_policy(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
policy: SolvePolicy,
) -> Result<ReceiverSolution, SolvePolicyError> {
use crate::estimation::recipe::StrategyId;
use crate::estimation::strategies::{
estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
};
match estimate(
EstimateInput::Spp {
eph,
inputs,
with_geodetic,
policy,
},
EstimateOptions::new(StrategyId::spp_reference()),
) {
Ok(EstimateOutput::Spp(solution)) => Ok(*solution),
Err(EstimateError::Spp(error)) => Err(error),
Ok(_) | Err(_) => {
unreachable!("the SPP reference strategy yields an SPP solution or an SPP error")
}
}
}
pub fn solve_spp_batch_serial(
eph: &dyn EphemerisSource,
epochs: &[SolveInputs],
with_geodetic: bool,
policy: SolvePolicy,
) -> Vec<Result<ReceiverSolution, SolvePolicyError>> {
epochs
.iter()
.map(|inputs| solve_with_policy(eph, inputs, with_geodetic, policy))
.collect()
}
pub fn solve_spp_batch_parallel(
eph: &(dyn EphemerisSource + Sync),
epochs: &[SolveInputs],
with_geodetic: bool,
policy: SolvePolicy,
) -> Vec<Result<ReceiverSolution, SolvePolicyError>> {
use rayon::prelude::*;
epochs
.par_iter()
.map(|inputs| solve_with_policy(eph, inputs, with_geodetic, policy))
.collect()
}
pub(crate) fn run(
recipe: &EstimationRecipe,
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
policy: SolvePolicy,
) -> Result<ReceiverSolution, SolvePolicyError> {
validate_solve_inputs(inputs)?;
let model = SppModelRecipe::from_recipe(recipe);
match policy.coarse_search_seeds {
Some(seed_count) => solve_coarse(
eph,
inputs,
with_geodetic,
policy,
seed_count,
model,
recipe.solver,
),
None => solve_validated(
eph,
inputs,
with_geodetic,
policy.validation,
model,
recipe.solver,
),
}
}
fn solve_validated(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
validation: SolutionValidationOptions,
model: SppModelRecipe,
solver: SolverRecipe,
) -> Result<ReceiverSolution, SolvePolicyError> {
let solution = solve_inner(
eph,
inputs,
with_geodetic,
model,
trust_region_solve(solver),
)?;
validate_receiver_solution(&solution, validation)?;
Ok(solution)
}
fn solve_coarse(
eph: &dyn EphemerisSource,
inputs: &SolveInputs,
with_geodetic: bool,
policy: SolvePolicy,
seed_count: usize,
model: SppModelRecipe,
solver: SolverRecipe,
) -> Result<ReceiverSolution, SolvePolicyError> {
let mut candidates = Vec::new();
let mut last_error = SolvePolicyError::NoCoarseSolution;
for seed in std::iter::once(inputs.initial_guess).chain(coarse_seeds(seed_count)) {
let mut seeded = inputs.clone();
seeded.initial_guess = seed;
match solve_validated(
eph,
&seeded,
with_geodetic,
policy.validation,
model,
solver,
) {
Ok(solution) => candidates.push(solution),
Err(error) => last_error = error,
}
}
select_coarse_candidate(&candidates)
.cloned()
.ok_or(last_error)
}
fn coarse_seeds(n: usize) -> Vec<[f64; 4]> {
let golden = PI * (3.0 - 5.0_f64.sqrt());
(0..n)
.map(|i| {
let z = 1.0 - 2.0 * (i as f64 + 0.5) / n as f64;
let r = (1.0 - z * z).max(0.0).sqrt();
let theta = golden * i as f64;
[
MEAN_EARTH_RADIUS_M * r * theta.cos(),
MEAN_EARTH_RADIUS_M * r * theta.sin(),
MEAN_EARTH_RADIUS_M * z,
0.0,
]
})
.collect()
}
fn select_coarse_candidate(candidates: &[ReceiverSolution]) -> Option<&ReceiverSolution> {
candidates
.iter()
.filter(|solution| solution.metadata.converged && solution.metadata.redundancy >= 1)
.min_by(|a, b| compare_coarse_candidates(a, b))
}
fn compare_coarse_candidates(a: &ReceiverSolution, b: &ReceiverSolution) -> core::cmp::Ordering {
b.used_sats
.len()
.cmp(&a.used_sats.len())
.then_with(|| residual_rms(&a.residuals_m).total_cmp(&residual_rms(&b.residuals_m)))
.then_with(|| candidate_gdop(a).total_cmp(&candidate_gdop(b)))
}
fn candidate_gdop(solution: &ReceiverSolution) -> f64 {
solution
.dop
.as_ref()
.map(|dop| dop.gdop)
.unwrap_or(f64::INFINITY)
}
pub fn residual_rms(residuals: &[f64]) -> f64 {
if residuals.is_empty() {
return 0.0;
}
let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
(sum_sq / residuals.len() as f64).sqrt()
}
fn redundancy(systems: &[GnssSystem], used_count: usize) -> isize {
used_count as isize - (3 + systems.len() as isize)
}
fn validate_solve_inputs(inputs: &SolveInputs) -> Result<(), SppError> {
validate::finite(inputs.t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
validate::second_of_day(inputs.t_rx_second_of_day_s, "t_rx_second_of_day_s")
.map_err(map_input_error)?;
validate::finite_in_range_exclusive_upper(inputs.day_of_year, 1.0, 367.0, "day_of_year")
.map_err(map_input_error)?;
validate::finite_slice(&inputs.initial_guess, "initial_guess").map_err(map_input_error)?;
validate_klobuchar(&inputs.klobuchar, "klobuchar")?;
if let Some(klobuchar) = &inputs.beidou_klobuchar {
validate_klobuchar(klobuchar, "beidou_klobuchar")?;
}
if let Some(nequick) = &inputs.galileo_nequick {
validate_galileo_nequick(nequick)?;
}
if inputs.corrections.troposphere {
validate_met(&inputs.met)?;
}
validate_observations(&inputs.observations)?;
if let Some(robust) = inputs.robust {
if robust.max_outer == 0 {
return Err(SppError::InvalidInput {
field: "robust.max_outer",
kind: SppInputErrorKind::NotPositive,
});
}
validate::finite_positive(robust.huber_k, "robust.huber_k").map_err(map_input_error)?;
validate::finite_positive(robust.scale_floor_m, "robust.scale_floor_m")
.map_err(map_input_error)?;
validate::finite_positive(robust.outer_tol_m, "robust.outer_tol_m")
.map_err(map_input_error)?;
}
Ok(())
}
fn validate_klobuchar(coeffs: &KlobucharCoeffs, field: &'static str) -> Result<(), SppError> {
validate::finite_slice(&coeffs.alpha, field).map_err(map_input_error)?;
validate::finite_slice(&coeffs.beta, field).map_err(map_input_error)
}
fn validate_galileo_nequick(coeffs: &GalileoNequickCoeffs) -> Result<(), SppError> {
validate::finite(coeffs.ai0, "galileo_nequick").map_err(map_input_error)?;
validate::finite(coeffs.ai1, "galileo_nequick").map_err(map_input_error)?;
validate::finite(coeffs.ai2, "galileo_nequick").map_err(map_input_error)?;
Ok(())
}
fn validate_met(met: &SurfaceMet) -> Result<(), SppError> {
validate::finite_positive(met.pressure_hpa, "met.pressure_hpa").map_err(map_input_error)?;
validate::finite_positive(met.temperature_k, "met.temperature_k").map_err(map_input_error)?;
validate::fraction(met.relative_humidity, "met.relative_humidity").map_err(map_input_error)?;
Ok(())
}
fn validate_observations(observations: &[Observation]) -> Result<(), SppError> {
for obs in observations {
validate::finite_positive(obs.pseudorange_m, "observation.pseudorange_m")
.map_err(map_input_error)?;
}
Ok(())
}
fn map_input_error(error: validate::FieldError) -> SppError {
SppError::InvalidInput {
field: error.field(),
kind: SppInputErrorKind::from(&error),
}
}
fn map_robust_error(error: RobustError) -> SppError {
let field = match error.field() {
"scale_floor" => "robust.scale_floor_m",
"residuals" | "values" => "robust.residuals",
other => other,
};
let kind = match error.reason() {
"not finite" => SppInputErrorKind::NonFinite,
"not positive" => SppInputErrorKind::NotPositive,
"negative" => SppInputErrorKind::Negative,
"out of range" => SppInputErrorKind::OutOfRange,
_ => SppInputErrorKind::OutOfRange,
};
SppError::InvalidInput { field, kind }
}
#[cfg(all(test, sidereon_repo_tests))]
pub(crate) mod test_support {
use super::*;
pub fn geodetic_from_ecef_m_for_test(x_m: f64, y_m: f64, z_m: f64) -> Wgs84Geodetic {
geodetic_from_ecef(FrameRecipe::SppSkyfieldAuThreeIter, [x_m, y_m, z_m])
}
pub fn sat_model_for_test(
env: &SatModelEnv,
sat: GnssSatelliteId,
rx: [f64; 3],
b_m: f64,
p_meas: f64,
klobuchar: &KlobucharCoeffs,
) -> Option<SatModel> {
sat_model(
env,
sat,
rx,
b_m,
p_meas,
SppIonosphere::Klobuchar(*klobuchar),
)
}
pub fn sat_model_with_ionosphere_for_test(
env: &SatModelEnv,
sat: GnssSatelliteId,
rx: [f64; 3],
b_m: f64,
p_meas: f64,
ionosphere: SppIonosphere<'_>,
) -> Option<SatModel> {
sat_model(env, sat, rx, b_m, p_meas, ionosphere)
}
pub fn itrs_to_geodetic_core_km(x_km: f64, y_km: f64, z_km: f64) -> (f64, f64, f64) {
crate::astro::frames::transforms::itrs_to_geodetic_compute(x_km, y_km, z_km)
.expect("valid ITRS coordinates")
}
}
#[cfg(all(test, sidereon_repo_tests))]
mod tests;