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`).
246const fn screens_for(technique: Technique) -> &'static [ScreenKind] {
247    match technique {
248        Technique::Spp => &[ScreenKind::RaimChiSquare],
249        Technique::Rtk => &[ScreenKind::RtkFixedResidualValidation],
250        Technique::Ppp => &[ScreenKind::PppFloatLeaveOneOut],
251    }
252}
253
254/// Run estimation under a runtime-selected [`StrategyId`].
255///
256/// Resolves `options.strategy` into its recipe/screen policy, checks that the
257/// strategy's technique matches `input`, then drives the technique's shared
258/// runner with `resolved.recipe`. The runner consumes the recipe to select its
259/// operation order; for a reference recipe every selected order equals the value
260/// the legacy path hard-coded, so the result is bit-identical and every existing
261/// 0-ULP golden is preserved.
262pub fn estimate(
263    input: EstimateInput<'_>,
264    options: EstimateOptions,
265) -> Result<EstimateOutput, EstimateError> {
266    let resolved = ResolvedStrategy::resolve(options.strategy)?;
267    let input_technique = input.technique();
268    if resolved.technique != input_technique {
269        return Err(EstimateError::TechniqueMismatch {
270            strategy: resolved.technique,
271            input: input_technique,
272        });
273    }
274
275    match input {
276        EstimateInput::Spp {
277            eph,
278            inputs,
279            with_geodetic,
280            policy,
281        } => crate::spp::run(&resolved.recipe, eph, inputs, with_geodetic, policy)
282            .map(|s| EstimateOutput::Spp(Box::new(s)))
283            .map_err(EstimateError::Spp),
284        EstimateInput::RtkFloat {
285            epochs,
286            base,
287            ambiguity_ids,
288            initial_baseline_m,
289            model,
290            opts,
291            receiver_antenna_corrections,
292        } => crate::rtk_filter::run_float(
293            &resolved.recipe,
294            crate::rtk_filter::MeasContext::new(base, model, receiver_antenna_corrections),
295            epochs,
296            ambiguity_ids,
297            initial_baseline_m,
298            opts,
299        )
300        .map(|s| EstimateOutput::RtkFloat(Box::new(s)))
301        .map_err(EstimateError::RtkFloat),
302        EstimateInput::RtkFixed {
303            epochs,
304            base,
305            initial_ambiguities,
306            initial_baseline_m,
307            model,
308            opts,
309            receiver_antenna_corrections,
310        } => crate::rtk_filter::run_fixed_validated(
311            &resolved.recipe,
312            crate::rtk_filter::MeasContext::new(base, model, receiver_antenna_corrections),
313            epochs,
314            initial_ambiguities,
315            initial_baseline_m,
316            opts,
317        )
318        .map(|s| EstimateOutput::RtkFixed(Box::new(s)))
319        .map_err(EstimateError::RtkFixed),
320        EstimateInput::PppFloat {
321            source,
322            epochs,
323            initial_state,
324            config,
325        } => crate::precise_positioning::run_float_epochs(
326            &resolved.recipe,
327            source,
328            epochs,
329            initial_state,
330            config,
331        )
332        .map(|s| EstimateOutput::PppFloat(Box::new(s)))
333        .map_err(EstimateError::PppFloat),
334        EstimateInput::PppFixed {
335            source,
336            epochs,
337            float_solution,
338            config,
339        } => crate::precise_positioning::run_fixed_from_float(
340            &resolved.recipe,
341            source,
342            epochs,
343            float_solution,
344            config,
345        )
346        .map(|s| EstimateOutput::PppFixed(Box::new(s)))
347        .map_err(EstimateError::PppFixed),
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::estimation::recipe::{ReferenceTarget, ResidualNormRecipe};
355
356    #[test]
357    fn input_technique_matches_each_variant() {
358        // Compile-time-ish guard that the float/fixed entries share a technique.
359        assert_eq!(
360            screens_for(Technique::Rtk),
361            &[ScreenKind::RtkFixedResidualValidation]
362        );
363        assert_eq!(screens_for(Technique::Spp), &[ScreenKind::RaimChiSquare]);
364        assert_eq!(
365            screens_for(Technique::Ppp),
366            &[ScreenKind::PppFloatLeaveOneOut]
367        );
368    }
369
370    #[test]
371    fn resolve_reference_strategies_to_their_recipe_and_screens() {
372        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
373        assert_eq!(spp.technique, Technique::Spp);
374        assert_eq!(spp.recipe, EstimationRecipe::spp());
375        assert_eq!(spp.screens, &[ScreenKind::RaimChiSquare]);
376        assert!(spp.ambiguity_id_policy(3.0, 1).is_none());
377
378        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
379        assert_eq!(rtk.technique, Technique::Rtk);
380        assert_eq!(rtk.recipe, EstimationRecipe::rtk());
381        let rtk_policy = rtk.ambiguity_id_policy(3.0, 4).unwrap();
382        assert_eq!(rtk_policy, AmbiguityIdPolicy::rtk_static(3.0, 4));
383
384        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
385        assert_eq!(ppp.technique, Technique::Ppp);
386        assert_eq!(ppp.recipe, EstimationRecipe::ppp());
387        assert_eq!(ppp.screens, &[ScreenKind::PppFloatLeaveOneOut]);
388        let ppp_policy = ppp.ambiguity_id_policy(2.5, 0).unwrap();
389        assert_eq!(ppp_policy, AmbiguityIdPolicy::ppp(2.5));
390    }
391
392    #[test]
393    fn each_resolved_strategy_screen_uses_its_own_residual_norm() {
394        // Each resolved screen maps to its committed normalization recipe: the
395        // RTK static baseline to the inverse-sigma residual and PPP to the
396        // inverse-sigma root. SPP's aggregate RAIM screen has no per-residual
397        // recipe.
398        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
399        assert_eq!(
400            rtk.screens
401                .iter()
402                .map(|screen| screen.residual_norm())
403                .collect::<Vec<_>>(),
404            vec![Some(ResidualNormRecipe::RtkInverseSigmaResidual)]
405        );
406        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
407        assert_eq!(
408            ppp.screens[0].residual_norm(),
409            Some(ResidualNormRecipe::PppInverseSigmaMagnitude)
410        );
411        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
412        assert_eq!(spp.screens[0].residual_norm(), None);
413    }
414
415    #[test]
416    fn resolve_owned_deterministic_spp_selects_the_owned_solver() {
417        use crate::estimation::recipe::SolverRecipe;
418
419        let owned = ResolvedStrategy::resolve(StrategyId::spp_owned_deterministic()).unwrap();
420        assert_eq!(owned.technique, Technique::Spp);
421        assert_eq!(owned.recipe.solver, SolverRecipe::OwnedDeterministicTrf);
422        assert_eq!(owned.recipe, EstimationRecipe::spp_owned_deterministic());
423        // Same SPP screen policy as the Skyfield reference strategy.
424        assert_eq!(owned.screens, &[ScreenKind::RaimChiSquare]);
425    }
426
427    #[test]
428    fn resolve_rejects_incompatible_technique_target_pairs() {
429        for (technique, target) in [
430            (Technique::Spp, ReferenceTarget::Rtklib),
431            (Technique::Spp, ReferenceTarget::Scipy),
432            (Technique::Rtk, ReferenceTarget::OwnedDeterministic),
433            (Technique::Ppp, ReferenceTarget::Skyfield),
434        ] {
435            let err =
436                ResolvedStrategy::resolve(StrategyId::Reference { technique, target }).unwrap_err();
437            match err {
438                EstimateError::IncompatibleTarget {
439                    technique: t,
440                    target: g,
441                } => {
442                    assert_eq!(t, technique);
443                    assert_eq!(g, target);
444                }
445                other => {
446                    panic!("{technique:?} + {target:?} should be IncompatibleTarget, got {other:?}")
447                }
448            }
449        }
450    }
451
452    #[test]
453    fn canonical_spp_resolves_to_the_canonical_recipe() {
454        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
455            technique: Technique::Spp,
456        })
457        .expect("canonical SPP resolves");
458        assert_eq!(resolved.technique, Technique::Spp);
459        assert_eq!(resolved.recipe, EstimationRecipe::canonical_spp());
460        // Canonical SPP carries the SPP screen policy (no integer ambiguities).
461        assert_eq!(resolved.screens, &[ScreenKind::RaimChiSquare]);
462        assert!(resolved.ambiguity_id_policy(3.0, 1).is_none());
463    }
464
465    #[test]
466    fn canonical_rtk_resolves_to_the_canonical_recipe() {
467        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
468            technique: Technique::Rtk,
469        })
470        .expect("canonical RTK resolves");
471        assert_eq!(resolved.technique, Technique::Rtk);
472        assert_eq!(resolved.recipe, EstimationRecipe::canonical_rtk());
473        // The owned Cholesky square-root information solve, not the reference
474        // first-tie Gaussian elimination.
475        assert_eq!(
476            resolved.recipe.normal,
477            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
478        );
479        assert_eq!(
480            resolved.recipe.solver,
481            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
482        );
483    }
484
485    #[test]
486    fn canonical_ppp_resolves_to_the_canonical_recipe() {
487        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
488            technique: Technique::Ppp,
489        })
490        .expect("canonical PPP resolves");
491        assert_eq!(resolved.technique, Technique::Ppp);
492        assert_eq!(resolved.recipe, EstimationRecipe::canonical_ppp());
493        // The owned Cholesky square-root information solve on the dense PPP normal
494        // system, not the reference dense last-tie Gaussian elimination.
495        assert_eq!(
496            resolved.recipe.normal,
497            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
498        );
499        assert_eq!(
500            resolved.recipe.solver,
501            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
502        );
503        // Canonical PPP carries the PPP screen policy.
504        assert_eq!(resolved.screens, &[ScreenKind::PppFloatLeaveOneOut]);
505        let policy = resolved.ambiguity_id_policy(2.5, 0).unwrap();
506        assert_eq!(policy, AmbiguityIdPolicy::ppp(2.5));
507    }
508
509    #[test]
510    fn default_options_select_spp_reference() {
511        let resolved = ResolvedStrategy::resolve(EstimateOptions::default().strategy).unwrap();
512        assert_eq!(
513            resolved.id,
514            StrategyId::Reference {
515                technique: Technique::Spp,
516                target: ReferenceTarget::Skyfield,
517            }
518        );
519        assert_eq!(resolved.recipe, EstimationRecipe::spp());
520    }
521}