Skip to main content

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