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