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