use crate::astro::dynamics::forces::OMEGA_EARTH_RAD_S;
use crate::astro::dynamics::{Position, Velocity};
use crate::coordinates::frames::GCRS;
use crate::time::JulianDate;
use qtty::unit::{Kilometer, Second};
use qtty::velocity::C;
use qtty::{Hertz, Meter, Per};
use super::error::PodObservationsError;
use super::obs_trait::{CartesianState, ObsType, Observation, PhaseResidual};
use crate::pod::observation::provider_bundle::ProviderBundle;
const C_M_S: f64 = 299_792_458.0;
#[derive(Debug, Clone, Copy)]
pub struct KlobucharParams {
pub alpha: [f64; 4],
pub beta: [f64; 4],
pub receiver_lat_sc: f64,
pub receiver_lon_sc: f64,
pub elevation_sc: f64,
pub azimuth_sc: f64,
pub utc_seconds: f64,
}
impl KlobucharParams {
pub fn gps_default() -> Self {
Self {
alpha: [2.0e-8, 1.490e-8, -5.960e-8, -5.960e-8],
beta: [90_112.0, 65_536.0, -131_072.0, -131_072.0],
receiver_lat_sc: 0.0,
receiver_lon_sc: 0.0,
elevation_sc: 0.25,
azimuth_sc: 0.0,
utc_seconds: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub enum IonoModel {
IonoFree,
Klobuchar(KlobucharParams),
None,
}
#[derive(Debug, Clone, Copy)]
pub enum TropModel {
Saastamoinen {
elevation_rad: f64,
},
None,
}
fn saastamoinen_m(elevation_rad: f64) -> f64 {
let sin_e = elevation_rad.sin().max(0.07); let dry = 0.002_277 * 1013.25 / sin_e;
let wet = 0.002_277 * (1255.0 / 288.15 + 0.05) * 11.691 / sin_e;
dry + wet
}
fn klobuchar_m(p: &KlobucharParams) -> f64 {
let e = p.elevation_sc;
let psi = 0.0137 / (e + 0.11) - 0.022;
let phi_i = (p.receiver_lat_sc + psi * (p.azimuth_sc * std::f64::consts::PI).cos())
.clamp(-0.416, 0.416);
let cos_phi = (phi_i * std::f64::consts::PI).cos();
let lambda_i = if cos_phi.abs() > 1e-9 {
p.receiver_lon_sc + psi * (p.azimuth_sc * std::f64::consts::PI).sin() / cos_phi
} else {
p.receiver_lon_sc
};
let phi_m = phi_i + 0.064 * ((lambda_i - 1.617) * std::f64::consts::PI).cos();
let mut t = 4.32e4 * lambda_i + p.utc_seconds;
t -= (t / 86400.0).floor() * 86400.0;
let period_raw: f64 = p.beta[0]
+ p.beta[1] * phi_m
+ p.beta[2] * phi_m * phi_m
+ p.beta[3] * phi_m * phi_m * phi_m;
let period = period_raw.max(72_000.0);
let amp_raw: f64 = p.alpha[0]
+ p.alpha[1] * phi_m
+ p.alpha[2] * phi_m * phi_m
+ p.alpha[3] * phi_m * phi_m * phi_m;
let amp = amp_raw.max(0.0);
let obliquity = 1.0 + 16.0 * (0.53 - e).powi(3);
let x = 2.0 * std::f64::consts::PI * (t - 50_400.0) / period;
let i_v = if x.abs() < std::f64::consts::FRAC_PI_2 {
5.0e-9 + amp * x.cos()
} else {
5.0e-9
};
obliquity * i_v * C_M_S
}
fn sagnac_km(gps_pos_km: Position<GCRS>, rx_pos_km: Position<GCRS>) -> f64 {
let c_km_s = C.to::<Per<Kilometer, Second>>().value();
OMEGA_EARTH_RAD_S.value()
* (gps_pos_km.x().value() * rx_pos_km.y().value()
- gps_pos_km.y().value() * rx_pos_km.x().value())
/ c_km_s
}
fn relativistic_gps_clock_m(pos_km: Position<GCRS>, vel_km_s: Velocity<GCRS>) -> f64 {
let dot = pos_km.x().value() * vel_km_s.x().value()
+ pos_km.y().value() * vel_km_s.y().value()
+ pos_km.z().value() * vel_km_s.z().value();
-2.0 * dot * 1_000.0 / C.value()
}
fn geometric_range_m(state: &CartesianState, sat_pos_km: Position<GCRS>) -> (f64, [f64; 3]) {
let los = state.position - sat_pos_km;
let rho_km = los.magnitude().value();
let rho_m = rho_km * 1_000.0;
let hat = if rho_km > 0.0 {
[
los.x().value() / rho_km,
los.y().value() / rho_km,
los.z().value() / rho_km,
]
} else {
[0.0; 3]
};
(rho_m, hat)
}
#[derive(Debug, Clone)]
pub struct GnssPseudorangeObs {
pub prn: String,
pub epoch: JulianDate,
pub measured_m: Meter,
pub sigma: Meter,
pub sat_pos_gcrs_km: Position<GCRS>,
pub sat_vel_gcrs_km_s: Velocity<GCRS>,
pub trop: TropModel,
pub iono: IonoModel,
pub frequency_hz: Hertz,
}
impl Observation for GnssPseudorangeObs {
type Residual = f64;
fn residual(
&self,
state: &CartesianState,
providers: &dyn ProviderBundle,
) -> Result<f64, PodObservationsError> {
let sat_pos = self.sat_pos_gcrs_km;
let sat_vel = self.sat_vel_gcrs_km_s;
let (rho_m, _) = geometric_range_m(state, sat_pos);
let sagnac_m = sagnac_km(sat_pos, state.position) * 1_000.0;
let rel_m = relativistic_gps_clock_m(sat_pos, sat_vel);
let trop_m = match self.trop {
TropModel::None => 0.0,
TropModel::Saastamoinen { elevation_rad } => saastamoinen_m(elevation_rad),
};
let f_hz = self.frequency_hz.value();
let iono_m = match &self.iono {
IonoModel::IonoFree => 0.0,
IonoModel::None => 0.0,
IonoModel::Klobuchar(p) => {
let iono_l1 = klobuchar_m(p);
let f_l1_hz = 1_575_420_000.0_f64;
if f_hz > 0.0 && (f_hz - f_l1_hz).abs() > 1e6 {
iono_l1 * (f_l1_hz / f_hz).powi(2)
} else {
iono_l1
}
}
};
let dt_r = providers.receiver_clock_m().value();
let dt_s = providers
.gnss_satellite_clock_m(&self.prn, self.epoch)
.value();
let modelled = rho_m + dt_r - dt_s + trop_m + iono_m + sagnac_m + rel_m;
Ok(self.measured_m.value() - modelled)
}
fn obs_type(&self) -> ObsType {
ObsType::GnssPseudorange
}
fn epoch(&self) -> JulianDate {
self.epoch
}
fn sigma(&self) -> Meter {
self.sigma
}
}
#[derive(Debug, Clone)]
pub struct GnssCarrierPhaseObs {
pub prn: String,
pub epoch: JulianDate,
pub measured_m: Meter,
pub sigma: Meter,
pub sat_pos_gcrs_km: Position<GCRS>,
pub sat_vel_gcrs_km_s: Velocity<GCRS>,
pub trop: TropModel,
pub iono: IonoModel,
pub frequency_hz: Hertz,
pub integer_ambiguity: i32,
}
impl Observation for GnssCarrierPhaseObs {
type Residual = PhaseResidual;
fn residual(
&self,
state: &CartesianState,
providers: &dyn ProviderBundle,
) -> Result<PhaseResidual, PodObservationsError> {
let sat_pos = self.sat_pos_gcrs_km;
let sat_vel = self.sat_vel_gcrs_km_s;
let (rho_m, _) = geometric_range_m(state, sat_pos);
let sagnac_m = sagnac_km(sat_pos, state.position) * 1_000.0;
let rel_m = relativistic_gps_clock_m(sat_pos, sat_vel);
let trop_m = match self.trop {
TropModel::None => 0.0,
TropModel::Saastamoinen { elevation_rad } => saastamoinen_m(elevation_rad),
};
let f_hz = self.frequency_hz.value();
let iono_m = match &self.iono {
IonoModel::IonoFree => 0.0,
IonoModel::None => 0.0,
IonoModel::Klobuchar(p) => {
let iono_l1 = klobuchar_m(p);
let f_l1_hz = 1_575_420_000.0_f64;
if f_hz > 0.0 && (f_hz - f_l1_hz).abs() > 1e6 {
iono_l1 * (f_l1_hz / f_hz).powi(2)
} else {
iono_l1
}
}
};
let dt_r = providers.receiver_clock_m().value();
let dt_s = providers
.gnss_satellite_clock_m(&self.prn, self.epoch)
.value();
let wavelength_m = if f_hz > 0.0 {
C_M_S / f_hz
} else {
0.1903 };
let ambiguity_m = self.integer_ambiguity as f64 * wavelength_m;
let modelled = rho_m + dt_r - dt_s + trop_m - iono_m + sagnac_m + rel_m + ambiguity_m;
let residual_m = self.measured_m.value() - modelled;
Ok(PhaseResidual {
residual_m,
cycles: residual_m / wavelength_m,
})
}
fn obs_type(&self) -> ObsType {
ObsType::GnssCarrierPhase
}
fn epoch(&self) -> JulianDate {
self.epoch
}
fn sigma(&self) -> Meter {
self.sigma
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::dynamics::{Position, Velocity};
use crate::pod::observation::provider_bundle::NullProviderBundle;
use approx::assert_abs_diff_eq;
use qtty::{Hertz, Meter};
const EPOCH: fn() -> JulianDate = || JulianDate::new(2_451_545.0);
fn leo_state() -> CartesianState {
CartesianState::new(
EPOCH().to_j2000s(),
Position::<GCRS>::new(7_000.0, 0.0, 0.0),
Velocity::<GCRS>::new(0.0, 7.5, 0.0),
)
}
fn gps_sat() -> (Position<GCRS>, Velocity<GCRS>) {
(
Position::<GCRS>::new(26_560.0, 0.0, 0.0),
Velocity::<GCRS>::new(0.0, 3.87, 0.0),
)
}
#[test]
fn pseudorange_residual_near_zero_for_consistent_obs() {
let (sat_pos, sat_vel) = gps_sat();
let state = leo_state();
let obs_probe = GnssPseudorangeObs {
prn: "G01".to_string(),
epoch: EPOCH(),
measured_m: Meter::new(0.0),
sigma: Meter::new(1.0),
sat_pos_gcrs_km: sat_pos,
sat_vel_gcrs_km_s: sat_vel,
trop: TropModel::None,
iono: IonoModel::IonoFree,
frequency_hz: Hertz::new(1_575_420_000.0),
};
let neg_residual = obs_probe.residual(&state, &NullProviderBundle).unwrap();
let modelled = -neg_residual;
let obs = GnssPseudorangeObs {
measured_m: Meter::new(modelled),
..obs_probe
};
let r = obs.residual(&state, &NullProviderBundle).unwrap();
assert_abs_diff_eq!(r, 0.0, epsilon = 1e-6);
}
#[test]
fn pseudorange_positive_trop_increases_range() {
let (sat_pos, sat_vel) = gps_sat();
let state = leo_state();
let make = |trop| GnssPseudorangeObs {
prn: "G01".to_string(),
epoch: EPOCH(),
measured_m: Meter::new(0.0),
sigma: Meter::new(1.0),
sat_pos_gcrs_km: sat_pos,
sat_vel_gcrs_km_s: sat_vel,
trop,
iono: IonoModel::IonoFree,
frequency_hz: Hertz::new(1_575_420_000.0),
};
let r_none = make(TropModel::None)
.residual(&state, &NullProviderBundle)
.unwrap();
let r_trop = make(TropModel::Saastamoinen {
elevation_rad: std::f64::consts::FRAC_PI_2,
})
.residual(&state, &NullProviderBundle)
.unwrap();
assert!(r_trop < r_none, "trop should increase modelled range");
}
#[test]
fn klobuchar_delay_positive() {
let delay = klobuchar_m(&KlobucharParams::gps_default());
assert!(delay > 0.0 && delay < 100.0, "klobuchar delay={delay:.3}m");
}
#[test]
fn saastamoinen_zenith_approx_2p4m() {
let d = saastamoinen_m(std::f64::consts::FRAC_PI_2);
assert!(d > 2.0 && d < 3.0, "zenith trop={d:.3}m");
}
#[test]
fn carrier_phase_iono_opposite_sign_to_pseudorange() {
let (sat_pos, sat_vel) = gps_sat();
let state = leo_state();
let klob = IonoModel::Klobuchar(KlobucharParams::gps_default());
let pr = GnssPseudorangeObs {
prn: "G01".to_string(),
epoch: EPOCH(),
measured_m: Meter::new(0.0),
sigma: Meter::new(1.0),
sat_pos_gcrs_km: sat_pos,
sat_vel_gcrs_km_s: sat_vel,
trop: TropModel::None,
iono: klob.clone(),
frequency_hz: Hertz::new(1_575_420_000.0),
};
let cp = GnssCarrierPhaseObs {
prn: "G01".to_string(),
epoch: EPOCH(),
measured_m: Meter::new(0.0),
sigma: Meter::new(0.003),
sat_pos_gcrs_km: sat_pos,
sat_vel_gcrs_km_s: sat_vel,
trop: TropModel::None,
iono: klob,
frequency_hz: Hertz::new(1_575_420_000.0),
integer_ambiguity: 0,
};
let r_pr = pr.residual(&state, &NullProviderBundle).unwrap();
let r_cp = cp.residual(&state, &NullProviderBundle).unwrap();
assert!(
r_cp.residual_m > r_pr,
"carrier more negative than expected"
);
}
#[test]
fn carrier_phase_residual_in_cycles_consistent() {
let (sat_pos, sat_vel) = gps_sat();
let state = leo_state();
let obs = GnssCarrierPhaseObs {
prn: "G01".to_string(),
epoch: EPOCH(),
measured_m: Meter::new(0.0),
sigma: Meter::new(0.003),
sat_pos_gcrs_km: sat_pos,
sat_vel_gcrs_km_s: sat_vel,
trop: TropModel::None,
iono: IonoModel::IonoFree,
frequency_hz: Hertz::new(1_575_420_000.0),
integer_ambiguity: 0,
};
let r = obs.residual(&state, &NullProviderBundle).unwrap();
let wavelength = C_M_S / 1_575_420_000.0;
assert_abs_diff_eq!(r.cycles, r.residual_m / wavelength, epsilon = 1e-9);
}
}