sidereon_core/spp/mod.rs
1//! Single-point positioning (SPP).
2//!
3//! Recovers a receiver ECEF position and clock bias from a set of pseudoranges,
4//! a satellite ephemeris source (a precise SP3 product or a broadcast navigation
5//! message, via the [`EphemerisSource`] trait), and broadcast ionosphere /
6//! Saastamoinen-Niell troposphere correction models. GPS L1 C/A, Galileo E1,
7//! BeiDou B1I, and GLONASS G1 are supported; GPS, BeiDou, and GLONASS use
8//! broadcast Klobuchar coefficients with carrier-frequency scaling, while
9//! Galileo can use its broadcast NeQuick-G `ai0`/`ai1`/`ai2` coefficients when
10//! supplied. GLONASS is FDMA, so its per-satellite carrier is resolved from the
11//! broadcast/observation channel number ([`SolveInputs::glonass_channels`]) and
12//! the Klobuchar L1 delay is scaled to it by `(f_L1 / f_k)^2`, matching
13//! RTKLIB-demo5, which applies no per-satellite inter-frequency bias and carries
14//! the single GLO-GPS offset on the per-system receiver clock. A satellite whose
15//! carrier cannot be resolved is rejected when the ionosphere correction is
16//! requested.
17//!
18//! The state vector is `[x_m, y_m, z_m, clk_0, clk_1, ...]`: three ECEF position
19//! components (meters) followed by one receiver clock per distinct GNSS in the
20//! solve, expressed as a length (meters). A single-system solve reduces to the
21//! classic `[x_m, y_m, z_m, b_m]`; a multi-system solve adds an inter-system
22//! bias parameter for each additional constellation. The seconds value
23//! `rx_clock_s = clk_0 / c` (the reference system) and the per-system clocks are
24//! reported only at the API boundary.
25//!
26//! The per-satellite predicted pseudorange is built in a pinned operation order:
27//! a fixed-count transmit-time iteration (receive time minus geometric range
28//! over `c`) locates the satellite ephemeris at transmission, an Earth-rotation
29//! (Sagnac) closed-form rotation brings the satellite into the receive-time
30//! frame, the geometric range and the line-of-sight azimuth/elevation follow,
31//! then the ionosphere and troposphere delays are added to the predicted range
32//! left-to-right. The residual the solver sees is `sqrt(w) * (P_meas - P_hat)`
33//! with an elevation-based weight evaluated once at the frozen initial-guess
34//! geometry.
35//!
36//! The geometric/clock/correction substrate and its 2-point finite-difference
37//! Jacobian are arithmetic over the libm-bound model functions and are a
38//! bit-exact (0-ULP) parity target against the reference recipe. The converged
39//! position is produced by the trust-region least-squares solver in the
40//! `sidereon-core` solver core, whose linear-algebra step is not bit-reproducible
41//! across BLAS builds; the converged solution is therefore a sub-micron
42//! solver-agreement result, not a 0-ULP claim.
43//!
44//! The bit-exact claim depends on the fused-multiply-add policy matching the
45//! reference exactly. The substrate uses no contracted `a*b+c` anywhere the
46//! reference computes the two roundings separately; the single deliberate
47//! exception is the 3x3-by-vector rotation primitive, which uses `mul_add` to
48//! reproduce the reference's rounding of that product. The certified target
49//! pins `target-cpu`/features so the compiler neither introduces nor drops a
50//! contraction; on a host that auto-contracts these expressions the last bit
51//! can differ and the goldens are not expected to hold.
52
53use crate::astro::angles::rad_to_deg_ref;
54use crate::astro::math::least_squares::{
55 self, singular_value_diagnostics, solve_trf_with, LeastSquaresProblem, SolveOptions, Status,
56 TrustRegionSolve,
57};
58use crate::geometry_quality::{classify, GeometryQuality, GeometryQualityThresholds};
59use nalgebra::DVector;
60use std::collections::BTreeMap;
61
62mod config;
63mod fallback;
64mod source;
65use crate::astro::math::robust::{huber_weight, mad_scale, RobustError};
66pub use config::{
67 DEFAULT_HUBER_K, DEFAULT_ROBUST_MAX_OUTER, DEFAULT_ROBUST_OUTER_TOL_M,
68 DEFAULT_ROBUST_SCALE_FLOOR_M, ELEVATION_MASK_RAD, SIGMA0_M, TRANSMIT_TIME_ITERATIONS,
69};
70pub use fallback::{
71 solve_broadcast, solve_with_fallback, BroadcastReason, FallbackError, FixSource,
72 SourcedSolution,
73};
74pub use source::EphemerisSource;
75
76pub use crate::constants::{C_M_S, F_L1_HZ, OMEGA_E_DOT_RAD_S};
77use crate::dop::{dop, dop_multi, Dop, LineOfSight};
78use crate::estimation::recipe::{
79 EstimationRecipe, FrameRecipe, RangeRecipe, SagnacRecipe, SolverRecipe,
80};
81use crate::estimation::substrate::frames::{az_el_from_ecef, geodetic_from_ecef};
82use crate::estimation::substrate::parameters::ParameterLayout;
83use crate::estimation::substrate::range::{geometric_range, rotate_transmit_satellite};
84use crate::frame::{ItrfPositionM, Wgs84Geodetic};
85use crate::frequencies;
86use crate::id::{GnssSatelliteId, GnssSystem};
87pub use crate::ionex::GalileoNequickCoeffs;
88use crate::ionex::{
89 galileo_nequick_g_native_unchecked, klobuchar_native_unchecked, GalileoNequickEval,
90 KlobucharParams,
91};
92use crate::quality::{
93 validate_receiver_solution, SolutionValidationError, SolutionValidationOptions,
94};
95use crate::sbas::SbasIonoGrid;
96use crate::tropo::slant_components;
97use crate::validate;
98
99/// The single-frequency carrier (Hz) the ionosphere correction is reported on
100/// for a constellation with one fixed single-frequency carrier, or `None` for a
101/// system that has none (GLONASS, whose FDMA carrier is per-satellite). GPS L1
102/// C/A and Galileo E1 are both at [`F_L1_HZ`]; BeiDou uses B1I. Klobuchar and
103/// Galileo broadcast delays are reported on this carrier. GLONASS is resolved
104/// per satellite by [`spp_iono_frequency_hz`] from its FDMA channel instead.
105pub(crate) const fn carrier_frequency_hz(system: GnssSystem) -> Option<f64> {
106 match system {
107 GnssSystem::Sbas => Some(F_L1_HZ),
108 _ => frequencies::default_spp_frequency_hz(system),
109 }
110}
111
112/// The carrier frequency (Hz) the broadcast ionosphere delay is scaled to for a
113/// single satellite, or `None` if the satellite's system has no carrier the
114/// model can resolve.
115///
116/// For the fixed-carrier systems (GPS L1, Galileo E1, BeiDou B1I) this is the
117/// system carrier from [`carrier_frequency_hz`]. GLONASS is FDMA, so its carrier
118/// is per-satellite: it is resolved from `glonass_channels` (the broadcast /
119/// observation FDMA channel `k` keyed by GLONASS slot number) as the G1
120/// frequency `1602.0 MHz + k * 562.5 kHz`. A GLONASS satellite whose channel is
121/// not in the map, or whose channel is outside the valid FDMA range
122/// `[-7, +6]` (the same domain the RINEX nav/obs parsers enforce via
123/// [`crate::rinex_nav::valid_glonass_frequency_channel`]), has no resolvable
124/// carrier and returns `None` -- `glonass_g1_frequency_hz` is a pure
125/// `1602.0 MHz + k * 562.5 kHz` evaluation that would otherwise return a
126/// bogus-but-positive carrier for an out-of-domain `k`. Mirroring RTKLIB-demo5,
127/// the single GLO-GPS inter-system offset is carried by the existing per-system
128/// receiver clock (see [`clock_systems`]) rather than a separate
129/// inter-frequency-bias parameter, and the only GLONASS-specific term in the
130/// measurement model is this per-satellite `(f_L1 / f_k)^2` ionosphere scaling.
131pub(crate) fn spp_iono_frequency_hz(
132 sat: GnssSatelliteId,
133 glonass_channels: &BTreeMap<u8, i8>,
134) -> Option<f64> {
135 match sat.system {
136 GnssSystem::Glonass => glonass_channels
137 .get(&sat.prn)
138 .copied()
139 .filter(|&k| crate::rinex_nav::valid_glonass_frequency_channel(i32::from(k)))
140 .map(frequencies::glonass_g1_frequency_hz),
141 _ => carrier_frequency_hz(sat.system),
142 }
143}
144use crate::constants::MEAN_EARTH_RADIUS_M;
145const PI: f64 = std::f64::consts::PI;
146
147// Agreement-track stopping thresholds for the independent SPP least-squares
148// solver. These drive the solver to the true fixed point of the noise-free,
149// by-construction-zero-residual problem so the converged position agrees with
150// the reference solution to the documented sub-micron bound; they are the
151// solver's own stopping thresholds, not a parity target's pinned scipy options.
152/// Canonical light-time convergence tolerance (s). The canonical range recipe
153/// ([`RangeRecipe::CanonicalLightTimeClosedFormSagnac`]) iterates the
154/// transmit-epoch light-time loop until the signal travel time changes by less
155/// than this between iterations, instead of the reference recipe's fixed
156/// [`TRANSMIT_TIME_ITERATIONS`] truncation. `1e-13 s` is ~30 microns of range
157/// (`tol * C_M_S`), far below the pseudorange noise floor; the loop is
158/// quadratically convergent so it reaches this in ~3 iterations.
159const CANONICAL_LIGHT_TIME_TOL_S: f64 = 1.0e-13;
160/// Iteration cap for the canonical light-time loop, a safety bound the
161/// quadratically convergent iteration never reaches in practice (it converges in
162/// ~3 iterations); present so a pathological geometry cannot spin forever.
163const CANONICAL_LIGHT_TIME_MAX_ITERS: usize = 10;
164/// First-order optimality tolerance on `||J^T r||_inf`.
165const SPP_SOLVER_GTOL: f64 = 1e-14;
166/// Relative-cost-reduction tolerance.
167const SPP_SOLVER_FTOL: f64 = 1e-15;
168/// Relative-step tolerance.
169const SPP_SOLVER_XTOL: f64 = 1e-14;
170/// Maximum number of residual evaluations.
171const SPP_SOLVER_MAX_NFEV: usize = 400;
172
173/// A single GPS L1 pseudorange observation.
174///
175/// The input boundary of the pipeline is the pseudorange; raw observation
176/// formation (RINEX decoding, code tracking) is out of scope. The receive epoch
177/// and the time-of-day / day-of-year arguments are common to all observations
178/// in one solve and are carried on [`SolveInputs`], not here.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct Observation {
181 /// The transmitting satellite.
182 pub satellite_id: GnssSatelliteId,
183 /// Measured pseudorange in meters.
184 pub pseudorange_m: f64,
185}
186
187/// Why a satellite was excluded from the solve, in pinned priority order.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum RejectionReason {
190 /// The SP3 product has no usable position or clock for the satellite at the
191 /// transmit epoch.
192 NoEphemeris,
193 /// The satellite is below the elevation mask at the frozen geometry.
194 LowElevation,
195 /// The bound augmentation source withdrew the satellite.
196 SbasWithdrawn,
197 /// The augmentation ionosphere grid does not cover the satellite line of sight.
198 SbasIonoUncovered,
199}
200
201/// A rejected satellite paired with its rejection reason.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub struct RejectedSat {
204 /// The excluded satellite.
205 pub satellite_id: GnssSatelliteId,
206 /// The first matching rejection reason.
207 pub reason: RejectionReason,
208}
209
210/// Models and convergence detail describing how a solution was produced.
211#[derive(Debug, Clone, PartialEq)]
212pub struct SolutionMetadata {
213 /// Number of accepted solver iterations.
214 pub iterations: usize,
215 /// Whether the solver reached a convergence stopping criterion (as opposed
216 /// to exhausting its evaluation budget).
217 pub converged: bool,
218 /// The solver's termination status.
219 pub status: Status,
220 /// Whether the ionosphere correction was applied.
221 pub ionosphere_applied: bool,
222 /// Whether the troposphere correction was applied.
223 pub troposphere_applied: bool,
224 /// Number of outer robust-reweighting iterations performed. `0` on the
225 /// static path (`robust = None`); on the robust path this counts the
226 /// reweighted resolves beyond the warm-start solve.
227 pub outer_iterations: usize,
228 /// The final MAD robust scale (m) of the last outer iteration, or `None` on
229 /// the static path.
230 pub final_robust_scale_m: Option<f64>,
231 /// Number of satellites used in the final solve.
232 pub used_count: usize,
233 /// Distinct GNSS systems present in the final solve, in ascending order.
234 pub systems: Vec<GnssSystem>,
235 /// Degrees of freedom, `used_count - (3 + systems.len())`.
236 pub redundancy: isize,
237 /// Whether residual-based RAIM can test the final solve (`redundancy >= 1`).
238 pub raim_checkable: bool,
239}
240
241/// A receiver position/clock solution with its geometry diagnostics.
242#[derive(Debug, Clone)]
243pub struct ReceiverSolution {
244 /// Converged receiver position, ITRF/IGS ECEF meters.
245 pub position: ItrfPositionM,
246 /// The geodetic form of the position, if the conversion was requested.
247 pub geodetic: Option<Wgs84Geodetic>,
248 /// Receiver clock bias in seconds (`clk_0 / c`) for the reference GNSS - the
249 /// first entry of `system_clocks_s`. For a single-system solve this is the
250 /// only clock; for a multi-system solve the other systems' absolute clocks
251 /// are in `system_clocks_s`.
252 pub rx_clock_s: f64,
253 /// The absolute receiver clock for each GNSS in the solve, in ascending
254 /// system order, in seconds. One entry for a single-system solve; one per
255 /// constellation for a multi-system solve. The first entry equals
256 /// `rx_clock_s`; the inter-system bias for any other system is *its clock
257 /// minus that reference* (these are absolute per-system clocks, not biases).
258 pub system_clocks_s: Vec<(GnssSystem, f64)>,
259 /// Dilution-of-precision scalars from the converged geometry. A
260 /// single-system solve uses the 0-ULP four-state cofactor; a multi-system
261 /// solve uses the general inverse with one clock column per constellation (a
262 /// deterministic diagnostic, not a 0-ULP target). `None` only if the
263 /// converged geometry is rank-deficient.
264 pub dop: Option<Dop>,
265 /// Per-constellation time (clock) DOP, one entry per GNSS in the solve, in
266 /// the same ascending system order as `system_clocks_s`: the square root of
267 /// that system's clock cofactor variance. The first entry's value equals
268 /// `dop.tdop` (the reference clock). One entry for a single-system solve.
269 /// Empty only when `dop` is `None` (rank-deficient geometry).
270 ///
271 /// This is exactly `dop.system_tdops`: the geometry layer reports the
272 /// per-system TDOPs already GNSS-tagged in [`Dop::system_tdops`], so this is
273 /// a direct copy and needs no re-tagging.
274 pub system_tdops: Vec<(GnssSystem, f64)>,
275 /// Post-fit residuals in meters, in `used_sats` order (unweighted
276 /// `P_meas - P_hat`).
277 pub residuals_m: Vec<f64>,
278 /// The satellites that contributed to the solve, ascending id order.
279 pub used_sats: Vec<GnssSatelliteId>,
280 /// The excluded satellites, each with its reason.
281 pub rejected_sats: Vec<RejectedSat>,
282 /// Geometry observability and covariance-validation diagnostics for the
283 /// converged design. Snapshot SPP has no propagated prior, so
284 /// `ZeroRedundancy` marks unvalidated covariance bounds, `Weak` leaves large
285 /// bounds unclamped, and `RankDeficient` is routed through [`SppError::Singular`]
286 /// instead of returning a solution.
287 pub geometry_quality: GeometryQuality,
288 /// Iteration / convergence / model metadata.
289 pub metadata: SolutionMetadata,
290}
291
292impl ReceiverSolution {
293 /// Root-mean-square of the post-fit pseudorange residuals over the used satellites (0.0 when empty).
294 pub fn residual_rms_m(&self) -> f64 {
295 residual_rms(&self.residuals_m)
296 }
297}
298
299/// Which correction terms a solve applies, building up incrementally.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct Corrections {
302 /// Apply the Klobuchar L1 ionosphere delay.
303 pub ionosphere: bool,
304 /// Apply the Saastamoinen/Niell troposphere delay.
305 pub troposphere: bool,
306}
307
308impl Corrections {
309 /// No atmospheric corrections (geometry + clock + Sagnac only).
310 pub const NONE: Self = Self {
311 ionosphere: false,
312 troposphere: false,
313 };
314 /// Ionosphere only.
315 pub const IONO: Self = Self {
316 ionosphere: true,
317 troposphere: false,
318 };
319 /// Ionosphere and troposphere.
320 pub const IONO_TROPO: Self = Self {
321 ionosphere: true,
322 troposphere: true,
323 };
324}
325
326/// Broadcast Klobuchar coefficients for the ionosphere term.
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct KlobucharCoeffs {
329 /// Cosine-amplitude polynomial coefficients (a0..a3).
330 pub alpha: [f64; 4],
331 /// Period polynomial coefficients (b0..b3).
332 pub beta: [f64; 4],
333}
334
335/// Surface meteorology for the troposphere term.
336#[derive(Debug, Clone, Copy, PartialEq)]
337pub struct SurfaceMet {
338 /// Total pressure (hPa).
339 pub pressure_hpa: f64,
340 /// Temperature (K).
341 pub temperature_k: f64,
342 /// Relative humidity, fraction in `[0, 1]`.
343 pub relative_humidity: f64,
344}
345
346impl Default for SurfaceMet {
347 /// Standard atmosphere: 1013.25 hPa, 288.15 K, 0.5 relative humidity.
348 fn default() -> Self {
349 Self {
350 pressure_hpa: 1013.25,
351 temperature_k: 288.15,
352 relative_humidity: 0.5,
353 }
354 }
355}
356
357/// Opt-in Huber/IRLS robust-reweighting configuration.
358///
359/// When a [`SolveInputs::robust`] is `Some(_)`, the solve runs an outer
360/// iteratively-reweighted least-squares loop on top of the static elevation
361/// weighting: a warm-start solve at the base elevation weights (bit-identical to
362/// the static path), then re-solves that rebuild the weight vector each outer
363/// iteration as `base_elevation_weight * huber(r_i / s)`, where `r_i` is the
364/// current unweighted post-fit residual and `s` is a floored MAD scale. With
365/// `robust = None` the solve is byte-identical to the static elevation-weighted
366/// solve. `Default` matches the `DEFAULT_*` config constants.
367#[derive(Debug, Clone, Copy, PartialEq)]
368pub struct RobustConfig {
369 /// Huber tuning constant `k`; residuals scaled below this keep full weight.
370 pub huber_k: f64,
371 /// Floor (m) on the MAD scale, preventing a near-perfect fit from
372 /// down-weighting every satellite.
373 pub scale_floor_m: f64,
374 /// Maximum total outer solves (the warm start plus reweighted resolves).
375 pub max_outer: usize,
376 /// Outer-loop position L2 step tolerance (m).
377 pub outer_tol_m: f64,
378}
379
380impl Default for RobustConfig {
381 fn default() -> Self {
382 Self {
383 huber_k: DEFAULT_HUBER_K,
384 scale_floor_m: DEFAULT_ROBUST_SCALE_FLOOR_M,
385 max_outer: DEFAULT_ROBUST_MAX_OUTER,
386 outer_tol_m: DEFAULT_ROBUST_OUTER_TOL_M,
387 }
388 }
389}
390
391/// Everything one SPP solve needs besides the SP3 product itself.
392///
393/// The receive epoch is carried as seconds-since-J2000 (`t_rx_j2000_s`), the
394/// argument the transmit-time iteration differences against the geometric range
395/// to land the satellite ephemeris at transmission, with no Julian-date
396/// round-trip inside the loop. The Klobuchar diurnal argument
397/// (`t_rx_second_of_day_s`) and the Niell seasonal argument (`day_of_year`) are
398/// supplied directly so the correction kernels run in their bit-exact native
399/// units.
400#[derive(Debug, Clone)]
401pub struct SolveInputs {
402 /// The pseudorange observations (any order; the solve sorts them).
403 pub observations: Vec<Observation>,
404 /// Receive epoch, seconds since J2000 in the SP3 product's time scale.
405 pub t_rx_j2000_s: f64,
406 /// GPS second-of-day of the receive epoch (Klobuchar diurnal argument).
407 pub t_rx_second_of_day_s: f64,
408 /// Fractional day-of-year of the receive epoch (Niell seasonal argument).
409 pub day_of_year: f64,
410 /// Initial guess `[x_m, y_m, z_m, b_m]`.
411 pub initial_guess: [f64; 4],
412 /// The correction terms to apply.
413 pub corrections: Corrections,
414 /// Broadcast Klobuchar coefficients (used iff `corrections.ionosphere`).
415 /// Applied to every system unless `beidou_klobuchar` overrides BeiDou.
416 pub klobuchar: KlobucharCoeffs,
417 /// Optional BeiDou-specific Klobuchar coefficients (the broadcast `BDSA`/
418 /// `BDSB` set). When present, BeiDou satellites use these instead of
419 /// [`klobuchar`](Self::klobuchar); both feed the same model, frequency-scaled
420 /// to B1I. `None` falls back to `klobuchar` for BeiDou too.
421 pub beidou_klobuchar: Option<KlobucharCoeffs>,
422 /// Optional Galileo-specific NeQuick-G coefficients (the broadcast `GAL`
423 /// `ai0`/`ai1`/`ai2` set). When present, Galileo satellites use these instead
424 /// of the GPS Klobuchar coefficients. `None` preserves the historical
425 /// Klobuchar fallback so existing zero-Galileo goldens stay bit-identical.
426 pub galileo_nequick: Option<GalileoNequickCoeffs>,
427 /// Optional augmentation ionosphere grid.
428 pub sbas_iono: Option<SbasIonoGrid>,
429 /// GLONASS FDMA channel numbers keyed by GLONASS slot (PRN), from the
430 /// broadcast nav `freq_channel` field or the observation header's
431 /// `GLONASS SLOT / FRQ #` records. Used only to resolve the per-satellite
432 /// GLONASS carrier for the ionosphere `(f_L1 / f_k)^2` scaling; an empty map
433 /// is correct for any solve with no GLONASS observation and leaves every
434 /// other constellation bit-identical. A GLONASS observation with the
435 /// ionosphere correction requested but no channel here is rejected with
436 /// [`SppError::IonosphereUnsupported`].
437 pub glonass_channels: BTreeMap<u8, i8>,
438 /// Surface meteorology (used iff `corrections.troposphere`).
439 pub met: SurfaceMet,
440 /// Opt-in Huber/IRLS robust reweighting. `None` (the default behavior)
441 /// runs the static elevation-weighted solve byte-identically; `Some(_)`
442 /// adds the outer reweighting loop described on [`RobustConfig`].
443 pub robust: Option<RobustConfig>,
444}
445
446impl Default for SolveInputs {
447 fn default() -> Self {
448 Self {
449 observations: Vec::new(),
450 t_rx_j2000_s: 0.0,
451 t_rx_second_of_day_s: 0.0,
452 day_of_year: 1.0,
453 initial_guess: [0.0; 4],
454 corrections: Corrections::NONE,
455 klobuchar: KlobucharCoeffs {
456 alpha: [0.0; 4],
457 beta: [0.0; 4],
458 },
459 beidou_klobuchar: None,
460 galileo_nequick: None,
461 sbas_iono: None,
462 glonass_channels: BTreeMap::new(),
463 met: SurfaceMet::default(),
464 robust: None,
465 }
466 }
467}
468
469/// Input-validation failure category for SPP public entry points.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum SppInputErrorKind {
472 /// A floating-point input was NaN or infinite.
473 NonFinite,
474 /// A positive physical input was zero or negative.
475 NotPositive,
476 /// A non-negative physical input was negative.
477 Negative,
478 /// A finite numeric input was outside its accepted range.
479 OutOfRange,
480 /// A required input field was absent.
481 Missing,
482 /// A text field could not be parsed as a float.
483 FloatParse,
484 /// A text field could not be parsed as an integer.
485 IntParse,
486 /// A civil date field was out of range.
487 InvalidCivilDate,
488 /// A civil time field was out of range.
489 InvalidCivilTime,
490}
491
492impl core::fmt::Display for SppInputErrorKind {
493 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
494 let label = match self {
495 Self::NonFinite => "not finite",
496 Self::NotPositive => "not positive",
497 Self::Negative => "negative",
498 Self::OutOfRange => "out of range",
499 Self::Missing => "missing",
500 Self::FloatParse => "invalid float",
501 Self::IntParse => "invalid integer",
502 Self::InvalidCivilDate => "invalid civil date",
503 Self::InvalidCivilTime => "invalid civil time",
504 };
505 f.write_str(label)
506 }
507}
508
509impl From<&validate::FieldError> for SppInputErrorKind {
510 fn from(error: &validate::FieldError) -> Self {
511 match error {
512 validate::FieldError::Missing { .. } => Self::Missing,
513 validate::FieldError::NonFinite { .. } => Self::NonFinite,
514 validate::FieldError::NotPositive { .. } => Self::NotPositive,
515 validate::FieldError::Negative { .. } => Self::Negative,
516 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
517 validate::FieldError::FloatParse { .. } => Self::FloatParse,
518 validate::FieldError::IntParse { .. } => Self::IntParse,
519 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
520 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
521 }
522 }
523}
524
525/// Error from [`solve`].
526#[derive(Debug, Clone)]
527pub enum SppError {
528 /// A public SPP input was malformed, non-finite, or outside its physical
529 /// domain. Boundary validation rejects this before satellite selection or
530 /// least-squares evaluation.
531 InvalidInput {
532 /// The invalid input field.
533 field: &'static str,
534 /// The validation failure category.
535 kind: SppInputErrorKind,
536 },
537 /// Fewer usable satellites survived rejection than the solve has parameters
538 /// (`3 + n_systems`: three position components plus one receiver clock per
539 /// GNSS), so the solve is underdetermined.
540 TooFewSatellites {
541 /// The number of satellites that survived rejection.
542 used: usize,
543 /// The number of satellites required (`3 + n_systems`).
544 required: usize,
545 },
546 /// The trust-region step hit a rank-deficient Jacobian (degenerate geometry).
547 Singular(least_squares::SolveError),
548 /// The same satellite appears in more than one observation. One pseudorange
549 /// per satellite is required, so the input is rejected rather than silently
550 /// picking one (which would make the result depend on observation order).
551 DuplicateObservation {
552 /// The satellite that was observed more than once.
553 satellite: GnssSatelliteId,
554 },
555 /// A satellite that survived the frozen selection had no usable SP3
556 /// position/clock at a transmit epoch reached during the solve. Returned
557 /// instead of panicking; normally precluded by the selection step.
558 EphemerisLost {
559 /// The satellite whose ephemeris became unavailable during the solve.
560 satellite: GnssSatelliteId,
561 },
562 /// The ionosphere correction was requested but an observed satellite has no
563 /// resolvable carrier frequency, so the L1 Klobuchar delay cannot be scaled
564 /// to it. GPS L1, Galileo E1, and BeiDou B1I have fixed carriers; a GLONASS
565 /// satellite resolves its per-satellite FDMA carrier from
566 /// [`SolveInputs::glonass_channels`], so a GLONASS observation whose channel
567 /// is absent from that map -- or present but outside the valid FDMA range
568 /// `[-7, +6]` -- (rather than GLONASS as a whole) is rejected here rather
569 /// than corrected with an undefined or out-of-domain frequency.
570 IonosphereUnsupported {
571 /// The satellite the ionosphere model does not cover.
572 satellite: GnssSatelliteId,
573 },
574}
575
576impl core::fmt::Display for SppError {
577 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
578 match self {
579 SppError::InvalidInput { field, kind } => {
580 write!(f, "invalid SPP input {field}: {kind}")
581 }
582 SppError::TooFewSatellites { used, required } => write!(
583 f,
584 "only {used} usable satellites; need at least {required} \
585 (3 position + 1 clock per GNSS)"
586 ),
587 SppError::Singular(e) => write!(f, "degenerate geometry: {e}"),
588 SppError::DuplicateObservation { satellite } => {
589 write!(f, "satellite {satellite} observed more than once")
590 }
591 SppError::EphemerisLost { satellite } => {
592 write!(f, "satellite {satellite} lost ephemeris during the solve")
593 }
594 SppError::IonosphereUnsupported { satellite } => write!(
595 f,
596 "ionosphere correction has no modeled carrier frequency for {satellite}"
597 ),
598 }
599 }
600}
601
602impl std::error::Error for SppError {
603 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
604 match self {
605 SppError::Singular(error) => Some(error),
606 _ => None,
607 }
608 }
609}
610
611impl From<least_squares::SolveError> for SppError {
612 fn from(e: least_squares::SolveError) -> Self {
613 SppError::Singular(e)
614 }
615}
616
617/// Language-independent SPP solve policy used by the public API boundary.
618#[derive(Debug, Clone, Copy, Default, PartialEq)]
619pub struct SolvePolicy {
620 /// Business-level solution validation gates.
621 pub validation: SolutionValidationOptions,
622 /// Optional count of near-surface golden-spiral seeds for cold starts.
623 pub coarse_search_seeds: Option<usize>,
624}
625
626/// Error from [`solve_with_policy`].
627#[derive(Debug, Clone)]
628pub enum SolvePolicyError {
629 /// The underlying SPP solver failed.
630 Solve(SppError),
631 /// The solved receiver state failed a business-level validation gate.
632 Validation(SolutionValidationError),
633 /// Coarse search found no converged redundant candidate.
634 NoCoarseSolution,
635}
636
637impl From<SppError> for SolvePolicyError {
638 fn from(error: SppError) -> Self {
639 Self::Solve(error)
640 }
641}
642
643impl From<SolutionValidationError> for SolvePolicyError {
644 fn from(error: SolutionValidationError) -> Self {
645 Self::Validation(error)
646 }
647}
648
649impl core::fmt::Display for SolvePolicyError {
650 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
651 match self {
652 Self::Solve(error) => write!(f, "SPP solve failed: {error}"),
653 Self::Validation(error) => write!(f, "SPP validation failed: {error}"),
654 Self::NoCoarseSolution => write!(f, "coarse search found no converged SPP solution"),
655 }
656 }
657}
658
659impl std::error::Error for SolvePolicyError {
660 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
661 match self {
662 Self::Solve(error) => Some(error),
663 Self::Validation(error) => Some(error),
664 Self::NoCoarseSolution => None,
665 }
666 }
667}
668
669/// The SPP measurement-model operation-order selections, resolved from a
670/// strategy's [`EstimationRecipe`]: the transmit-time light-time range recipe,
671/// the Sagnac rotation recipe, and the receiver-frame (geodetic / az-el) recipe.
672///
673/// Threading these into [`sat_model`] is what makes SPP consume its
674/// `recipe.range` / `recipe.sagnac` / `recipe.frame` rather than hard-coding a
675/// single op-order. [`Self::reference`] is the SPP Skyfield reference selection,
676/// so the legacy entry points reproduce the current behavior bit-for-bit.
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678pub(crate) struct SppModelRecipe {
679 pub range: RangeRecipe,
680 pub sagnac: SagnacRecipe,
681 pub frame: FrameRecipe,
682}
683
684impl SppModelRecipe {
685 /// The model selections carried by `recipe` (its range/sagnac/frame stages).
686 pub(crate) const fn from_recipe(recipe: &EstimationRecipe) -> Self {
687 Self {
688 range: recipe.range,
689 sagnac: recipe.sagnac,
690 frame: recipe.frame,
691 }
692 }
693
694 /// The SPP Skyfield reference model selections (the
695 /// [`EstimationRecipe::spp`] range/sagnac/frame stages).
696 pub(crate) const fn reference() -> Self {
697 Self::from_recipe(&EstimationRecipe::spp())
698 }
699}
700
701/// Per-satellite model used by the solve path: the Sagnac-rotated satellite
702/// position, the topocentric az/el, and the predicted pseudorange.
703///
704/// In test builds the struct additionally carries the named intermediate
705/// quantities (transmit time, satellite ECEF, Sagnac angle, geometric range,
706/// ionosphere, troposphere) so the 0-ULP trace-replay parity test can assert
707/// each one bit-for-bit against the reference recipe; the solve path never
708/// reads them, so they are gated out of production builds.
709#[derive(Debug, Clone, Copy)]
710pub(crate) struct SatModel {
711 pub sat_rot_ecef_m: [f64; 3],
712 pub el_rad: f64,
713 pub p_hat_m: f64,
714 #[cfg(all(test, sidereon_repo_tests))]
715 pub az_rad: f64,
716 #[cfg(all(test, sidereon_repo_tests))]
717 pub tau_s: f64,
718 #[cfg(all(test, sidereon_repo_tests))]
719 pub t_tx_j2000_s: f64,
720 #[cfg(all(test, sidereon_repo_tests))]
721 pub sat_ecef_m: [f64; 3],
722 #[cfg(all(test, sidereon_repo_tests))]
723 pub dt_sat_s: f64,
724 #[cfg(all(test, sidereon_repo_tests))]
725 pub theta_rad: f64,
726 #[cfg(all(test, sidereon_repo_tests))]
727 pub rho_m: f64,
728 #[cfg(all(test, sidereon_repo_tests))]
729 pub iono_m: f64,
730 #[cfg(all(test, sidereon_repo_tests))]
731 pub tropo_m: f64,
732}
733
734/// The broadcast ionosphere correction a satellite's system uses.
735#[derive(Debug, Clone, Copy, PartialEq)]
736pub(crate) enum SppIonosphere<'a> {
737 /// GPS/BeiDou Klobuchar alpha/beta model.
738 Klobuchar(KlobucharCoeffs),
739 /// Galileo NeQuick-G effective-ionisation coefficients.
740 GalileoNequick(GalileoNequickCoeffs),
741 /// Augmentation grid delay model.
742 SbasGrid(&'a SbasIonoGrid),
743}
744
745/// The ionosphere coefficients a satellite's system uses: Galileo prefers its
746/// `galileo_nequick` (`GAL`) set when present; BeiDou prefers its
747/// `beidou_klobuchar` (`BDSA`/`BDSB`) set when present; all missing
748/// constellation-specific sets fall back to the shared GPS Klobuchar values to
749/// preserve existing callers.
750fn ionosphere_for<'a>(system: GnssSystem, inputs: &'a SolveInputs) -> SppIonosphere<'a> {
751 if let Some(grid) = inputs
752 .sbas_iono
753 .as_ref()
754 .filter(|_| inputs.corrections.ionosphere)
755 {
756 return SppIonosphere::SbasGrid(grid);
757 }
758 match (system, inputs.galileo_nequick, inputs.beidou_klobuchar) {
759 (GnssSystem::Galileo, Some(gal), _) => SppIonosphere::GalileoNequick(gal),
760 (GnssSystem::BeiDou, _, Some(bds)) => SppIonosphere::Klobuchar(bds),
761 _ => SppIonosphere::Klobuchar(inputs.klobuchar),
762 }
763}
764
765/// Per-epoch inputs shared by every satellite's [`sat_model`] evaluation in a
766/// solve: the ephemeris source plus the epoch and correction arguments that do
767/// not vary between satellites. Bundling them lets [`sat_model`] take only the
768/// per-satellite arguments (id, receiver state, measurement, system Klobuchar)
769/// instead of a long positional parameter list.
770pub(crate) struct SatModelEnv<'a> {
771 pub eph: &'a dyn EphemerisSource,
772 /// Receive epoch, seconds since J2000 in the SP3 product's time scale.
773 pub t_rx_j2000_s: f64,
774 /// GPS second-of-day of the receive epoch (Klobuchar diurnal argument).
775 pub t_rx_second_of_day_s: f64,
776 /// Fractional day-of-year of the receive epoch (Niell seasonal argument).
777 pub day_of_year: f64,
778 /// The correction terms to apply.
779 pub corrections: Corrections,
780 /// Surface meteorology (used iff `corrections.troposphere`).
781 pub met: &'a SurfaceMet,
782 /// GLONASS FDMA channel numbers keyed by slot (PRN), used to resolve the
783 /// per-satellite GLONASS carrier for the ionosphere scaling.
784 pub glonass_channels: &'a BTreeMap<u8, i8>,
785 /// The range/sagnac/frame operation-order selections [`sat_model`] consumes,
786 /// resolved from the strategy's recipe.
787 pub model: SppModelRecipe,
788}
789
790/// Build the per-satellite predicted pseudorange in the SPP operation order
791/// SELECTED BY THE RECIPE on [`SatModelEnv::model`], sharing the
792/// parity-sensitive range and frame substrate with the other strategies.
793///
794/// The three model stages are read from the recipe rather than hard-coded:
795/// - **range** (`env.model.range`): the transmit-time light-time iteration.
796/// [`RangeRecipe::SppMeasuredPseudorangeFixedIter`] (the SPP reference) seeds
797/// `tau` from the measured pseudorange and runs a fixed iteration count (no
798/// convergence test). [`RangeRecipe::CanonicalLightTimeClosedFormSagnac`] (the
799/// canonical strategy) seeds the same way but iterates the light-time loop to
800/// convergence (the IERS-rigorous op-order). These are the two light-time
801/// recipes the SPP measurement model implements; the observable
802/// rounded-microsecond and RTK provided-transmit recipes are other strategies'
803/// range models and never reach here.
804/// - **sagnac** (`env.model.sagnac`): the closed-form Sagnac Z-rotation and the
805/// pre/post-rotation geometric range route through
806/// [`crate::estimation::substrate::range`] under the selected recipe.
807/// - **frame** (`env.model.frame`): the receiver geodetic conversion and the
808/// geodetic ENU azimuth/elevation route through
809/// [`crate::estimation::substrate::frames`] under the selected recipe (the SPP
810/// reference selects [`FrameRecipe::SppSkyfieldAuThreeIter`], the Skyfield AU
811/// three-iteration solve).
812///
813/// The raw residual ([`residual_unweighted`], `P_meas - P_hat`) the trust-region
814/// finite-difference solver differences carries no design rows of its own; the
815/// substrate [`crate::estimation::substrate::rows`] `ResidualRow` assembly serves
816/// the RTK/PPP normal-equation stacks.
817///
818/// Returns `None` if the ephemeris source has no usable position/clock for the
819/// satellite at the transmit epoch.
820pub(crate) fn sat_model(
821 env: &SatModelEnv,
822 sat: GnssSatelliteId,
823 rx_ecef_m: [f64; 3],
824 b_m: f64,
825 p_meas_m: f64,
826 ionosphere: SppIonosphere<'_>,
827) -> Option<SatModel> {
828 let sagnac = env.model.sagnac;
829 let frame = env.model.frame;
830
831 // Transmit-time light-time iteration, selected by the range recipe.
832 let (sat_pos, dt_sat, tau) = match env.model.range {
833 RangeRecipe::SppMeasuredPseudorangeFixedIter => {
834 // Fixed iteration count, no inner convergence test; seed tau from the
835 // measured pseudorange.
836 let mut tau = p_meas_m / C_M_S;
837 let mut t_tx = env.t_rx_j2000_s - tau;
838 let mut sat_pos = [0.0f64; 3];
839 let mut dt_sat = 0.0f64;
840 for _ in 0..TRANSMIT_TIME_ITERATIONS {
841 let (pos, clk) = env.eph.position_clock_at_j2000_s(sat, t_tx)?;
842 sat_pos = pos;
843 dt_sat = clk;
844 // Pre-rotation geometric range through the shared substrate (the
845 // closed-form recipe = plain `norm3(sub3(sat, recv))`).
846 let rho0 = geometric_range(sagnac, sat_pos, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
847 tau = rho0 / C_M_S;
848 t_tx = env.t_rx_j2000_s - tau;
849 }
850 (sat_pos, dt_sat, tau)
851 }
852 RangeRecipe::CanonicalLightTimeClosedFormSagnac => {
853 // Full iterative light-time (the IERS-rigorous op-order): iterate the
854 // transmit epoch until the signal travel time stops changing, rather
855 // than the reference recipe's fixed two-iteration truncation. Seeded,
856 // like the reference, from the measured pseudorange; the iteration
857 // converges to the geometric light-time fixed point
858 // `t_tx = t_rx - rho(t_tx)/c` with the closed-form Sagnac range (never
859 // a first-order scalar Sagnac). The satellite clock `dt_sat` returned
860 // by the ephemeris already carries the relativistic periodic term
861 // (the broadcast Keplerian evaluation applies `F*e*sqrt(A)*sin(E)`;
862 // SP3 precise clocks include it, the SPP L3 no-op), so the canonical
863 // relativistically-correct range consumes it directly with no
864 // double-counting term.
865 let mut tau = p_meas_m / C_M_S;
866 let mut t_tx = env.t_rx_j2000_s - tau;
867 let mut sat_pos = [0.0f64; 3];
868 let mut dt_sat = 0.0f64;
869 let mut prev_tau = f64::INFINITY;
870 for _ in 0..CANONICAL_LIGHT_TIME_MAX_ITERS {
871 let (pos, clk) = env.eph.position_clock_at_j2000_s(sat, t_tx)?;
872 sat_pos = pos;
873 dt_sat = clk;
874 let rho0 = geometric_range(sagnac, sat_pos, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
875 tau = rho0 / C_M_S;
876 t_tx = env.t_rx_j2000_s - tau;
877 if (tau - prev_tau).abs() <= CANONICAL_LIGHT_TIME_TOL_S {
878 break;
879 }
880 prev_tau = tau;
881 }
882 (sat_pos, dt_sat, tau)
883 }
884 RangeRecipe::ObservableRoundedMicrosecondFixedIter
885 | RangeRecipe::RtkProvidedTxFirstOrderSagnac => unreachable!(
886 "the SPP measurement model runs only the measured-pseudorange or canonical light-time recipe"
887 ),
888 };
889
890 // Sagnac / Earth-rotation rotation over the flight time, selected by recipe.
891 let sat_rot = rotate_transmit_satellite(sagnac, sat_pos, tau, OMEGA_E_DOT_RAD_S);
892
893 // Geometric range (post-Sagnac) through the shared substrate.
894 let rho = geometric_range(sagnac, sat_rot, rx_ecef_m, OMEGA_E_DOT_RAD_S, C_M_S);
895
896 // Geometry for corrections: az/el from rx and the Sagnac-rotated satellite,
897 // through the recipe-selected frame substrate.
898 let g = az_el_from_ecef(frame, rx_ecef_m, sat_rot);
899
900 let mut iono_m = 0.0;
901 let mut tropo_m = 0.0;
902 if env.corrections.ionosphere {
903 // The SPP 0-ULP trace oracle pins this multiply-then-divide order, which
904 // `rad_to_deg_ref` implements (`rad * 180 / PI`).
905 let lat_deg = rad_to_deg_ref(g.geodetic.lat_rad);
906 let lon_deg = rad_to_deg_ref(g.geodetic.lon_rad);
907 let az_deg = rad_to_deg_ref(g.az_rad);
908 let el_deg = rad_to_deg_ref(g.el_rad);
909 // A used satellite always has a resolvable carrier here (the solve
910 // rejects an ionosphere request for any satellite that does not, GLONASS
911 // included via its FDMA channel), so the fallback is unreachable. The
912 // GLONASS per-satellite carrier makes the Klobuchar delay scale by
913 // `(f_L1 / f_k)^2` inside the kernel, exactly as RTKLIB-demo5 does.
914 let freq_hz = spp_iono_frequency_hz(sat, env.glonass_channels).unwrap_or(F_L1_HZ);
915 iono_m = match ionosphere {
916 SppIonosphere::Klobuchar(klobuchar) => klobuchar_native_unchecked(
917 &KlobucharParams {
918 alpha: klobuchar.alpha,
919 beta: klobuchar.beta,
920 },
921 lat_deg,
922 lon_deg,
923 az_deg,
924 el_deg,
925 env.t_rx_second_of_day_s,
926 freq_hz,
927 ),
928 SppIonosphere::GalileoNequick(coeffs) => galileo_nequick_g_native_unchecked(
929 &coeffs,
930 GalileoNequickEval {
931 lat_deg,
932 lon_deg,
933 el_deg,
934 t_gal_s: env.t_rx_second_of_day_s,
935 day_of_year: env.day_of_year,
936 frequency_hz: freq_hz,
937 },
938 ),
939 SppIonosphere::SbasGrid(grid) => {
940 grid.slant_delay_m(g.geodetic, g.el_rad, g.az_rad, freq_hz)?
941 }
942 };
943 }
944 if env.corrections.troposphere {
945 tropo_m = slant_components(
946 g.el_rad,
947 g.geodetic,
948 env.met.pressure_hpa,
949 env.met.temperature_k,
950 env.met.relative_humidity,
951 env.day_of_year,
952 )
953 .slant_m;
954 }
955
956 // Predicted pseudorange, left-to-right; c*dt_sat is a single multiply.
957 let p_hat = rho + b_m - C_M_S * dt_sat + iono_m + tropo_m;
958
959 Some(SatModel {
960 sat_rot_ecef_m: sat_rot,
961 el_rad: g.el_rad,
962 p_hat_m: p_hat,
963 #[cfg(all(test, sidereon_repo_tests))]
964 az_rad: g.az_rad,
965 #[cfg(all(test, sidereon_repo_tests))]
966 tau_s: tau,
967 // Bit-identical to the loop's final `t_tx = t_rx - tau` (same operands).
968 #[cfg(all(test, sidereon_repo_tests))]
969 t_tx_j2000_s: env.t_rx_j2000_s - tau,
970 #[cfg(all(test, sidereon_repo_tests))]
971 sat_ecef_m: sat_pos,
972 #[cfg(all(test, sidereon_repo_tests))]
973 dt_sat_s: dt_sat,
974 #[cfg(all(test, sidereon_repo_tests))]
975 theta_rad: OMEGA_E_DOT_RAD_S * tau,
976 #[cfg(all(test, sidereon_repo_tests))]
977 rho_m: rho,
978 #[cfg(all(test, sidereon_repo_tests))]
979 iono_m,
980 #[cfg(all(test, sidereon_repo_tests))]
981 tropo_m,
982 })
983}
984
985/// The frozen-geometry selection: used satellites (ascending id), rejected
986/// satellites with reason, and the per-used-sat weight from the elevation at
987/// the initial-guess geometry.
988pub(crate) struct Selection {
989 pub used: Vec<GnssSatelliteId>,
990 pub rejected: Vec<RejectedSat>,
991 /// `weight` per used satellite, index-aligned to `used`.
992 pub weights: Vec<f64>,
993}
994
995pub(crate) fn select_sats(
996 eph: &dyn EphemerisSource,
997 inputs: &SolveInputs,
998 model: SppModelRecipe,
999) -> Selection {
1000 let rx0 = [
1001 inputs.initial_guess[0],
1002 inputs.initial_guess[1],
1003 inputs.initial_guess[2],
1004 ];
1005 let b0 = inputs.initial_guess[3];
1006
1007 // Ascending satellite-id order, never observation order.
1008 let mut obs: Vec<&Observation> = inputs.observations.iter().collect();
1009 obs.sort_by_key(|o| o.satellite_id);
1010
1011 let mut used = Vec::new();
1012 let mut rejected = Vec::new();
1013 let mut weights = Vec::new();
1014
1015 let env = SatModelEnv {
1016 eph,
1017 t_rx_j2000_s: inputs.t_rx_j2000_s,
1018 t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
1019 day_of_year: inputs.day_of_year,
1020 corrections: inputs.corrections,
1021 met: &inputs.met,
1022 glonass_channels: &inputs.glonass_channels,
1023 model,
1024 };
1025 for ob in obs {
1026 let ionosphere = ionosphere_for(ob.satellite_id.system, inputs);
1027 let uses_sbas_grid = matches!(ionosphere, SppIonosphere::SbasGrid(_));
1028 let model = sat_model(&env, ob.satellite_id, rx0, b0, ob.pseudorange_m, ionosphere);
1029 let Some(model) = model else {
1030 rejected.push(RejectedSat {
1031 satellite_id: ob.satellite_id,
1032 reason: if uses_sbas_grid {
1033 RejectionReason::SbasIonoUncovered
1034 } else {
1035 RejectionReason::NoEphemeris
1036 },
1037 });
1038 continue;
1039 };
1040 if model.el_rad < ELEVATION_MASK_RAD {
1041 rejected.push(RejectedSat {
1042 satellite_id: ob.satellite_id,
1043 reason: RejectionReason::LowElevation,
1044 });
1045 continue;
1046 }
1047 let sin_el = model.el_rad.sin();
1048 let weight = (sin_el * sin_el) / (SIGMA0_M * SIGMA0_M);
1049 used.push(ob.satellite_id);
1050 weights.push(weight);
1051 }
1052
1053 Selection {
1054 used,
1055 rejected,
1056 weights,
1057 }
1058}
1059
1060/// The distinct GNSS present in `used`, in ascending system order.
1061///
1062/// The receiver-clock part of the state has one entry per system, each the
1063/// *absolute* receiver clock for that system (not a bias); the first is the
1064/// reference clock and a system's inter-system bias is its clock minus that
1065/// reference. For a single-system solve this is one element and the state is the
1066/// classic `[x, y, z, b]`.
1067pub(crate) fn clock_systems(used: &[GnssSatelliteId]) -> Vec<GnssSystem> {
1068 let mut systems: Vec<GnssSystem> = used
1069 .iter()
1070 .map(|s| match s.system {
1071 GnssSystem::Sbas => GnssSystem::Gps,
1072 system => system,
1073 })
1074 .collect();
1075 systems.sort_unstable();
1076 systems.dedup();
1077 systems
1078}
1079
1080/// The unweighted residual vector `P_meas - P_hat` at state `x`, in `used` order.
1081///
1082/// The state is `[x, y, z, clk_0, clk_1, ...]` where `clk_i` is the absolute
1083/// receiver clock for the i-th system returned by [`clock_systems`] (in meters).
1084/// Each satellite's residual uses its own system's clock, so a multi-GNSS set is
1085/// solved with one absolute receiver clock per system (a system's inter-system
1086/// bias is its clock minus the reference `clk_0`). A single-system set reduces to
1087/// `[x, y, z, b]` and `clk_0 = x[3]`.
1088///
1089/// Returns `Err(satellite)` if a used satellite has no observation or no usable
1090/// ephemeris at `x` (the frozen used set is fixed, but a finite-difference probe
1091/// could in principle reach an epoch off the ephemeris coverage). The caller
1092/// turns that into an [`SppError`] rather than panicking.
1093pub(crate) fn residual_unweighted(
1094 eph: &dyn EphemerisSource,
1095 used: &[GnssSatelliteId],
1096 obs_by_id: &[(GnssSatelliteId, f64)],
1097 x: &[f64],
1098 inputs: &SolveInputs,
1099 model: SppModelRecipe,
1100) -> Result<Vec<f64>, GnssSatelliteId> {
1101 let rx = [x[0], x[1], x[2]];
1102 let systems = clock_systems(used);
1103 let env = SatModelEnv {
1104 eph,
1105 t_rx_j2000_s: inputs.t_rx_j2000_s,
1106 t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
1107 day_of_year: inputs.day_of_year,
1108 corrections: inputs.corrections,
1109 met: &inputs.met,
1110 glonass_channels: &inputs.glonass_channels,
1111 model,
1112 };
1113 let mut out = Vec::with_capacity(used.len());
1114 for &sat in used {
1115 let p_meas = obs_by_id
1116 .iter()
1117 .find(|(id, _)| *id == sat)
1118 .map(|(_, p)| *p)
1119 .ok_or(sat)?;
1120 // The clock for this satellite's system (index 0 = reference clock).
1121 let sat_clock_system = match sat.system {
1122 GnssSystem::Sbas => GnssSystem::Gps,
1123 system => system,
1124 };
1125 let sys_idx = systems
1126 .iter()
1127 .position(|s| *s == sat_clock_system)
1128 .unwrap_or(0);
1129 let b = x[3 + sys_idx];
1130 let m =
1131 sat_model(&env, sat, rx, b, p_meas, ionosphere_for(sat.system, inputs)).ok_or(sat)?;
1132 out.push(p_meas - m.p_hat_m);
1133 }
1134 Ok(out)
1135}
1136
1137/// Run the SPP solve from synthesized/measured pseudoranges.
1138///
1139/// Uses the core trust-region weighted least-squares solver over the
1140/// `sqrt(w) * (P_meas - P_hat)` residual. The converged position/clock is a
1141/// sub-micron solver-agreement result (the linear-algebra step is not
1142/// bit-reproducible across BLAS builds), not a 0-ULP claim. The residual /
1143/// Jacobian substrate evaluated at recorded states is the 0-ULP target and is
1144/// exercised by the trace-replay parity test, not by this entry point.
1145///
1146/// This is the reference SPP entry point: it runs the legacy
1147/// [`SolverRecipe::NalgebraTrfLegacy`] trust-region factorization, so its
1148/// existing goldens are unchanged. [`solve_with_solver`] selects the owned
1149/// deterministic kernel.
1150pub fn solve(
1151 eph: &dyn EphemerisSource,
1152 inputs: &SolveInputs,
1153 with_geodetic: bool,
1154) -> Result<ReceiverSolution, SppError> {
1155 validate_solve_inputs(inputs)?;
1156 solve_inner(
1157 eph,
1158 inputs,
1159 with_geodetic,
1160 SppModelRecipe::reference(),
1161 TrustRegionSolve::NalgebraLu,
1162 )
1163}
1164
1165/// SPP's trust-region stage recognizes the owned deterministic solver
1166/// ([`SolverRecipe::OwnedDeterministicTrf`]), which owns the dense subproblem
1167/// factorization with a fixed reduction order and its own frozen-bits golden;
1168/// every other recipe selects the legacy nalgebra LU path that [`solve`] uses.
1169/// The other [`SolverRecipe`] variants name other strategies' linear-solve
1170/// stages (RTK first-tie, PPP last-tie, host LAPACK) and are not SPP
1171/// trust-region solvers.
1172const fn trust_region_solve(solver: SolverRecipe) -> TrustRegionSolve {
1173 match solver {
1174 SolverRecipe::OwnedDeterministicTrf => TrustRegionSolve::OwnedGaussianFirstTie,
1175 _ => TrustRegionSolve::NalgebraLu,
1176 }
1177}
1178
1179/// SPP solve with an explicit [`SolverRecipe`] for the trust-region stage.
1180///
1181/// Selecting [`SolverRecipe::NalgebraTrfLegacy`] is bit-identical to [`solve`].
1182/// [`SolverRecipe::OwnedDeterministicTrf`] swaps in the owned deterministic
1183/// Gaussian-elimination factorization for the dense trust-region subproblem (no
1184/// nalgebra LU, no black-box BLAS in that solve), pinned to its own frozen-bits
1185/// golden; all other model stages are unchanged. The owned kernel owns ONLY the
1186/// subproblem factorization: the normal-matrix / gradient / norm reductions that
1187/// build the subproblem still go through nalgebra's CPU-dispatched dense
1188/// algebra, so the cross-platform bit guarantee is scoped to the factorization
1189/// (the converged bits are this build's reproducible output, not a portable
1190/// constant).
1191pub fn solve_with_solver(
1192 eph: &dyn EphemerisSource,
1193 inputs: &SolveInputs,
1194 with_geodetic: bool,
1195 solver: SolverRecipe,
1196) -> Result<ReceiverSolution, SppError> {
1197 validate_solve_inputs(inputs)?;
1198 solve_inner(
1199 eph,
1200 inputs,
1201 with_geodetic,
1202 SppModelRecipe::reference(),
1203 trust_region_solve(solver),
1204 )
1205}
1206
1207fn solve_inner(
1208 eph: &dyn EphemerisSource,
1209 inputs: &SolveInputs,
1210 with_geodetic: bool,
1211 model: SppModelRecipe,
1212 linear_solve: TrustRegionSolve,
1213) -> Result<ReceiverSolution, SppError> {
1214 // One pseudorange per satellite. Reject duplicates deterministically (by
1215 // the smallest repeated id) so the result can never depend on observation
1216 // order and the parameter-count check below (`sel.used.len() < n_params`,
1217 // where `n_params = 3 + n_clocks`) counts distinct satellites.
1218 let mut ids: Vec<GnssSatelliteId> =
1219 inputs.observations.iter().map(|o| o.satellite_id).collect();
1220 ids.sort_unstable();
1221 if let Some(w) = ids.windows(2).find(|w| w[0] == w[1]) {
1222 return Err(SppError::DuplicateObservation { satellite: w[0] });
1223 }
1224
1225 // The broadcast Klobuchar delay is computed on L1 and scaled to each
1226 // satellite's carrier by `(f_L1 / f)^2`. GPS L1, Galileo E1, and BeiDou B1I
1227 // have fixed carriers; GLONASS is FDMA, so its carrier is resolved per
1228 // satellite from `glonass_channels`. A satellite whose carrier cannot be
1229 // resolved (a GLONASS observation with no channel in the map, or a channel
1230 // outside the valid `[-7, +6]` FDMA range) cannot be scaled, so reject an
1231 // ionosphere-corrected solve that includes it rather
1232 // than apply an undefined correction. This runs before selection so the
1233 // model is never evaluated for it (`select_sats` would otherwise call
1234 // `sat_model` with the correction for every observation).
1235 if inputs.corrections.ionosphere {
1236 if let Some(sat) = ids
1237 .iter()
1238 .find(|s| spp_iono_frequency_hz(**s, &inputs.glonass_channels).is_none())
1239 {
1240 return Err(SppError::IonosphereUnsupported { satellite: *sat });
1241 }
1242 }
1243
1244 let sel = select_sats(eph, inputs, model);
1245
1246 // One receiver-clock parameter per distinct GNSS (a reference clock plus an
1247 // inter-system bias for each additional system), so the state has
1248 // `3 + n_systems` parameters and needs at least that many usable satellites.
1249 // Floor the clock count at one: the minimum solve is the four-parameter
1250 // single-system form even when no satellite survives selection.
1251 let systems = clock_systems(&sel.used);
1252 let n_clocks = systems.len();
1253 // SPP's weighted-residual rows feed the trust-region solver, which owns the
1254 // normal-equation factorization (NormalRecipe::SppWeightedResidualFiniteDifference
1255 // via SolverRecipe::NalgebraTrfLegacy); only the parameter stack is named here.
1256 let n_params = ParameterLayout::spp(n_clocks.max(1)).dim();
1257 if sel.used.len() < n_params {
1258 return Err(SppError::TooFewSatellites {
1259 used: sel.used.len(),
1260 required: n_params,
1261 });
1262 }
1263
1264 let obs_by_id: Vec<(GnssSatelliteId, f64)> = inputs
1265 .observations
1266 .iter()
1267 .map(|o| (o.satellite_id, o.pseudorange_m))
1268 .collect();
1269
1270 let used = sel.used.clone();
1271 let inputs_ref = inputs.clone();
1272 let obs_ref = obs_by_id.clone();
1273 let eph_ref = eph;
1274 let n_used = used.len();
1275
1276 // The least-squares solver's residual closure cannot return an error, so an
1277 // ephemeris loss during a probe is recorded here and surfaced as an
1278 // SppError after the solve (rather than panicking inside the closure).
1279 let lost = std::rc::Rc::new(std::cell::Cell::new(None::<GnssSatelliteId>));
1280 let lost_in = lost.clone();
1281 let residual = move |x: &DVector<f64>| -> DVector<f64> {
1282 match residual_unweighted(eph_ref, &used, &obs_ref, x.as_slice(), &inputs_ref, model) {
1283 Ok(r) => DVector::from_vec(r),
1284 Err(sat) => {
1285 lost_in.set(Some(sat));
1286 DVector::from_vec(vec![0.0; n_used])
1287 }
1288 }
1289 };
1290
1291 // Extend the 4-element initial guess `[x, y, z, b_ref]` with a zero starting
1292 // value for each additional system's inter-system bias.
1293 let mut x0v = inputs.initial_guess.to_vec();
1294 x0v.extend(std::iter::repeat_n(0.0, n_clocks - 1));
1295 let x0 = DVector::from_vec(x0v);
1296 // Agreement-track stopping thresholds (see the SPP_SOLVER_* constants).
1297 let opts = SolveOptions {
1298 gtol: SPP_SOLVER_GTOL,
1299 ftol: SPP_SOLVER_FTOL,
1300 xtol: SPP_SOLVER_XTOL,
1301 max_nfev: SPP_SOLVER_MAX_NFEV,
1302 };
1303
1304 // The static elevation weights (base weights), index-aligned to `sel.used`.
1305 let base_weights = DVector::from_row_slice(&sel.weights);
1306
1307 // The warm-start solve uses the base elevation weights exactly. On the
1308 // static path (`robust == None`) this is the literal current sequence: a
1309 // single `with_weights(residual, x0, base_weights)` solve and nothing else,
1310 // so the byte output is unchanged. On the robust path it seeds the outer
1311 // loop.
1312 //
1313 // Check for an ephemeris loss recorded by the residual closure BEFORE
1314 // propagating a solver error: a lost satellite zeroes its residual row,
1315 // which can itself make the Jacobian singular, and EphemerisLost is the
1316 // more specific, actionable cause.
1317 let problem = LeastSquaresProblem::with_weights(&residual, x0, base_weights);
1318 let report_result = solve_trf_with(&problem, &opts, linear_solve);
1319 if let Some(satellite) = lost.get() {
1320 return Err(SppError::EphemerisLost { satellite });
1321 }
1322 let mut report = report_result?;
1323
1324 let mut outer_iterations = 0usize;
1325 let mut final_robust_scale_m: Option<f64> = None;
1326
1327 // Outer Huber/IRLS reweighting loop, ONLY on the robust path. Each iteration
1328 // recomputes the unweighted post-fit residuals at the current converged
1329 // state, derives a floored MAD scale, builds the effective weight vector
1330 // `base_elevation_weight * huber(r_i / s)` index-aligned to `sel.used`,
1331 // rebuilds the problem warm-started at the previous state, and re-solves. It
1332 // stops when the position step drops below `outer_tol_m` or the reweighted
1333 // solve budget left after the warm start is hit (recording
1334 // `converged = false` if the inner solve itself did not converge on the
1335 // final pass).
1336 if let Some(rc) = inputs.robust {
1337 for _ in 0..rc.max_outer.saturating_sub(1) {
1338 if lost.get().is_some() {
1339 break;
1340 }
1341 // Unweighted post-fit residuals at the current state, in used order.
1342 let post = match residual_unweighted(
1343 eph,
1344 &sel.used,
1345 &obs_by_id,
1346 report.x.as_slice(),
1347 inputs,
1348 model,
1349 ) {
1350 Ok(r) => r,
1351 Err(satellite) => return Err(SppError::EphemerisLost { satellite }),
1352 };
1353 let scale = mad_scale(&post, rc.scale_floor_m).map_err(map_robust_error)?;
1354 // Effective weight per used sat: base elevation weight times the
1355 // Huber multiplier of the scaled residual.
1356 let eff: Vec<f64> = post
1357 .iter()
1358 .zip(sel.weights.iter())
1359 .map(|(&r, &bw)| bw * huber_weight(r / scale, rc.huber_k))
1360 .collect();
1361 let eff_w = DVector::from_row_slice(&eff);
1362 let x_prev = report.x.clone();
1363 let problem = LeastSquaresProblem::with_weights(&residual, x_prev.clone(), eff_w);
1364 let next = solve_trf_with(&problem, &opts, linear_solve);
1365 if let Some(satellite) = lost.get() {
1366 return Err(SppError::EphemerisLost { satellite });
1367 }
1368 report = next?;
1369 outer_iterations += 1;
1370 final_robust_scale_m = Some(scale);
1371 // Position L2 step between successive outer solves.
1372 let dpos = ((report.x[0] - x_prev[0]).powi(2)
1373 + (report.x[1] - x_prev[1]).powi(2)
1374 + (report.x[2] - x_prev[2]).powi(2))
1375 .sqrt();
1376 if dpos < rc.outer_tol_m {
1377 break;
1378 }
1379 }
1380 }
1381
1382 let xs = &report.x;
1383 let position = ItrfPositionM::new(xs[0], xs[1], xs[2]).expect("valid ITRF position");
1384 let rx_clock_s = xs[3] / C_M_S;
1385 // One receiver clock (seconds) per system, in the same order as the state's
1386 // clock parameters. The first equals `rx_clock_s` (the reference system).
1387 let system_clocks_s: Vec<(GnssSystem, f64)> = systems
1388 .iter()
1389 .enumerate()
1390 .map(|(i, &sys)| (sys, xs[3 + i] / C_M_S))
1391 .collect();
1392 let geodetic = if with_geodetic {
1393 Some(geodetic_from_ecef(model.frame, [xs[0], xs[1], xs[2]]))
1394 } else {
1395 None
1396 };
1397
1398 // Post-fit unweighted residuals in used order.
1399 let residuals_m = residual_unweighted(eph, &sel.used, &obs_by_id, xs.as_slice(), inputs, model)
1400 .map_err(|satellite| SppError::EphemerisLost { satellite })?;
1401
1402 // DOP from the converged geometry: line-of-sight unit vectors to the
1403 // Sagnac-rotated satellite positions, with the frozen weights. A
1404 // single-system solve uses the 0-ULP four-state cofactor inverse; a
1405 // multi-system solve uses the general (3 + n_systems) inverse with one clock
1406 // column per GNSS (a deterministic geometry diagnostic, not a 0-ULP target).
1407 // The receiver-clock argument does not affect the line of sight, so the
1408 // reference clock is passed for every satellite.
1409 let rx_ecef = [xs[0], xs[1], xs[2]];
1410 let geo = geodetic_from_ecef(model.frame, [xs[0], xs[1], xs[2]]);
1411 let mut los = Vec::with_capacity(sel.used.len());
1412 let mut clock_index = Vec::with_capacity(sel.used.len());
1413 let env = SatModelEnv {
1414 eph,
1415 t_rx_j2000_s: inputs.t_rx_j2000_s,
1416 t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
1417 day_of_year: inputs.day_of_year,
1418 corrections: inputs.corrections,
1419 met: &inputs.met,
1420 glonass_channels: &inputs.glonass_channels,
1421 model,
1422 };
1423 for &sat in &sel.used {
1424 let p_meas = obs_by_id
1425 .iter()
1426 .find(|(id, _)| *id == sat)
1427 .map(|(_, p)| *p)
1428 .ok_or(SppError::EphemerisLost { satellite: sat })?;
1429 let m = sat_model(
1430 &env,
1431 sat,
1432 rx_ecef,
1433 xs[3],
1434 p_meas,
1435 ionosphere_for(sat.system, inputs),
1436 )
1437 .ok_or(SppError::EphemerisLost { satellite: sat })?;
1438 let dx = m.sat_rot_ecef_m[0] - rx_ecef[0];
1439 let dy = m.sat_rot_ecef_m[1] - rx_ecef[1];
1440 let dz = m.sat_rot_ecef_m[2] - rx_ecef[2];
1441 let n = (dx * dx + dy * dy + dz * dz).sqrt();
1442 los.push(LineOfSight::new(dx / n, dy / n, dz / n));
1443 let idx = systems.iter().position(|s| *s == sat.system).unwrap_or(0);
1444 clock_index.push(idx);
1445 }
1446 // `systems` is the clock-column ordering: `clock_index[k] ==
1447 // systems.position(sat.system)`, so `systems[c]` owns clock column `c` (the
1448 // same ordering `system_clocks_s` uses). The multi-system path is handed
1449 // that mapping and returns `Dop::system_tdops` already GNSS-tagged; the
1450 // single-system 0-ULP `dop` carries no constellation identity, so tag its
1451 // lone clock here with the one system in the solve.
1452 let dop_result = if n_clocks == 1 {
1453 dop(&los, &sel.weights, geo).ok().map(|mut d| {
1454 d.system_tdops = vec![(systems[0], d.tdop)];
1455 d
1456 })
1457 } else {
1458 dop_multi(&los, &clock_index, &systems, n_clocks, &sel.weights, geo).ok()
1459 };
1460 let n_params = xs.len();
1461 let jacobian_svd = report.jacobian.clone().svd(false, false);
1462 let diagnostics = singular_value_diagnostics(
1463 jacobian_svd.singular_values.as_slice(),
1464 report.jacobian.nrows(),
1465 report.jacobian.ncols(),
1466 );
1467 if diagnostics.rank < n_params || dop_result.is_none() {
1468 return Err(SppError::Singular(
1469 least_squares::SolveError::SingularJacobian,
1470 ));
1471 }
1472 let gdop = dop_result
1473 .as_ref()
1474 .expect("full-rank SPP geometry has DOP")
1475 .gdop;
1476 // The solution's per-system TDOPs come straight from the now-tagged
1477 // `Dop::system_tdops`; empty when the converged geometry is rank-deficient.
1478 let system_tdops: Vec<(GnssSystem, f64)> = dop_result
1479 .as_ref()
1480 .map(|d| d.system_tdops.clone())
1481 .unwrap_or_default();
1482
1483 let converged = matches!(
1484 report.status,
1485 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
1486 );
1487 let metadata_used_count = sel.used.len();
1488 let metadata_redundancy = redundancy(&systems, metadata_used_count);
1489 let geometry_quality = classify(
1490 diagnostics.rank,
1491 n_params,
1492 metadata_redundancy as i32,
1493 diagnostics.condition_number,
1494 gdop,
1495 false,
1496 GeometryQualityThresholds::default(),
1497 );
1498
1499 Ok(ReceiverSolution {
1500 position,
1501 geodetic,
1502 rx_clock_s,
1503 system_clocks_s,
1504 dop: dop_result,
1505 system_tdops,
1506 residuals_m,
1507 used_sats: sel.used,
1508 rejected_sats: sel.rejected,
1509 geometry_quality,
1510 metadata: SolutionMetadata {
1511 iterations: report.iterations,
1512 converged,
1513 status: report.status,
1514 ionosphere_applied: inputs.corrections.ionosphere,
1515 troposphere_applied: inputs.corrections.troposphere,
1516 outer_iterations,
1517 final_robust_scale_m,
1518 used_count: metadata_used_count,
1519 systems,
1520 redundancy: metadata_redundancy,
1521 raim_checkable: metadata_redundancy >= 1,
1522 },
1523 })
1524}
1525
1526/// Run SPP under the public API's language-independent validation/orchestration
1527/// policy.
1528///
1529/// Thin compatibility wrapper over the runtime strategy selector
1530/// ([`crate::estimation::strategies::estimate`]): it drives the shared
1531/// per-technique implementation [`run`] under the SPP reference strategy, which
1532/// resolves to the SPP reference recipe. The reference strategy always yields an
1533/// SPP solution or an SPP error, so the result is bit-identical to the recipe
1534/// driving [`run`] directly.
1535pub fn solve_with_policy(
1536 eph: &dyn EphemerisSource,
1537 inputs: &SolveInputs,
1538 with_geodetic: bool,
1539 policy: SolvePolicy,
1540) -> Result<ReceiverSolution, SolvePolicyError> {
1541 use crate::estimation::recipe::StrategyId;
1542 use crate::estimation::strategies::{
1543 estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
1544 };
1545 match estimate(
1546 EstimateInput::Spp {
1547 eph,
1548 inputs,
1549 with_geodetic,
1550 policy,
1551 },
1552 EstimateOptions::new(StrategyId::spp_reference()),
1553 ) {
1554 Ok(EstimateOutput::Spp(solution)) => Ok(*solution),
1555 Err(EstimateError::Spp(error)) => Err(error),
1556 Ok(_) | Err(_) => {
1557 unreachable!("the SPP reference strategy yields an SPP solution or an SPP error")
1558 }
1559 }
1560}
1561
1562/// Solve a batch of independent SPP epochs against a shared ephemeris, serially.
1563///
1564/// Element `i` of the result is [`solve_with_policy`] applied to `epochs[i]`,
1565/// with the shared `eph`, `with_geodetic`, and `policy` (every epoch is one
1566/// receive instant's [`SolveInputs`]; the receiver's clock and position are
1567/// re-estimated per epoch, so the epochs are independent). The first solve error
1568/// for an epoch becomes that element's `Err`. This is the single-threaded
1569/// reference the parallel [`solve_spp_batch_parallel`] is proven bit-identical
1570/// against.
1571pub fn solve_spp_batch_serial(
1572 eph: &dyn EphemerisSource,
1573 epochs: &[SolveInputs],
1574 with_geodetic: bool,
1575 policy: SolvePolicy,
1576) -> Vec<Result<ReceiverSolution, SolvePolicyError>> {
1577 epochs
1578 .iter()
1579 .map(|inputs| solve_with_policy(eph, inputs, with_geodetic, policy))
1580 .collect()
1581}
1582
1583/// Solve a batch of independent SPP epochs against a shared ephemeris, fanning
1584/// the independent per-epoch solves across a rayon thread pool.
1585///
1586/// Each epoch is solved by the same serial [`solve_with_policy`] kernel and the
1587/// indexed parallel collect preserves input order, so element `i` is
1588/// byte-for-byte identical to element `i` of [`solve_spp_batch_serial`]: the
1589/// epochs share only the immutable `eph`/`policy`, there is no cross-epoch state
1590/// and no reduction, and a single solve is unchanged. The work is embarrassingly
1591/// parallel (epochs are independent), so throughput scales with cores while
1592/// every value stays bit-exact. `eph` must be [`Sync`] to be shared across the
1593/// pool.
1594pub fn solve_spp_batch_parallel(
1595 eph: &(dyn EphemerisSource + Sync),
1596 epochs: &[SolveInputs],
1597 with_geodetic: bool,
1598 policy: SolvePolicy,
1599) -> Vec<Result<ReceiverSolution, SolvePolicyError>> {
1600 use rayon::prelude::*;
1601 epochs
1602 .par_iter()
1603 .map(|inputs| solve_with_policy(eph, inputs, with_geodetic, policy))
1604 .collect()
1605}
1606
1607/// Drive SPP from a resolved [`EstimationRecipe`]: the shared per-technique
1608/// implementation that [`crate::estimation::strategies::estimate`] dispatches to.
1609/// The recipe's range/sagnac/frame stages select the SPP measurement-model
1610/// operation order ([`SppModelRecipe`], threaded into [`sat_model`]) and its
1611/// [`SolverRecipe`] selects the trust-region factorization; the public
1612/// validation/orchestration policy is applied here. For the SPP reference recipe
1613/// every selected order equals the value the legacy [`solve`] path hard-coded, so
1614/// this is bit-identical to it.
1615pub(crate) fn run(
1616 recipe: &EstimationRecipe,
1617 eph: &dyn EphemerisSource,
1618 inputs: &SolveInputs,
1619 with_geodetic: bool,
1620 policy: SolvePolicy,
1621) -> Result<ReceiverSolution, SolvePolicyError> {
1622 validate_solve_inputs(inputs)?;
1623 let model = SppModelRecipe::from_recipe(recipe);
1624 match policy.coarse_search_seeds {
1625 Some(seed_count) => solve_coarse(
1626 eph,
1627 inputs,
1628 with_geodetic,
1629 policy,
1630 seed_count,
1631 model,
1632 recipe.solver,
1633 ),
1634 None => solve_validated(
1635 eph,
1636 inputs,
1637 with_geodetic,
1638 policy.validation,
1639 model,
1640 recipe.solver,
1641 ),
1642 }
1643}
1644
1645fn solve_validated(
1646 eph: &dyn EphemerisSource,
1647 inputs: &SolveInputs,
1648 with_geodetic: bool,
1649 validation: SolutionValidationOptions,
1650 model: SppModelRecipe,
1651 solver: SolverRecipe,
1652) -> Result<ReceiverSolution, SolvePolicyError> {
1653 let solution = solve_inner(
1654 eph,
1655 inputs,
1656 with_geodetic,
1657 model,
1658 trust_region_solve(solver),
1659 )?;
1660 validate_receiver_solution(&solution, validation)?;
1661 Ok(solution)
1662}
1663
1664fn solve_coarse(
1665 eph: &dyn EphemerisSource,
1666 inputs: &SolveInputs,
1667 with_geodetic: bool,
1668 policy: SolvePolicy,
1669 seed_count: usize,
1670 model: SppModelRecipe,
1671 solver: SolverRecipe,
1672) -> Result<ReceiverSolution, SolvePolicyError> {
1673 let mut candidates = Vec::new();
1674 let mut last_error = SolvePolicyError::NoCoarseSolution;
1675
1676 for seed in std::iter::once(inputs.initial_guess).chain(coarse_seeds(seed_count)) {
1677 let mut seeded = inputs.clone();
1678 seeded.initial_guess = seed;
1679 match solve_validated(
1680 eph,
1681 &seeded,
1682 with_geodetic,
1683 policy.validation,
1684 model,
1685 solver,
1686 ) {
1687 Ok(solution) => candidates.push(solution),
1688 Err(error) => last_error = error,
1689 }
1690 }
1691
1692 select_coarse_candidate(&candidates)
1693 .cloned()
1694 .ok_or(last_error)
1695}
1696
1697fn coarse_seeds(n: usize) -> Vec<[f64; 4]> {
1698 let golden = PI * (3.0 - 5.0_f64.sqrt());
1699 (0..n)
1700 .map(|i| {
1701 let z = 1.0 - 2.0 * (i as f64 + 0.5) / n as f64;
1702 let r = (1.0 - z * z).max(0.0).sqrt();
1703 let theta = golden * i as f64;
1704 [
1705 MEAN_EARTH_RADIUS_M * r * theta.cos(),
1706 MEAN_EARTH_RADIUS_M * r * theta.sin(),
1707 MEAN_EARTH_RADIUS_M * z,
1708 0.0,
1709 ]
1710 })
1711 .collect()
1712}
1713
1714fn select_coarse_candidate(candidates: &[ReceiverSolution]) -> Option<&ReceiverSolution> {
1715 candidates
1716 .iter()
1717 .filter(|solution| solution.metadata.converged && solution.metadata.redundancy >= 1)
1718 .min_by(|a, b| compare_coarse_candidates(a, b))
1719}
1720
1721fn compare_coarse_candidates(a: &ReceiverSolution, b: &ReceiverSolution) -> core::cmp::Ordering {
1722 b.used_sats
1723 .len()
1724 .cmp(&a.used_sats.len())
1725 .then_with(|| residual_rms(&a.residuals_m).total_cmp(&residual_rms(&b.residuals_m)))
1726 .then_with(|| candidate_gdop(a).total_cmp(&candidate_gdop(b)))
1727}
1728
1729fn candidate_gdop(solution: &ReceiverSolution) -> f64 {
1730 solution
1731 .dop
1732 .as_ref()
1733 .map(|dop| dop.gdop)
1734 .unwrap_or(f64::INFINITY)
1735}
1736
1737/// Root-mean-square of post-fit pseudorange residuals (0.0 when empty).
1738///
1739/// Exposed so language bindings can delegate residual-RMS reporting to the core
1740/// rather than recomputing the formula.
1741pub fn residual_rms(residuals: &[f64]) -> f64 {
1742 if residuals.is_empty() {
1743 return 0.0;
1744 }
1745 let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1746 (sum_sq / residuals.len() as f64).sqrt()
1747}
1748
1749fn redundancy(systems: &[GnssSystem], used_count: usize) -> isize {
1750 used_count as isize - (3 + systems.len() as isize)
1751}
1752
1753fn validate_solve_inputs(inputs: &SolveInputs) -> Result<(), SppError> {
1754 validate::finite(inputs.t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1755 validate::second_of_day(inputs.t_rx_second_of_day_s, "t_rx_second_of_day_s")
1756 .map_err(map_input_error)?;
1757 validate::finite_in_range_exclusive_upper(inputs.day_of_year, 1.0, 367.0, "day_of_year")
1758 .map_err(map_input_error)?;
1759 validate::finite_slice(&inputs.initial_guess, "initial_guess").map_err(map_input_error)?;
1760 validate_klobuchar(&inputs.klobuchar, "klobuchar")?;
1761 if let Some(klobuchar) = &inputs.beidou_klobuchar {
1762 validate_klobuchar(klobuchar, "beidou_klobuchar")?;
1763 }
1764 if let Some(nequick) = &inputs.galileo_nequick {
1765 validate_galileo_nequick(nequick)?;
1766 }
1767 if inputs.corrections.troposphere {
1768 validate_met(&inputs.met)?;
1769 }
1770 validate_observations(&inputs.observations)?;
1771 if let Some(robust) = inputs.robust {
1772 if robust.max_outer == 0 {
1773 return Err(SppError::InvalidInput {
1774 field: "robust.max_outer",
1775 kind: SppInputErrorKind::NotPositive,
1776 });
1777 }
1778 validate::finite_positive(robust.huber_k, "robust.huber_k").map_err(map_input_error)?;
1779 validate::finite_positive(robust.scale_floor_m, "robust.scale_floor_m")
1780 .map_err(map_input_error)?;
1781 validate::finite_positive(robust.outer_tol_m, "robust.outer_tol_m")
1782 .map_err(map_input_error)?;
1783 }
1784 Ok(())
1785}
1786
1787fn validate_klobuchar(coeffs: &KlobucharCoeffs, field: &'static str) -> Result<(), SppError> {
1788 validate::finite_slice(&coeffs.alpha, field).map_err(map_input_error)?;
1789 validate::finite_slice(&coeffs.beta, field).map_err(map_input_error)
1790}
1791
1792fn validate_galileo_nequick(coeffs: &GalileoNequickCoeffs) -> Result<(), SppError> {
1793 validate::finite(coeffs.ai0, "galileo_nequick").map_err(map_input_error)?;
1794 validate::finite(coeffs.ai1, "galileo_nequick").map_err(map_input_error)?;
1795 validate::finite(coeffs.ai2, "galileo_nequick").map_err(map_input_error)?;
1796 Ok(())
1797}
1798
1799fn validate_met(met: &SurfaceMet) -> Result<(), SppError> {
1800 validate::finite_positive(met.pressure_hpa, "met.pressure_hpa").map_err(map_input_error)?;
1801 validate::finite_positive(met.temperature_k, "met.temperature_k").map_err(map_input_error)?;
1802 validate::fraction(met.relative_humidity, "met.relative_humidity").map_err(map_input_error)?;
1803 Ok(())
1804}
1805
1806fn validate_observations(observations: &[Observation]) -> Result<(), SppError> {
1807 for obs in observations {
1808 validate::finite_positive(obs.pseudorange_m, "observation.pseudorange_m")
1809 .map_err(map_input_error)?;
1810 }
1811 Ok(())
1812}
1813
1814fn map_input_error(error: validate::FieldError) -> SppError {
1815 SppError::InvalidInput {
1816 field: error.field(),
1817 kind: SppInputErrorKind::from(&error),
1818 }
1819}
1820
1821fn map_robust_error(error: RobustError) -> SppError {
1822 let field = match error.field() {
1823 "scale_floor" => "robust.scale_floor_m",
1824 "residuals" | "values" => "robust.residuals",
1825 other => other,
1826 };
1827 let kind = match error.reason() {
1828 "not finite" => SppInputErrorKind::NonFinite,
1829 "not positive" => SppInputErrorKind::NotPositive,
1830 "negative" => SppInputErrorKind::Negative,
1831 "out of range" => SppInputErrorKind::OutOfRange,
1832 _ => SppInputErrorKind::OutOfRange,
1833 };
1834 SppError::InvalidInput { field, kind }
1835}
1836
1837/// The core km/deg geodetic recipe, for the boundary cross-check against the
1838/// meters-native helper.
1839#[cfg(all(test, sidereon_repo_tests))]
1840pub(crate) mod test_support {
1841 use super::*;
1842
1843 pub fn geodetic_from_ecef_m_for_test(x_m: f64, y_m: f64, z_m: f64) -> Wgs84Geodetic {
1844 geodetic_from_ecef(FrameRecipe::SppSkyfieldAuThreeIter, [x_m, y_m, z_m])
1845 }
1846
1847 pub fn sat_model_for_test(
1848 env: &SatModelEnv,
1849 sat: GnssSatelliteId,
1850 rx: [f64; 3],
1851 b_m: f64,
1852 p_meas: f64,
1853 klobuchar: &KlobucharCoeffs,
1854 ) -> Option<SatModel> {
1855 sat_model(
1856 env,
1857 sat,
1858 rx,
1859 b_m,
1860 p_meas,
1861 SppIonosphere::Klobuchar(*klobuchar),
1862 )
1863 }
1864
1865 pub fn sat_model_with_ionosphere_for_test(
1866 env: &SatModelEnv,
1867 sat: GnssSatelliteId,
1868 rx: [f64; 3],
1869 b_m: f64,
1870 p_meas: f64,
1871 ionosphere: SppIonosphere<'_>,
1872 ) -> Option<SatModel> {
1873 sat_model(env, sat, rx, b_m, p_meas, ionosphere)
1874 }
1875
1876 /// The core km/deg geodetic recipe (Skyfield AU-internal), returning the
1877 /// public `(lat_deg, lon_deg, alt_km)`, for the boundary cross-check.
1878 pub fn itrs_to_geodetic_core_km(x_km: f64, y_km: f64, z_km: f64) -> (f64, f64, f64) {
1879 crate::astro::frames::transforms::itrs_to_geodetic_compute(x_km, y_km, z_km)
1880 .expect("valid ITRS coordinates")
1881 }
1882}
1883
1884#[cfg(all(test, sidereon_repo_tests))]
1885mod tests;