sidereon_core/estimation/recipe.rs
1//! Named operation-order recipes for the GNSS estimation substrate.
2//!
3//! Phase-2 collapses the three thick estimator stacks (`spp`, `rtk`/`rtk_filter`,
4//! `precise_positioning`) onto one shared substrate plus thin, runtime-selectable
5//! strategies. The single hard constraint is that each external reference's
6//! bit-exactness (Skyfield for SPP, RTKLIB for RTK, the PPP oracle for PPP) must
7//! be preserved to 0 ULP. Different references need different floating-point
8//! operation orders for the *same* physical quantity, so the substrate never
9//! "simplifies" a parity-sensitive formula into one shared form. Instead every
10//! such choice is a NAMED variant: a strategy selects the op-order it needs by
11//! enum value rather than by owning a copy of the helper.
12//!
13//! This module *names* the recipes; the substrate and strategies route every
14//! caller through them. Each reference-faithful strategy resolves to the single
15//! op-order it was already using, so threading the recipe through the shared
16//! spine reproduces the prior code path bit-for-bit and leaves every existing
17//! 0-ULP golden unchanged.
18//!
19//! The `Canonical*` variants belong to the single consistent IERS-rigorous
20//! model (the bounded-tolerance canonical strategy, P6). They are NOT used by
21//! any reference-faithful strategy; canonical is an additional selectable
22//! strategy that changes nothing about the references. The SPP canonical range
23//! and frame variants ([`RangeRecipe::CanonicalLightTimeClosedFormSagnac`],
24//! [`FrameRecipe::CanonicalWgs84`]) are implemented and driven by
25//! [`EstimationRecipe::canonical_spp`]; the RTK and PPP canonical square-root
26//! solve ([`NormalRecipe::CanonicalSquareRoot`] on
27//! [`SolverRecipe::OwnedDeterministicCholesky`]) by
28//! [`EstimationRecipe::canonical_rtk`] and [`EstimationRecipe::canonical_ppp`].
29//! Canonical SPP, RTK, and PPP are all wired.
30
31/// Estimation technique: which physical observation model and parameter set a
32/// strategy estimates.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum Technique {
35 /// Single-point positioning: undifferenced pseudorange PVT.
36 #[default]
37 Spp,
38 /// Real-time kinematic: double-differenced code/phase baseline.
39 Rtk,
40 /// Precise point positioning: undifferenced ionosphere-free code/phase.
41 Ppp,
42}
43
44/// The reference a reference-faithful strategy is bit-exact against. The
45/// external oracles (Skyfield, RTKLIB, the PPP oracle) are CI validation targets
46/// whose goldens stay 0-ULP unchanged through P0-P5; [`Self::OwnedDeterministic`]
47/// is instead pinned to the owned solver's own frozen-bits golden (P5).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
49pub enum ReferenceTarget {
50 /// Skyfield (the SPP geometry/clock/Sagnac reference).
51 #[default]
52 Skyfield,
53 /// RTKLIB (the RTK double-difference baseline reference).
54 Rtklib,
55 /// scipy least-squares host solve (the SPP solver-agreement reference).
56 /// Named for the `bitexact` host-LAPACK fingerprint study; not a runtime
57 /// estimation strategy (it is not wired into the SPP solve path), so it is
58 /// not a valid [`StrategyId`] target.
59 Scipy,
60 /// The PPP float/fixed oracle arc.
61 PppOracle,
62 /// The SPP owned deterministic trust-region solver
63 /// ([`SolverRecipe::OwnedDeterministicTrf`]): a fixed-reduction-order dense
64 /// subproblem factorization with no nalgebra LU and no black-box BLAS in that
65 /// solve. It is pinned to its own frozen-bits golden rather than to an
66 /// external library, and is selectable only for [`Technique::Spp`]. The owned
67 /// kernel owns only the subproblem factorization (the surrounding
68 /// normal-matrix / gradient / norm reductions stay on nalgebra), so its
69 /// cross-platform bit guarantee is scoped to the factorization; see
70 /// [`SolverRecipe::OwnedDeterministicTrf`] for the precise scope.
71 OwnedDeterministic,
72}
73
74/// Runtime-selectable strategy identity. `Reference` strategies are 0-ULP
75/// bit-exact to an external reference and remain the validation oracles;
76/// `Canonical` is the single bounded-tolerance "best" model (P6). Canonical SPP,
77/// RTK, and PPP are all wired.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum StrategyId {
80 /// A reference-faithful strategy: 0-ULP to `target` for `technique`.
81 Reference {
82 technique: Technique,
83 target: ReferenceTarget,
84 },
85 /// The canonical strategy for `technique` (bounded-tolerance, truth-gated).
86 Canonical { technique: Technique },
87}
88
89impl Default for StrategyId {
90 fn default() -> Self {
91 Self::Reference {
92 technique: Technique::Spp,
93 target: ReferenceTarget::Skyfield,
94 }
95 }
96}
97
98impl StrategyId {
99 /// SPP, bit-exact to Skyfield (`spp::solve`).
100 pub const fn spp_reference() -> Self {
101 Self::Reference {
102 technique: Technique::Spp,
103 target: ReferenceTarget::Skyfield,
104 }
105 }
106
107 /// RTK, bit-exact to RTKLIB (`rtk` / `rtk_filter`).
108 pub const fn rtk_reference() -> Self {
109 Self::Reference {
110 technique: Technique::Rtk,
111 target: ReferenceTarget::Rtklib,
112 }
113 }
114
115 /// PPP, bit-exact to the PPP oracle arc (`precise_positioning`).
116 pub const fn ppp_reference() -> Self {
117 Self::Reference {
118 technique: Technique::Ppp,
119 target: ReferenceTarget::PppOracle,
120 }
121 }
122
123 /// SPP via the owned deterministic trust-region solver
124 /// ([`SolverRecipe::OwnedDeterministicTrf`]): the owned dense subproblem
125 /// factorization, pinned to its own frozen-bits golden (its cross-platform
126 /// bit guarantee is scoped to the factorization). Selecting this through
127 /// [`crate::estimation::strategies::estimate`] drives the owned solver
128 /// rather than the legacy nalgebra LU path.
129 pub const fn spp_owned_deterministic() -> Self {
130 Self::Reference {
131 technique: Technique::Spp,
132 target: ReferenceTarget::OwnedDeterministic,
133 }
134 }
135}
136
137/// Geometric range / light-time / transmit-time operation order. Each variant
138/// names an existing range model; the substrate selects the op-order rather
139/// than copying the helper.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
141pub enum RangeRecipe {
142 /// SPP closed-form light-time with a fixed transmit-time iteration count and
143 /// a measured-pseudorange seed (`spp/mod.rs` `sat_model`).
144 #[default]
145 SppMeasuredPseudorangeFixedIter,
146 /// `observables::predict` rounded-microsecond transmit time with a fixed
147 /// light-time iteration count (PPP / forward-prediction model).
148 ObservableRoundedMicrosecondFixedIter,
149 /// RTK provided-transmit-position range with the RTKLIB first-order Sagnac
150 /// scalar (`rtk_filter::model` line-of-sight / geometric range).
151 RtkProvidedTxFirstOrderSagnac,
152 /// Canonical: full iterative light-time (iterated to convergence, not a
153 /// fixed truncation) with the closed-form Sagnac Z-rotation, never a
154 /// first-order scalar Sagnac. Driven by [`EstimationRecipe::canonical_spp`]
155 /// in the SPP measurement model; not used by any reference strategy.
156 CanonicalLightTimeClosedFormSagnac,
157}
158
159/// Earth-rotation (Sagnac) correction operation order.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
161pub enum SagnacRecipe {
162 /// Closed-form z-axis rotation of the satellite ECEF position by
163 /// `OMEGA_E_DOT * tau` (SPP / observables).
164 #[default]
165 ClosedFormZRotation,
166 /// RTKLIB first-order scalar Sagnac term added to the range
167 /// (`rtk_filter::model`).
168 RtklibFirstOrderScalar,
169 /// No Sagnac correction (synthetic / ECI-consistent inputs).
170 Off,
171}
172
173/// Local-frame / ENU / az-el basis construction operation order.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
175pub enum FrameRecipe {
176 /// SPP Skyfield-parity ECEF->geodetic with the three-iteration AU-scaled
177 /// latitude solve (`spp` geodetic conversion).
178 #[default]
179 SppSkyfieldAuThreeIter,
180 /// Geocentric-up local frame used by the RTK elevation reference
181 /// (`rtk_filter` elevation mask / antenna projection).
182 GeocentricUpRtkReference,
183 /// Geodetic NEU basis built from the cross-product convention
184 /// (`precise_positioning::model` troposphere geometry).
185 GeodeticNeuCrossProduct,
186 /// DOP ENU rotation basis (`dop`).
187 DopEnuRotation,
188 /// Canonical: one consistent meters-native WGS84/ITRF geodetic basis under
189 /// IERS conventions (the core PROJ-pinned closed-form solve), never a
190 /// reference-specific AU-scaled path. Driven by
191 /// [`EstimationRecipe::canonical_spp`]; not used by any reference strategy.
192 CanonicalWgs84,
193}
194
195/// Normal-equation assembly tie-breaking / fold order. The tie order is the
196/// pivot/elimination convention that fixes the bit pattern of the reduced
197/// system.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
199pub enum NormalRecipe {
200 /// SPP weighted-residual rows with a finite-difference design matrix
201 /// (`spp` least-squares).
202 #[default]
203 SppWeightedResidualFiniteDifference,
204 /// RTK double-difference block assembly with the first-tie covariance fold
205 /// (`rtk_filter::normal` first-tie block).
206 RtkDoubleDifferenceBlockFirstTie,
207 /// PPP dense normal equations with the last-tie solve
208 /// (`precise_positioning::normal` `*_last_tie`).
209 PppDenseLastTie,
210 /// Canonical square-root-information solve, shared by canonical RTK and
211 /// canonical PPP: the SPD normal system is solved by the owned deterministic
212 /// Cholesky factorization `Λ = L Lᵀ` plus forward/back substitution, where
213 /// `L` is the information-matrix square root. For RTK this is the
214 /// double-difference information system `Λ x = η` assembled by the same shared
215 /// block fold the RTK reference uses; for PPP it is the dense weighted normal
216 /// system `AᵀWA x = AᵀWy` assembled from the same undifferenced rows the PPP
217 /// reference uses. This is the numerically rigorous op-order for an SPD normal
218 /// matrix (no pivoting; exploits symmetry), distinct from the reference RTK
219 /// general first-tie Gaussian elimination
220 /// ([`Self::RtkDoubleDifferenceBlockFirstTie`]) and the reference PPP last-tie
221 /// Gaussian elimination ([`Self::PppDenseLastTie`]). Driven by
222 /// [`EstimationRecipe::canonical_rtk`] and [`EstimationRecipe::canonical_ppp`]
223 /// on the owned [`SolverRecipe::OwnedDeterministicCholesky`] kernel; not used
224 /// by any reference strategy.
225 CanonicalSquareRoot,
226}
227
228/// Linear-solve / factorization operation order. Determinism note: the legacy
229/// SPP path is nalgebra LU (not bit-portable end-to-end), preserved as a named
230/// variant; the owned deterministic kernel (P5) owns the dense subproblem
231/// factorization with its own goldens. Its determinism scope is the
232/// factorization, not the surrounding nalgebra reductions that build the
233/// subproblem -- see [`Self::OwnedDeterministicTrf`].
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
235pub enum SolverRecipe {
236 /// nalgebra trust-region least squares, the current SPP solver
237 /// (`spp` / `crate::astro::math::least_squares`). Existing SPP goldens use
238 /// this; kept unchanged.
239 #[default]
240 NalgebraTrfLegacy,
241 /// Flat first-tie Gaussian elimination (RTK baseline/filter solve).
242 FlatGaussianFirstTie,
243 /// Dense last-tie Gaussian elimination (PPP solve,
244 /// `crate::astro::math::linear::solve_linear_last_tie`).
245 DenseGaussianLastTie,
246 /// scipy host LAPACK reference solve (machine-dependent; only as a
247 /// fingerprinted CI reference, never canonical).
248 ScipyHostLapackReference,
249 /// Owned deterministic Cholesky (square-root) linear solve, the canonical RTK
250 /// (P6 increment 2) and canonical PPP (P6 increment 3) solver: the SPD normal
251 /// system is factored `Λ = L Lᵀ` and solved by forward/back substitution
252 /// through the owned
253 /// [`crate::astro::math::linear::solve_flat_normal_square_root_into`] kernel,
254 /// with no nalgebra LU and no black-box BLAS. Paired with
255 /// [`NormalRecipe::CanonicalSquareRoot`]. Both the RTK and PPP canonical paths
256 /// are owned scalar arithmetic and f64 sqrt is IEEE-754 correctly rounded, so
257 /// unlike [`Self::OwnedDeterministicTrf`] (whose surrounding reductions ride
258 /// nalgebra) its bit guarantee covers the full solve and is portable across
259 /// platforms.
260 OwnedDeterministicCholesky,
261 /// Owned deterministic trust-region subproblem solve added in P5: a
262 /// fixed-reduction-order dense Gaussian elimination (the
263 /// `OwnedGaussianFirstTie` kernel) with no nalgebra LU and no black-box BLAS
264 /// in the factorization, pinned to its OWN frozen-bits goldens. Scope: it
265 /// owns ONLY the subproblem factorization; the normal-matrix / gradient /
266 /// norm reductions that build the subproblem still flow through nalgebra's
267 /// CPU-dispatched dense algebra, so the cross-platform bit guarantee is
268 /// scoped to the factorization, not the full solve.
269 OwnedDeterministicTrf,
270}
271
272/// The full operation-order recipe a strategy composes: one variant per stage.
273/// `Default` and the named constructors reproduce the CURRENT behavior of each
274/// existing strategy, so selecting a recipe never changes a reference golden.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
276pub struct EstimationRecipe {
277 pub range: RangeRecipe,
278 pub sagnac: SagnacRecipe,
279 pub frame: FrameRecipe,
280 pub normal: NormalRecipe,
281 pub solver: SolverRecipe,
282}
283
284impl EstimationRecipe {
285 /// The current SPP reference recipe (`spp::solve`, Skyfield-parity).
286 pub const fn spp() -> Self {
287 Self {
288 range: RangeRecipe::SppMeasuredPseudorangeFixedIter,
289 sagnac: SagnacRecipe::ClosedFormZRotation,
290 frame: FrameRecipe::SppSkyfieldAuThreeIter,
291 normal: NormalRecipe::SppWeightedResidualFiniteDifference,
292 solver: SolverRecipe::NalgebraTrfLegacy,
293 }
294 }
295
296 /// The current RTK reference recipe (`rtk` / `rtk_filter`, RTKLIB-parity).
297 pub const fn rtk() -> Self {
298 Self {
299 range: RangeRecipe::RtkProvidedTxFirstOrderSagnac,
300 sagnac: SagnacRecipe::RtklibFirstOrderScalar,
301 frame: FrameRecipe::GeocentricUpRtkReference,
302 normal: NormalRecipe::RtkDoubleDifferenceBlockFirstTie,
303 solver: SolverRecipe::FlatGaussianFirstTie,
304 }
305 }
306
307 /// The current PPP reference recipe (`precise_positioning`, oracle-parity).
308 pub const fn ppp() -> Self {
309 Self {
310 range: RangeRecipe::ObservableRoundedMicrosecondFixedIter,
311 sagnac: SagnacRecipe::ClosedFormZRotation,
312 frame: FrameRecipe::GeodeticNeuCrossProduct,
313 normal: NormalRecipe::PppDenseLastTie,
314 solver: SolverRecipe::DenseGaussianLastTie,
315 }
316 }
317
318 /// The SPP recipe driving the owned deterministic trust-region solver: the
319 /// SPP reference model with [`SolverRecipe::OwnedDeterministicTrf`] swapped
320 /// in for the legacy nalgebra LU linear-solve stage. Every other stage is the
321 /// SPP reference op-order, so only the factorization changes.
322 pub const fn spp_owned_deterministic() -> Self {
323 let mut recipe = Self::spp();
324 recipe.solver = SolverRecipe::OwnedDeterministicTrf;
325 recipe
326 }
327
328 /// The canonical SPP recipe: the single consistent IERS-rigorous SPP
329 /// measurement model. It diverges from [`Self::spp`] (the Skyfield-faithful
330 /// reference) only where the physics says to:
331 /// - range: [`RangeRecipe::CanonicalLightTimeClosedFormSagnac`] iterates the
332 /// light-time loop to convergence (vs the reference's fixed
333 /// transmit-time truncation), with the closed-form Sagnac Z-rotation (never
334 /// a first-order scalar Sagnac).
335 /// - frame: [`FrameRecipe::CanonicalWgs84`] solves ECEF->geodetic directly in
336 /// meters on the WGS84 ellipsoid (vs the reference's Skyfield AU-scaled
337 /// three-iteration latitude loop).
338 /// - solver: [`SolverRecipe::OwnedDeterministicTrf`] owns the trust-region
339 /// subproblem factorization so canonical is deterministic run-to-run on a
340 /// pinned build (its cross-platform bit guarantee is scoped to the
341 /// factorization; the surrounding reductions ride nalgebra).
342 ///
343 /// The Sagnac stage is the closed-form Z-rotation the SPP reference already
344 /// uses (the rigorous form), and the normal stage is the SPP
345 /// weighted-residual finite-difference assembly the trust-region solver
346 /// consumes; neither needs a separate canonical variant for SPP.
347 pub const fn canonical_spp() -> Self {
348 Self {
349 range: RangeRecipe::CanonicalLightTimeClosedFormSagnac,
350 sagnac: SagnacRecipe::ClosedFormZRotation,
351 frame: FrameRecipe::CanonicalWgs84,
352 normal: NormalRecipe::SppWeightedResidualFiniteDifference,
353 solver: SolverRecipe::OwnedDeterministicTrf,
354 }
355 }
356
357 /// The canonical RTK recipe: the double-difference baseline under the
358 /// numerically rigorous square-root-information solve. It keeps the RTK
359 /// reference's double-difference measurement physics (the provided-transmit
360 /// range with the RTKLIB first-order Sagnac scalar, the geocentric-up
361 /// elevation frame), because the canonical RTK divergence the physics calls
362 /// for is in the linear algebra, not the observation model: the same SPD
363 /// information system the reference assembles is solved by the owned
364 /// deterministic Cholesky square-root factorization
365 /// ([`NormalRecipe::CanonicalSquareRoot`] on
366 /// [`SolverRecipe::OwnedDeterministicCholesky`]) instead of the reference's
367 /// general first-tie Gaussian elimination. The square-root solve needs no
368 /// pivoting, exploits the symmetry of the SPD normal matrix, and is entirely
369 /// owned scalar arithmetic (no nalgebra, no BLAS), so canonical RTK is
370 /// well-conditioned and bit-reproducible across platforms.
371 pub const fn canonical_rtk() -> Self {
372 Self {
373 range: RangeRecipe::RtkProvidedTxFirstOrderSagnac,
374 sagnac: SagnacRecipe::RtklibFirstOrderScalar,
375 frame: FrameRecipe::GeocentricUpRtkReference,
376 normal: NormalRecipe::CanonicalSquareRoot,
377 solver: SolverRecipe::OwnedDeterministicCholesky,
378 }
379 }
380
381 /// The canonical PPP recipe: the undifferenced ionosphere-free PPP arc under
382 /// the numerically rigorous square-root-information solve. Like
383 /// [`Self::canonical_rtk`] it keeps the PPP reference's measurement physics
384 /// (the rounded-microsecond fixed-iteration light-time with the rigorous
385 /// closed-form Sagnac Z-rotation, and the geodetic NEU antenna frame), because
386 /// the canonical PPP divergence the physics calls for is in the linear
387 /// algebra, not the observation model: the same dense SPD weighted normal
388 /// system `AᵀWA x = AᵀWy` the reference assembles from the undifferenced rows
389 /// is solved by the owned deterministic Cholesky square-root factorization
390 /// ([`NormalRecipe::CanonicalSquareRoot`] on
391 /// [`SolverRecipe::OwnedDeterministicCholesky`]) instead of the reference's
392 /// dense last-tie Gaussian elimination ([`SolverRecipe::DenseGaussianLastTie`]).
393 /// The square-root solve needs no pivoting, exploits the symmetry of the SPD
394 /// normal matrix, and is entirely owned scalar arithmetic (no nalgebra, no
395 /// BLAS), so it is well-conditioned and the solve itself is bit-portable.
396 /// Determinism scope (calibrated, not overstated): unlike canonical RTK, the PPP
397 /// measurement model that builds the rows evaluates troposphere mapping,
398 /// antenna, and geodetic-frame transcendentals through the platform math
399 /// library, so canonical PPP's overall output is bit-reproducible run-to-run on
400 /// a pinned build but is not claimed bit-portable across platforms; only the
401 /// owned Cholesky solve carries the cross-platform guarantee.
402 pub const fn canonical_ppp() -> Self {
403 Self {
404 range: RangeRecipe::ObservableRoundedMicrosecondFixedIter,
405 sagnac: SagnacRecipe::ClosedFormZRotation,
406 frame: FrameRecipe::GeodeticNeuCrossProduct,
407 normal: NormalRecipe::CanonicalSquareRoot,
408 solver: SolverRecipe::OwnedDeterministicCholesky,
409 }
410 }
411
412 /// The canonical recipe for a `technique`. Canonical SPP (P6 increment 1),
413 /// canonical RTK (P6 increment 2), and canonical PPP (P6 increment 3) are all
414 /// wired, so every technique has a canonical strategy. Returns `Option` to keep
415 /// the resolver's "not yet implemented" surface stable.
416 pub const fn for_canonical(technique: Technique) -> Option<Self> {
417 match technique {
418 Technique::Spp => Some(Self::canonical_spp()),
419 Technique::Rtk => Some(Self::canonical_rtk()),
420 Technique::Ppp => Some(Self::canonical_ppp()),
421 }
422 }
423
424 /// The reference recipe for an explicit `(technique, target)` pair, or `None`
425 /// if the pair is not a supported reference strategy. This is the single
426 /// source of truth for which targets each technique can run: only the wired
427 /// reference oracles (Skyfield for SPP, RTKLIB for RTK, the PPP oracle for
428 /// PPP) and the SPP owned deterministic solver are valid. Every other pair
429 /// (a cross-technique oracle, or the unwired scipy host-LAPACK reference) is
430 /// rejected so an impossible strategy can never silently run a mismatched
431 /// recipe.
432 pub const fn for_reference(technique: Technique, target: ReferenceTarget) -> Option<Self> {
433 match (technique, target) {
434 (Technique::Spp, ReferenceTarget::Skyfield) => Some(Self::spp()),
435 (Technique::Spp, ReferenceTarget::OwnedDeterministic) => {
436 Some(Self::spp_owned_deterministic())
437 }
438 (Technique::Rtk, ReferenceTarget::Rtklib) => Some(Self::rtk()),
439 (Technique::Ppp, ReferenceTarget::PppOracle) => Some(Self::ppp()),
440 _ => None,
441 }
442 }
443}
444
445/// How a strategy forms its integer-ambiguity identifiers, and against what they
446/// are referenced. Naming this lets the RTK and PPP fixed solvers share one
447/// LAMBDA resolution kernel
448/// ([`crate::estimation::substrate::ambiguity::resolve_integer_lattice`]) and
449/// differ only in DATA rather than in separate algorithm trees.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
451pub enum DifferencingMode {
452 /// Double-differenced ambiguities, one reference satellite per constellation
453 /// (the RTK baseline / sequential-filter convention: each non-reference
454 /// satellite is differenced against its own system's reference).
455 DoubleDifferencePerSystemReference,
456 /// Undifferenced ambiguities, one per satellite per receiver (the PPP
457 /// convention: no reference satellite, all satellites carry their own
458 /// ionosphere-free ambiguity).
459 Undifferenced,
460}
461
462/// Whether partial ambiguity resolution is attempted when the full-set integer
463/// fix fails its ratio test, and with what floor on the retained subset size.
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
465pub enum PartialResolution {
466 /// Full-set only: a failed ratio test means "not fixed" (PPP, and the RTK
467 /// sequential filter, both take the full set or nothing).
468 Disabled,
469 /// Confidence-ranked then exhaustive subset fallback down to
470 /// `min_ambiguities` retained (the RTK static fixed solver,
471 /// `rtk_filter::search::search_partial_fixed_ambiguities`).
472 Exhaustive { min_ambiguities: usize },
473}
474
475/// The integer-ambiguity identity/eligibility policy a strategy resolves under:
476/// the strategy DATA that replaces the RTK-vs-PPP algorithm-tree split. The
477/// LAMBDA resolution kernel is common; only these fields differ between the
478/// reference strategies. Named in P3; consumed by the runtime selector in P4.
479#[derive(Debug, Clone, Copy, PartialEq)]
480pub struct AmbiguityIdPolicy {
481 pub differencing: DifferencingMode,
482 /// Exclude float-only constellations from the integer search set.
483 pub float_only_gating: bool,
484 pub partial: PartialResolution,
485 /// Ratio-test acceptance threshold passed to the LAMBDA kernel.
486 pub ratio_threshold: f64,
487}
488
489impl AmbiguityIdPolicy {
490 /// The static RTK fixed-baseline policy (`rtk_filter::fixed`): per-system
491 /// double differences, float-only constellations excluded from the search,
492 /// partial resolution down to `partial_min_ambiguities`.
493 pub const fn rtk_static(ratio_threshold: f64, partial_min_ambiguities: usize) -> Self {
494 Self {
495 differencing: DifferencingMode::DoubleDifferencePerSystemReference,
496 float_only_gating: true,
497 partial: PartialResolution::Exhaustive {
498 min_ambiguities: partial_min_ambiguities,
499 },
500 ratio_threshold,
501 }
502 }
503
504 /// The sequential RTK filter policy (`rtk_filter::update`): per-system double
505 /// differences, float-only constellations excluded, full set or nothing.
506 pub const fn rtk_sequential(ratio_threshold: f64) -> Self {
507 Self {
508 differencing: DifferencingMode::DoubleDifferencePerSystemReference,
509 float_only_gating: true,
510 partial: PartialResolution::Disabled,
511 ratio_threshold,
512 }
513 }
514
515 /// The static PPP fixed policy (`precise_positioning::fixed`): undifferenced
516 /// per-satellite ambiguities, no constellation gating, full set or nothing.
517 pub const fn ppp(ratio_threshold: f64) -> Self {
518 Self {
519 differencing: DifferencingMode::Undifferenced,
520 float_only_gating: false,
521 partial: PartialResolution::Disabled,
522 ratio_threshold,
523 }
524 }
525}
526
527/// The operation order used to normalize one residual against its weight before
528/// the sigma comparison in a per-residual screen. Naming the order keeps each
529/// screen bit-identical while the formula lives in exactly one place
530/// ([`crate::estimation::substrate::qc::normalized_residual`]).
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
532pub enum ResidualNormRecipe {
533 /// `value · weight` where `weight` is an inverse double-difference *variance*
534 /// (`1/(sigma_sat^2 + sigma_ref^2)`), so the normalized innovation is
535 /// `value / sigma^2`. The RTK sequential information filter, whose DD rows
536 /// weight by inverse variance, screens its predicted innovations this way.
537 RtkInverseVarianceInnovation,
538 /// `value · weight` where `weight` is an inverse *sigma*
539 /// (`1/sqrt(sigma_sat^2 + sigma_ref^2)`), so the normalized residual is
540 /// `value / sigma`. The RTK static float/fixed least-squares baselines, whose
541 /// DD rows weight by inverse sigma, screen their post-fit residuals this way.
542 RtkInverseSigmaResidual,
543 /// `|value| · sqrt(weight)` where `weight` is an inverse *sigma* (`1/sigma`):
544 /// the residual magnitude times the square root of the inverse-sigma weight.
545 /// The PPP float leave-one-out screen (PPP rows weight by inverse sigma, as
546 /// `MeasurementWeights` documents).
547 PppInverseSigmaMagnitude,
548}
549
550/// The residual-screen family a strategy applies after (or, for the filter,
551/// before) a solve. Strategy DATA for the P4 selector; the chi-square variant is
552/// the SPP RAIM aggregate test, the rest are per-residual sigma screens that
553/// share [`crate::estimation::substrate::qc::normalized_residual`].
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
555pub enum ScreenKind {
556 /// SPP RAIM: aggregate chi-square on the weighted residual sum, then FDE
557 /// leave-one-out exclusion (`quality::raim` / `quality::fde`).
558 RaimChiSquare,
559 /// RTK static fixed: worst information-weighted residual vs a sigma gate,
560 /// excluding the worst satellite within a budget
561 /// (`rtk_filter::fixed::solve_fixed_baseline_validated`).
562 RtkFixedResidualValidation,
563 /// RTK sequential filter: information-weighted innovation screen on predicted
564 /// DD rows, masking rejected rows and coasting (`rtk_filter::update`).
565 RtkSequentialInnovation,
566 /// PPP float: worst studentized residual vs a sigma gate, leave-one-out prune
567 /// and re-solve while WRMS improves (`precise_positioning::float`).
568 PppFloatLeaveOneOut,
569}
570
571impl ScreenKind {
572 /// The per-residual normalization op-order this screen uses, or `None` for
573 /// the aggregate chi-square RAIM screen (which scores the weighted residual
574 /// sum, not individual residuals).
575 pub const fn residual_norm(self) -> Option<ResidualNormRecipe> {
576 match self {
577 Self::RaimChiSquare => None,
578 Self::RtkFixedResidualValidation => Some(ResidualNormRecipe::RtkInverseSigmaResidual),
579 Self::RtkSequentialInnovation => Some(ResidualNormRecipe::RtkInverseVarianceInnovation),
580 Self::PppFloatLeaveOneOut => Some(ResidualNormRecipe::PppInverseSigmaMagnitude),
581 }
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn defaults_name_current_spp_behavior() {
591 // The per-stage defaults are the SPP reference op-orders, so an
592 // unspecified recipe reproduces the current SPP path.
593 assert_eq!(EstimationRecipe::default(), EstimationRecipe::spp());
594 assert_eq!(
595 RangeRecipe::default(),
596 RangeRecipe::SppMeasuredPseudorangeFixedIter
597 );
598 assert_eq!(SagnacRecipe::default(), SagnacRecipe::ClosedFormZRotation);
599 assert_eq!(FrameRecipe::default(), FrameRecipe::SppSkyfieldAuThreeIter);
600 assert_eq!(
601 NormalRecipe::default(),
602 NormalRecipe::SppWeightedResidualFiniteDifference
603 );
604 assert_eq!(SolverRecipe::default(), SolverRecipe::NalgebraTrfLegacy);
605 assert_eq!(StrategyId::default(), StrategyId::spp_reference());
606 }
607
608 #[test]
609 fn strategy_constructors_match_reference_targets() {
610 assert_eq!(
611 StrategyId::spp_reference(),
612 StrategyId::Reference {
613 technique: Technique::Spp,
614 target: ReferenceTarget::Skyfield,
615 }
616 );
617 assert_eq!(
618 StrategyId::rtk_reference(),
619 StrategyId::Reference {
620 technique: Technique::Rtk,
621 target: ReferenceTarget::Rtklib,
622 }
623 );
624 assert_eq!(
625 StrategyId::ppp_reference(),
626 StrategyId::Reference {
627 technique: Technique::Ppp,
628 target: ReferenceTarget::PppOracle,
629 }
630 );
631 }
632
633 #[test]
634 fn for_reference_selects_each_supported_pairs_recipe() {
635 assert_eq!(
636 EstimationRecipe::for_reference(Technique::Spp, ReferenceTarget::Skyfield),
637 Some(EstimationRecipe::spp())
638 );
639 assert_eq!(
640 EstimationRecipe::for_reference(Technique::Rtk, ReferenceTarget::Rtklib),
641 Some(EstimationRecipe::rtk())
642 );
643 assert_eq!(
644 EstimationRecipe::for_reference(Technique::Ppp, ReferenceTarget::PppOracle),
645 Some(EstimationRecipe::ppp())
646 );
647 }
648
649 #[test]
650 fn owned_deterministic_recipe_swaps_only_the_solver() {
651 let owned = EstimationRecipe::spp_owned_deterministic();
652 assert_eq!(owned.solver, SolverRecipe::OwnedDeterministicTrf);
653 // Every non-solver stage is the SPP reference op-order.
654 assert_eq!(
655 EstimationRecipe {
656 solver: SolverRecipe::NalgebraTrfLegacy,
657 ..owned
658 },
659 EstimationRecipe::spp()
660 );
661 assert_eq!(
662 EstimationRecipe::for_reference(Technique::Spp, ReferenceTarget::OwnedDeterministic),
663 Some(owned)
664 );
665 }
666
667 #[test]
668 fn canonical_spp_recipe_uses_the_rigorous_op_orders() {
669 let canonical = EstimationRecipe::canonical_spp();
670 // Range: full iterative light-time with closed-form Sagnac, not the SPP
671 // reference's fixed-iteration measured-pseudorange recipe.
672 assert_eq!(
673 canonical.range,
674 RangeRecipe::CanonicalLightTimeClosedFormSagnac
675 );
676 assert_ne!(canonical.range, EstimationRecipe::spp().range);
677 // Frame: one consistent meters-native WGS84 basis, not the Skyfield AU
678 // path.
679 assert_eq!(canonical.frame, FrameRecipe::CanonicalWgs84);
680 assert_ne!(canonical.frame, EstimationRecipe::spp().frame);
681 // Sagnac stays the closed-form Z-rotation (the rigorous form the SPP
682 // reference already uses); the canonical divergence is never a
683 // first-order scalar Sagnac.
684 assert_eq!(canonical.sagnac, SagnacRecipe::ClosedFormZRotation);
685 assert_ne!(canonical.sagnac, SagnacRecipe::RtklibFirstOrderScalar);
686 // Solver: the owned deterministic factorization, for run-to-run
687 // determinism on a pinned build.
688 assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicTrf);
689 assert_eq!(
690 EstimationRecipe::for_canonical(Technique::Spp),
691 Some(canonical)
692 );
693 }
694
695 #[test]
696 fn canonical_rtk_recipe_uses_the_square_root_solve() {
697 let canonical = EstimationRecipe::canonical_rtk();
698 // Normal + solver: the owned Cholesky square-root information solve, not
699 // the reference RTK first-tie Gaussian elimination.
700 assert_eq!(canonical.normal, NormalRecipe::CanonicalSquareRoot);
701 assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicCholesky);
702 assert_ne!(canonical.normal, EstimationRecipe::rtk().normal);
703 assert_ne!(canonical.solver, EstimationRecipe::rtk().solver);
704 // Measurement physics stays the RTK reference double-difference model: the
705 // canonical RTK divergence is in the linear algebra, not the observation
706 // model, so range/sagnac/frame match the reference.
707 assert_eq!(canonical.range, EstimationRecipe::rtk().range);
708 assert_eq!(canonical.sagnac, EstimationRecipe::rtk().sagnac);
709 assert_eq!(canonical.frame, EstimationRecipe::rtk().frame);
710 assert_eq!(
711 EstimationRecipe::for_canonical(Technique::Rtk),
712 Some(canonical)
713 );
714 }
715
716 #[test]
717 fn canonical_ppp_recipe_uses_the_square_root_solve() {
718 let canonical = EstimationRecipe::canonical_ppp();
719 // Normal + solver: the owned Cholesky square-root information solve, not
720 // the reference PPP dense last-tie Gaussian elimination.
721 assert_eq!(canonical.normal, NormalRecipe::CanonicalSquareRoot);
722 assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicCholesky);
723 assert_ne!(canonical.normal, EstimationRecipe::ppp().normal);
724 assert_ne!(canonical.solver, EstimationRecipe::ppp().solver);
725 // Measurement physics stays the PPP reference undifferenced model: the
726 // canonical PPP divergence is in the linear algebra, not the observation
727 // model, so range/sagnac/frame match the reference.
728 assert_eq!(canonical.range, EstimationRecipe::ppp().range);
729 assert_eq!(canonical.sagnac, EstimationRecipe::ppp().sagnac);
730 assert_eq!(canonical.frame, EstimationRecipe::ppp().frame);
731 // Canonical RTK and PPP share the square-root normal + owned Cholesky
732 // solver (the same numerically rigorous SPD op-order).
733 assert_eq!(canonical.normal, EstimationRecipe::canonical_rtk().normal);
734 assert_eq!(canonical.solver, EstimationRecipe::canonical_rtk().solver);
735 assert_eq!(
736 EstimationRecipe::for_canonical(Technique::Ppp),
737 Some(canonical)
738 );
739 }
740
741 #[test]
742 fn for_canonical_wires_all_three_techniques() {
743 assert_eq!(
744 EstimationRecipe::for_canonical(Technique::Spp),
745 Some(EstimationRecipe::canonical_spp())
746 );
747 assert_eq!(
748 EstimationRecipe::for_canonical(Technique::Rtk),
749 Some(EstimationRecipe::canonical_rtk())
750 );
751 assert_eq!(
752 EstimationRecipe::for_canonical(Technique::Ppp),
753 Some(EstimationRecipe::canonical_ppp())
754 );
755 }
756
757 #[test]
758 fn for_reference_rejects_impossible_pairs() {
759 // Cross-technique oracles and the unwired scipy reference are not
760 // supported reference strategies.
761 for (technique, target) in [
762 (Technique::Spp, ReferenceTarget::Rtklib),
763 (Technique::Spp, ReferenceTarget::PppOracle),
764 (Technique::Spp, ReferenceTarget::Scipy),
765 (Technique::Rtk, ReferenceTarget::Skyfield),
766 (Technique::Rtk, ReferenceTarget::OwnedDeterministic),
767 (Technique::Rtk, ReferenceTarget::PppOracle),
768 (Technique::Ppp, ReferenceTarget::Skyfield),
769 (Technique::Ppp, ReferenceTarget::OwnedDeterministic),
770 ] {
771 assert_eq!(
772 EstimationRecipe::for_reference(technique, target),
773 None,
774 "{technique:?} + {target:?} must be rejected"
775 );
776 }
777 }
778
779 #[test]
780 fn reference_ambiguity_policies_name_current_behavior() {
781 let rtk_static = AmbiguityIdPolicy::rtk_static(3.0, 4);
782 assert_eq!(
783 rtk_static.differencing,
784 DifferencingMode::DoubleDifferencePerSystemReference
785 );
786 assert!(rtk_static.float_only_gating);
787 assert_eq!(
788 rtk_static.partial,
789 PartialResolution::Exhaustive { min_ambiguities: 4 }
790 );
791
792 let rtk_seq = AmbiguityIdPolicy::rtk_sequential(3.0);
793 assert_eq!(
794 rtk_seq.differencing,
795 DifferencingMode::DoubleDifferencePerSystemReference
796 );
797 assert!(rtk_seq.float_only_gating);
798 assert_eq!(rtk_seq.partial, PartialResolution::Disabled);
799
800 let ppp = AmbiguityIdPolicy::ppp(2.5);
801 assert_eq!(ppp.differencing, DifferencingMode::Undifferenced);
802 assert!(!ppp.float_only_gating);
803 assert_eq!(ppp.partial, PartialResolution::Disabled);
804 }
805
806 #[test]
807 fn rtk_and_ppp_id_policies_differ_only_in_data() {
808 // Same LAMBDA kernel, different identity/eligibility data: the two stacks
809 // are no longer separate algorithm trees, only different policy values.
810 let rtk = AmbiguityIdPolicy::rtk_static(3.0, 1);
811 let ppp = AmbiguityIdPolicy::ppp(3.0);
812 assert_ne!(rtk.differencing, ppp.differencing);
813 assert_ne!(rtk.float_only_gating, ppp.float_only_gating);
814 assert_ne!(rtk.partial, ppp.partial);
815 }
816
817 #[test]
818 fn screen_kinds_select_their_normalization_order() {
819 assert_eq!(ScreenKind::RaimChiSquare.residual_norm(), None);
820 assert_eq!(
821 ScreenKind::RtkFixedResidualValidation.residual_norm(),
822 Some(ResidualNormRecipe::RtkInverseSigmaResidual)
823 );
824 assert_eq!(
825 ScreenKind::RtkSequentialInnovation.residual_norm(),
826 Some(ResidualNormRecipe::RtkInverseVarianceInnovation)
827 );
828 assert_eq!(
829 ScreenKind::PppFloatLeaveOneOut.residual_norm(),
830 Some(ResidualNormRecipe::PppInverseSigmaMagnitude)
831 );
832 }
833
834 #[test]
835 fn each_strategy_selects_a_distinct_solver_order() {
836 // The three reference strategies must not collapse onto one solver
837 // op-order; that distinction is what preserves their separate goldens.
838 assert_ne!(
839 EstimationRecipe::spp().solver,
840 EstimationRecipe::rtk().solver
841 );
842 assert_ne!(
843 EstimationRecipe::rtk().solver,
844 EstimationRecipe::ppp().solver
845 );
846 assert_ne!(
847 EstimationRecipe::spp().solver,
848 EstimationRecipe::ppp().solver
849 );
850 }
851}