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