Skip to main content

sidereon_core/estimation/
strategies.rs

1//! Runtime-selectable estimation strategies (Phase-2 P4, driving in 2b).
2//!
3//! P0-P3 named the operation-order recipes ([`super::recipe`]) and routed the
4//! frame/range/normal/ambiguity/qc kernels of the three reference stacks through
5//! the shared [`super::substrate`]. This module is the runtime selector that ties
6//! those names together: [`estimate`] takes an [`EstimateInput`] plus an
7//! [`EstimateOptions`] carrying a [`StrategyId`], resolves the strategy into its
8//! [`EstimationRecipe`] and screen/ambiguity policy DATA, and DRIVES the shared
9//! per-technique implementation with that recipe.
10//!
11//! [`estimate`] is the driver, not a facade: each branch passes `resolved.recipe`
12//! into the technique's shared runner (`spp::run`, `rtk_filter::run_float` /
13//! `run_fixed_validated`, `precise_positioning::run_float_epochs` /
14//! `run_fixed_from_float`), which consumes the recipe to select its operation
15//! order (the SPP trust-region [`SolverRecipe`] via `spp::solve_with_solver`, the
16//! RTK/PPP normal-equation [`NormalRecipe`] via the shared
17//! [`super::substrate::normal::NormalAssembler`]). The old public entry points
18//! (`spp::solve_with_policy`, `rtk_filter::solve_float_baseline` /
19//! `solve_fixed_baseline_validated`, `precise_positioning::solve_float_epochs` /
20//! `solve_fixed_from_float`) are now thin compatibility wrappers that call
21//! [`estimate`] under their reference strategy. For a reference recipe every
22//! selected operation order equals the value the legacy path hard-coded, so
23//! results are bit-identical and existing 0-ULP goldens are unchanged, with one
24//! exception: the static PPP reference path now eliminates per-epoch receiver
25//! clocks from the normal equations (pinned equivalent to the unreduced dense
26//! solve and its inverse in `precise_positioning::normal` tests), so PPP
27//! goldens were re-frozen at the reduced path's bits.
28//!
29//! `Canonical` strategies (the bounded-tolerance "best" model) are the P6
30//! additive strategy, and all three techniques are now wired. Resolving
31//! [`StrategyId::Canonical`] with [`Technique::Spp`] drives `spp::run` under the
32//! [`EstimationRecipe::canonical_spp`] recipe (the IERS-rigorous light-time /
33//! WGS84-geodetic op-order on the owned deterministic solver); with
34//! [`Technique::Rtk`] drives the RTK runners under
35//! [`EstimationRecipe::canonical_rtk`] (the owned Cholesky square-root-information
36//! solve); and with [`Technique::Ppp`] drives the PPP runners under
37//! [`EstimationRecipe::canonical_ppp`] (the same owned Cholesky
38//! square-root-information solve on the dense weighted PPP normal system).
39//! [`EstimateError::CanonicalUnavailable`] is retained as the resolver's
40//! not-yet-implemented surface but no technique currently produces it.
41
42use super::recipe::{
43    AmbiguityIdPolicy, EstimationRecipe, ReferenceTarget, ScreenKind, StrategyId, Technique,
44};
45use crate::observables::ObservableEphemerisSource;
46use crate::precise_positioning::{
47    FixedSolution, FixedSolveConfig, FixedSolveError, FloatEpoch, FloatSolution, FloatSolveConfig,
48    FloatSolveError as PppFloatSolveError, FloatState,
49};
50use crate::rtk_filter::{
51    AmbiguitySet, Epoch, FloatBaselineSolution, FloatSolveError as RtkFloatSolveError,
52    FloatSolveOpts, MeasModel, ReceiverAntennaCorrections, ValidatedFixedBaselineSolution,
53    ValidatedFixedSolveError, ValidatedFixedSolveOpts,
54};
55use crate::spp::{EphemerisSource, ReceiverSolution, SolveInputs, SolvePolicy, SolvePolicyError};
56
57/// Runtime selection options for [`estimate`]. Defaults to the SPP reference
58/// strategy ([`StrategyId::default`]), matching the per-stage recipe defaults.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
60pub struct EstimateOptions {
61    pub strategy: StrategyId,
62}
63
64impl EstimateOptions {
65    /// Options selecting `strategy`.
66    pub const fn new(strategy: StrategyId) -> Self {
67        Self { strategy }
68    }
69}
70
71/// The unified input to [`estimate`], one variant per technique entry. Each
72/// variant carries exactly the arguments the shared per-technique runner needs;
73/// [`estimate`] drives that runner with the resolved recipe. RTK and PPP expose a
74/// float and a fixed entry; both map to the same [`Technique`].
75#[allow(clippy::large_enum_variant)]
76pub enum EstimateInput<'a> {
77    /// SPP under the public validation/orchestration policy
78    /// (`spp::solve_with_policy`).
79    Spp {
80        eph: &'a dyn EphemerisSource,
81        inputs: &'a SolveInputs,
82        with_geodetic: bool,
83        policy: SolvePolicy,
84    },
85    /// Static multi-epoch float RTK baseline (`rtk_filter::solve_float_baseline`).
86    RtkFloat {
87        epochs: &'a [Epoch],
88        base: [f64; 3],
89        ambiguity_ids: &'a [String],
90        initial_baseline_m: [f64; 3],
91        model: &'a MeasModel,
92        opts: FloatSolveOpts,
93        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
94    },
95    /// Static fixed RTK baseline with residual validation/FDE
96    /// (`rtk_filter::solve_fixed_baseline_validated`).
97    RtkFixed {
98        epochs: &'a [Epoch],
99        base: [f64; 3],
100        initial_ambiguities: AmbiguitySet<'a>,
101        initial_baseline_m: [f64; 3],
102        model: &'a MeasModel,
103        opts: ValidatedFixedSolveOpts,
104        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
105    },
106    /// Static multi-epoch float PPP arc
107    /// (`precise_positioning::solve_float_epochs`).
108    PppFloat {
109        source: &'a dyn ObservableEphemerisSource,
110        epochs: &'a [FloatEpoch],
111        initial_state: FloatState,
112        config: FloatSolveConfig,
113    },
114    /// Integer-fixed PPP from an existing float solution
115    /// (`precise_positioning::solve_fixed_from_float`).
116    PppFixed {
117        source: &'a dyn ObservableEphemerisSource,
118        epochs: &'a [FloatEpoch],
119        float_solution: FloatSolution,
120        config: FixedSolveConfig,
121    },
122}
123
124impl EstimateInput<'_> {
125    /// The estimation technique this input runs.
126    pub fn technique(&self) -> Technique {
127        match self {
128            Self::Spp { .. } => Technique::Spp,
129            Self::RtkFloat { .. } | Self::RtkFixed { .. } => Technique::Rtk,
130            Self::PppFloat { .. } | Self::PppFixed { .. } => Technique::Ppp,
131        }
132    }
133}
134
135/// The unified result of [`estimate`], wrapping each reference entry point's
136/// existing return type unchanged. The payloads are heterogeneously sized
137/// (RTK/PPP solutions are large), so each is boxed to keep the enum
138/// pointer-sized regardless of which technique ran.
139#[derive(Debug, Clone)]
140pub enum EstimateOutput {
141    Spp(Box<ReceiverSolution>),
142    RtkFloat(Box<FloatBaselineSolution>),
143    RtkFixed(Box<ValidatedFixedBaselineSolution>),
144    PppFloat(Box<FloatSolution>),
145    PppFixed(Box<FixedSolution>),
146}
147
148/// Failure of [`estimate`]: a selection error, or the wrapped error of the
149/// dispatched reference entry point.
150#[derive(Debug)]
151pub enum EstimateError {
152    /// The selected strategy's technique does not match the input's technique
153    /// (e.g. an RTK strategy with an SPP input).
154    TechniqueMismatch {
155        strategy: Technique,
156        input: Technique,
157    },
158    /// A `Reference` strategy named a `target` that is not a supported reference
159    /// for its `technique` (e.g. an RTK technique against the Skyfield SPP
160    /// oracle, or the owned deterministic solver for a non-SPP technique). The
161    /// supported pairs are enumerated by [`EstimationRecipe::for_reference`].
162    IncompatibleTarget {
163        technique: Technique,
164        target: ReferenceTarget,
165    },
166    /// A `Canonical` strategy was selected for a technique whose canonical model
167    /// is not yet implemented. Canonical SPP, RTK, and PPP are all wired, so no
168    /// technique currently produces this; it is retained as the resolver's stable
169    /// not-yet-implemented surface for any future technique.
170    CanonicalUnavailable {
171        technique: Technique,
172    },
173    Spp(SolvePolicyError),
174    RtkFloat(RtkFloatSolveError),
175    RtkFixed(ValidatedFixedSolveError),
176    PppFloat(PppFloatSolveError),
177    PppFixed(FixedSolveError),
178}
179
180/// A [`StrategyId`] resolved into the selection DATA it runs under: the
181/// operation-order [`EstimationRecipe`] (P0-P2) and the residual-screen families
182/// (P3). The recipe is the current reference recipe for the technique, so a
183/// resolved reference strategy dispatches bit-identically to the existing path.
184#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct ResolvedStrategy {
186    pub id: StrategyId,
187    pub technique: Technique,
188    pub recipe: EstimationRecipe,
189    /// The residual-screen families this technique applies (P3 `ScreenKind`).
190    pub screens: &'static [ScreenKind],
191}
192
193impl ResolvedStrategy {
194    /// Resolve a runtime [`StrategyId`] into its recipe and screen policy.
195    /// `Reference` strategies resolve to the recipe for their `(technique,
196    /// target)` pair, rejecting an unsupported pair with
197    /// [`EstimateError::IncompatibleTarget`]; `Canonical` strategies resolve to
198    /// their canonical recipe ([`EstimationRecipe::for_canonical`]), rejecting a
199    /// technique whose canonical model is not yet implemented with
200    /// [`EstimateError::CanonicalUnavailable`].
201    pub fn resolve(id: StrategyId) -> Result<Self, EstimateError> {
202        match id {
203            StrategyId::Reference { technique, target } => {
204                let recipe = EstimationRecipe::for_reference(technique, target)
205                    .ok_or(EstimateError::IncompatibleTarget { technique, target })?;
206                Ok(Self {
207                    id,
208                    technique,
209                    recipe,
210                    screens: screens_for(technique),
211                })
212            }
213            StrategyId::Canonical { technique } => {
214                let recipe = EstimationRecipe::for_canonical(technique)
215                    .ok_or(EstimateError::CanonicalUnavailable { technique })?;
216                Ok(Self {
217                    id,
218                    technique,
219                    recipe,
220                    screens: screens_for(technique),
221                })
222            }
223        }
224    }
225
226    /// The integer-ambiguity identity policy (P3) this strategy resolves under,
227    /// parameterized by the runtime ratio threshold and (RTK only) partial-set
228    /// floor. `None` for SPP, which carries no integer ambiguities.
229    pub fn ambiguity_id_policy(
230        &self,
231        ratio_threshold: f64,
232        partial_min_ambiguities: usize,
233    ) -> Option<AmbiguityIdPolicy> {
234        match self.technique {
235            Technique::Spp => None,
236            Technique::Rtk => Some(AmbiguityIdPolicy::rtk_static(
237                ratio_threshold,
238                partial_min_ambiguities,
239            )),
240            Technique::Ppp => Some(AmbiguityIdPolicy::ppp(ratio_threshold)),
241        }
242    }
243}
244
245/// The residual-screen families a technique applies (P3 `ScreenKind`). RTK lists
246/// both the static fixed-baseline validation and the sequential-filter innovation
247/// screen, the two members of its screen family.
248const fn screens_for(technique: Technique) -> &'static [ScreenKind] {
249    match technique {
250        Technique::Spp => &[ScreenKind::RaimChiSquare],
251        Technique::Rtk => &[
252            ScreenKind::RtkFixedResidualValidation,
253            ScreenKind::RtkSequentialInnovation,
254        ],
255        Technique::Ppp => &[ScreenKind::PppFloatLeaveOneOut],
256    }
257}
258
259/// Run estimation under a runtime-selected [`StrategyId`].
260///
261/// Resolves `options.strategy` into its recipe/screen policy, checks that the
262/// strategy's technique matches `input`, then drives the technique's shared
263/// runner with `resolved.recipe`. The runner consumes the recipe to select its
264/// operation order; for a reference recipe every selected order equals the value
265/// the legacy path hard-coded, so the result is bit-identical and every existing
266/// 0-ULP golden is preserved.
267pub fn estimate(
268    input: EstimateInput<'_>,
269    options: EstimateOptions,
270) -> Result<EstimateOutput, EstimateError> {
271    let resolved = ResolvedStrategy::resolve(options.strategy)?;
272    let input_technique = input.technique();
273    if resolved.technique != input_technique {
274        return Err(EstimateError::TechniqueMismatch {
275            strategy: resolved.technique,
276            input: input_technique,
277        });
278    }
279
280    match input {
281        EstimateInput::Spp {
282            eph,
283            inputs,
284            with_geodetic,
285            policy,
286        } => crate::spp::run(&resolved.recipe, eph, inputs, with_geodetic, policy)
287            .map(|s| EstimateOutput::Spp(Box::new(s)))
288            .map_err(EstimateError::Spp),
289        EstimateInput::RtkFloat {
290            epochs,
291            base,
292            ambiguity_ids,
293            initial_baseline_m,
294            model,
295            opts,
296            receiver_antenna_corrections,
297        } => crate::rtk_filter::run_float(
298            &resolved.recipe,
299            crate::rtk_filter::MeasContext::new(base, model, receiver_antenna_corrections),
300            epochs,
301            ambiguity_ids,
302            initial_baseline_m,
303            opts,
304        )
305        .map(|s| EstimateOutput::RtkFloat(Box::new(s)))
306        .map_err(EstimateError::RtkFloat),
307        EstimateInput::RtkFixed {
308            epochs,
309            base,
310            initial_ambiguities,
311            initial_baseline_m,
312            model,
313            opts,
314            receiver_antenna_corrections,
315        } => crate::rtk_filter::run_fixed_validated(
316            &resolved.recipe,
317            crate::rtk_filter::MeasContext::new(base, model, receiver_antenna_corrections),
318            epochs,
319            initial_ambiguities,
320            initial_baseline_m,
321            opts,
322        )
323        .map(|s| EstimateOutput::RtkFixed(Box::new(s)))
324        .map_err(EstimateError::RtkFixed),
325        EstimateInput::PppFloat {
326            source,
327            epochs,
328            initial_state,
329            config,
330        } => crate::precise_positioning::run_float_epochs(
331            &resolved.recipe,
332            source,
333            epochs,
334            initial_state,
335            config,
336        )
337        .map(|s| EstimateOutput::PppFloat(Box::new(s)))
338        .map_err(EstimateError::PppFloat),
339        EstimateInput::PppFixed {
340            source,
341            epochs,
342            float_solution,
343            config,
344        } => crate::precise_positioning::run_fixed_from_float(
345            &resolved.recipe,
346            source,
347            epochs,
348            float_solution,
349            config,
350        )
351        .map(|s| EstimateOutput::PppFixed(Box::new(s)))
352        .map_err(EstimateError::PppFixed),
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::estimation::recipe::{ReferenceTarget, ResidualNormRecipe};
360
361    #[test]
362    fn input_technique_matches_each_variant() {
363        // Compile-time-ish guard that the float/fixed entries share a technique.
364        assert_eq!(
365            screens_for(Technique::Rtk),
366            &[
367                ScreenKind::RtkFixedResidualValidation,
368                ScreenKind::RtkSequentialInnovation,
369            ]
370        );
371        assert_eq!(screens_for(Technique::Spp), &[ScreenKind::RaimChiSquare]);
372        assert_eq!(
373            screens_for(Technique::Ppp),
374            &[ScreenKind::PppFloatLeaveOneOut]
375        );
376    }
377
378    #[test]
379    fn resolve_reference_strategies_to_their_recipe_and_screens() {
380        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
381        assert_eq!(spp.technique, Technique::Spp);
382        assert_eq!(spp.recipe, EstimationRecipe::spp());
383        assert_eq!(spp.screens, &[ScreenKind::RaimChiSquare]);
384        assert!(spp.ambiguity_id_policy(3.0, 1).is_none());
385
386        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
387        assert_eq!(rtk.technique, Technique::Rtk);
388        assert_eq!(rtk.recipe, EstimationRecipe::rtk());
389        let rtk_policy = rtk.ambiguity_id_policy(3.0, 4).unwrap();
390        assert_eq!(rtk_policy, AmbiguityIdPolicy::rtk_static(3.0, 4));
391
392        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
393        assert_eq!(ppp.technique, Technique::Ppp);
394        assert_eq!(ppp.recipe, EstimationRecipe::ppp());
395        assert_eq!(ppp.screens, &[ScreenKind::PppFloatLeaveOneOut]);
396        let ppp_policy = ppp.ambiguity_id_policy(2.5, 0).unwrap();
397        assert_eq!(ppp_policy, AmbiguityIdPolicy::ppp(2.5));
398    }
399
400    #[test]
401    fn each_resolved_strategy_screen_uses_its_own_residual_norm() {
402        // Each resolved screen maps to its committed normalization recipe: the
403        // RTK static baseline to the inverse-sigma residual, the RTK sequential
404        // filter to the inverse-variance innovation, PPP to the inverse-sigma
405        // root. SPP's aggregate RAIM screen has no per-residual recipe.
406        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
407        assert_eq!(
408            rtk.screens
409                .iter()
410                .map(|screen| screen.residual_norm())
411                .collect::<Vec<_>>(),
412            vec![
413                Some(ResidualNormRecipe::RtkInverseSigmaResidual),
414                Some(ResidualNormRecipe::RtkInverseVarianceInnovation),
415            ]
416        );
417        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
418        assert_eq!(
419            ppp.screens[0].residual_norm(),
420            Some(ResidualNormRecipe::PppInverseSigmaMagnitude)
421        );
422        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
423        assert_eq!(spp.screens[0].residual_norm(), None);
424    }
425
426    #[test]
427    fn resolve_owned_deterministic_spp_selects_the_owned_solver() {
428        use crate::estimation::recipe::SolverRecipe;
429
430        let owned = ResolvedStrategy::resolve(StrategyId::spp_owned_deterministic()).unwrap();
431        assert_eq!(owned.technique, Technique::Spp);
432        assert_eq!(owned.recipe.solver, SolverRecipe::OwnedDeterministicTrf);
433        assert_eq!(owned.recipe, EstimationRecipe::spp_owned_deterministic());
434        // Same SPP screen policy as the Skyfield reference strategy.
435        assert_eq!(owned.screens, &[ScreenKind::RaimChiSquare]);
436    }
437
438    #[test]
439    fn resolve_rejects_incompatible_technique_target_pairs() {
440        for (technique, target) in [
441            (Technique::Spp, ReferenceTarget::Rtklib),
442            (Technique::Spp, ReferenceTarget::Scipy),
443            (Technique::Rtk, ReferenceTarget::OwnedDeterministic),
444            (Technique::Ppp, ReferenceTarget::Skyfield),
445        ] {
446            let err =
447                ResolvedStrategy::resolve(StrategyId::Reference { technique, target }).unwrap_err();
448            match err {
449                EstimateError::IncompatibleTarget {
450                    technique: t,
451                    target: g,
452                } => {
453                    assert_eq!(t, technique);
454                    assert_eq!(g, target);
455                }
456                other => {
457                    panic!("{technique:?} + {target:?} should be IncompatibleTarget, got {other:?}")
458                }
459            }
460        }
461    }
462
463    #[test]
464    fn canonical_spp_resolves_to_the_canonical_recipe() {
465        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
466            technique: Technique::Spp,
467        })
468        .expect("canonical SPP resolves");
469        assert_eq!(resolved.technique, Technique::Spp);
470        assert_eq!(resolved.recipe, EstimationRecipe::canonical_spp());
471        // Canonical SPP carries the SPP screen policy (no integer ambiguities).
472        assert_eq!(resolved.screens, &[ScreenKind::RaimChiSquare]);
473        assert!(resolved.ambiguity_id_policy(3.0, 1).is_none());
474    }
475
476    #[test]
477    fn canonical_rtk_resolves_to_the_canonical_recipe() {
478        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
479            technique: Technique::Rtk,
480        })
481        .expect("canonical RTK resolves");
482        assert_eq!(resolved.technique, Technique::Rtk);
483        assert_eq!(resolved.recipe, EstimationRecipe::canonical_rtk());
484        // The owned Cholesky square-root information solve, not the reference
485        // first-tie Gaussian elimination.
486        assert_eq!(
487            resolved.recipe.normal,
488            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
489        );
490        assert_eq!(
491            resolved.recipe.solver,
492            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
493        );
494    }
495
496    #[test]
497    fn canonical_ppp_resolves_to_the_canonical_recipe() {
498        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
499            technique: Technique::Ppp,
500        })
501        .expect("canonical PPP resolves");
502        assert_eq!(resolved.technique, Technique::Ppp);
503        assert_eq!(resolved.recipe, EstimationRecipe::canonical_ppp());
504        // The owned Cholesky square-root information solve on the dense PPP normal
505        // system, not the reference dense last-tie Gaussian elimination.
506        assert_eq!(
507            resolved.recipe.normal,
508            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
509        );
510        assert_eq!(
511            resolved.recipe.solver,
512            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
513        );
514        // Canonical PPP carries the PPP screen policy.
515        assert_eq!(resolved.screens, &[ScreenKind::PppFloatLeaveOneOut]);
516        let policy = resolved.ambiguity_id_policy(2.5, 0).unwrap();
517        assert_eq!(policy, AmbiguityIdPolicy::ppp(2.5));
518    }
519
520    #[test]
521    fn default_options_select_spp_reference() {
522        let resolved = ResolvedStrategy::resolve(EstimateOptions::default().strategy).unwrap();
523        assert_eq!(
524            resolved.id,
525            StrategyId::Reference {
526                technique: Technique::Spp,
527                target: ReferenceTarget::Skyfield,
528            }
529        );
530        assert_eq!(resolved.recipe, EstimationRecipe::spp());
531    }
532}