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