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 the
23//! result is bit-identical and every existing 0-ULP golden is unchanged.
24//!
25//! `Canonical` strategies (the bounded-tolerance "best" model) are the P6
26//! additive strategy, and all three techniques are now wired. Resolving
27//! [`StrategyId::Canonical`] with [`Technique::Spp`] drives `spp::run` under the
28//! [`EstimationRecipe::canonical_spp`] recipe (the IERS-rigorous light-time /
29//! WGS84-geodetic op-order on the owned deterministic solver); with
30//! [`Technique::Rtk`] drives the RTK runners under
31//! [`EstimationRecipe::canonical_rtk`] (the owned Cholesky square-root-information
32//! solve); and with [`Technique::Ppp`] drives the PPP runners under
33//! [`EstimationRecipe::canonical_ppp`] (the same owned Cholesky
34//! square-root-information solve on the dense weighted PPP normal system).
35//! [`EstimateError::CanonicalUnavailable`] is retained as the resolver's
36//! not-yet-implemented surface but no technique currently produces it.
37
38use super::recipe::{
39    AmbiguityIdPolicy, EstimationRecipe, ReferenceTarget, ScreenKind, StrategyId, Technique,
40};
41use crate::observables::ObservableEphemerisSource;
42use crate::precise_positioning::{
43    FixedSolution, FixedSolveConfig, FixedSolveError, FloatEpoch, FloatSolution, FloatSolveConfig,
44    FloatSolveError as PppFloatSolveError, FloatState,
45};
46use crate::rtk_filter::{
47    AmbiguitySet, Epoch, FloatBaselineSolution, FloatSolveError as RtkFloatSolveError,
48    FloatSolveOpts, MeasModel, ReceiverAntennaCorrections, ValidatedFixedBaselineSolution,
49    ValidatedFixedSolveError, ValidatedFixedSolveOpts,
50};
51use crate::spp::{EphemerisSource, ReceiverSolution, SolveInputs, SolvePolicy, SolvePolicyError};
52
53/// Runtime selection options for [`estimate`]. Defaults to the SPP reference
54/// strategy ([`StrategyId::default`]), matching the per-stage recipe defaults.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
56pub struct EstimateOptions {
57    pub strategy: StrategyId,
58}
59
60impl EstimateOptions {
61    /// Options selecting `strategy`.
62    pub const fn new(strategy: StrategyId) -> Self {
63        Self { strategy }
64    }
65}
66
67/// The unified input to [`estimate`], one variant per technique entry. Each
68/// variant carries exactly the arguments the shared per-technique runner needs;
69/// [`estimate`] drives that runner with the resolved recipe. RTK and PPP expose a
70/// float and a fixed entry; both map to the same [`Technique`].
71pub enum EstimateInput<'a> {
72    /// SPP under the public validation/orchestration policy
73    /// (`spp::solve_with_policy`).
74    Spp {
75        eph: &'a dyn EphemerisSource,
76        inputs: &'a SolveInputs,
77        with_geodetic: bool,
78        policy: SolvePolicy,
79    },
80    /// Static multi-epoch float RTK baseline (`rtk_filter::solve_float_baseline`).
81    RtkFloat {
82        epochs: &'a [Epoch],
83        base: [f64; 3],
84        ambiguity_ids: &'a [String],
85        initial_baseline_m: [f64; 3],
86        model: &'a MeasModel,
87        opts: FloatSolveOpts,
88        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
89    },
90    /// Static fixed RTK baseline with residual validation/FDE
91    /// (`rtk_filter::solve_fixed_baseline_validated`).
92    RtkFixed {
93        epochs: &'a [Epoch],
94        base: [f64; 3],
95        initial_ambiguities: AmbiguitySet<'a>,
96        initial_baseline_m: [f64; 3],
97        model: &'a MeasModel,
98        opts: ValidatedFixedSolveOpts,
99        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
100    },
101    /// Static multi-epoch float PPP arc
102    /// (`precise_positioning::solve_float_epochs`).
103    PppFloat {
104        source: &'a dyn ObservableEphemerisSource,
105        epochs: &'a [FloatEpoch],
106        initial_state: FloatState,
107        config: FloatSolveConfig,
108    },
109    /// Integer-fixed PPP from an existing float solution
110    /// (`precise_positioning::solve_fixed_from_float`).
111    PppFixed {
112        source: &'a dyn ObservableEphemerisSource,
113        epochs: &'a [FloatEpoch],
114        float_solution: FloatSolution,
115        config: FixedSolveConfig,
116    },
117}
118
119impl EstimateInput<'_> {
120    /// The estimation technique this input runs.
121    pub fn technique(&self) -> Technique {
122        match self {
123            Self::Spp { .. } => Technique::Spp,
124            Self::RtkFloat { .. } | Self::RtkFixed { .. } => Technique::Rtk,
125            Self::PppFloat { .. } | Self::PppFixed { .. } => Technique::Ppp,
126        }
127    }
128}
129
130/// The unified result of [`estimate`], wrapping each reference entry point's
131/// existing return type unchanged. The payloads are heterogeneously sized
132/// (RTK/PPP solutions are large), so each is boxed to keep the enum
133/// pointer-sized regardless of which technique ran.
134#[derive(Debug, Clone)]
135pub enum EstimateOutput {
136    Spp(Box<ReceiverSolution>),
137    RtkFloat(Box<FloatBaselineSolution>),
138    RtkFixed(Box<ValidatedFixedBaselineSolution>),
139    PppFloat(Box<FloatSolution>),
140    PppFixed(Box<FixedSolution>),
141}
142
143/// Failure of [`estimate`]: a selection error, or the wrapped error of the
144/// dispatched reference entry point.
145#[derive(Debug)]
146pub enum EstimateError {
147    /// The selected strategy's technique does not match the input's technique
148    /// (e.g. an RTK strategy with an SPP input).
149    TechniqueMismatch {
150        strategy: Technique,
151        input: Technique,
152    },
153    /// A `Reference` strategy named a `target` that is not a supported reference
154    /// for its `technique` (e.g. an RTK technique against the Skyfield SPP
155    /// oracle, or the owned deterministic solver for a non-SPP technique). The
156    /// supported pairs are enumerated by [`EstimationRecipe::for_reference`].
157    IncompatibleTarget {
158        technique: Technique,
159        target: ReferenceTarget,
160    },
161    /// A `Canonical` strategy was selected for a technique whose canonical model
162    /// is not yet implemented. Canonical SPP, RTK, and PPP are all wired, so no
163    /// technique currently produces this; it is retained as the resolver's stable
164    /// not-yet-implemented surface for any future technique.
165    CanonicalUnavailable {
166        technique: Technique,
167    },
168    Spp(SolvePolicyError),
169    RtkFloat(RtkFloatSolveError),
170    RtkFixed(ValidatedFixedSolveError),
171    PppFloat(PppFloatSolveError),
172    PppFixed(FixedSolveError),
173}
174
175/// A [`StrategyId`] resolved into the selection DATA it runs under: the
176/// operation-order [`EstimationRecipe`] (P0-P2) and the residual-screen families
177/// (P3). The recipe is the current reference recipe for the technique, so a
178/// resolved reference strategy dispatches bit-identically to the existing path.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct ResolvedStrategy {
181    pub id: StrategyId,
182    pub technique: Technique,
183    pub recipe: EstimationRecipe,
184    /// The residual-screen families this technique applies (P3 `ScreenKind`).
185    pub screens: &'static [ScreenKind],
186}
187
188impl ResolvedStrategy {
189    /// Resolve a runtime [`StrategyId`] into its recipe and screen policy.
190    /// `Reference` strategies resolve to the recipe for their `(technique,
191    /// target)` pair, rejecting an unsupported pair with
192    /// [`EstimateError::IncompatibleTarget`]; `Canonical` strategies resolve to
193    /// their canonical recipe ([`EstimationRecipe::for_canonical`]), rejecting a
194    /// technique whose canonical model is not yet implemented with
195    /// [`EstimateError::CanonicalUnavailable`].
196    pub fn resolve(id: StrategyId) -> Result<Self, EstimateError> {
197        match id {
198            StrategyId::Reference { technique, target } => {
199                let recipe = EstimationRecipe::for_reference(technique, target)
200                    .ok_or(EstimateError::IncompatibleTarget { technique, target })?;
201                Ok(Self {
202                    id,
203                    technique,
204                    recipe,
205                    screens: screens_for(technique),
206                })
207            }
208            StrategyId::Canonical { technique } => {
209                let recipe = EstimationRecipe::for_canonical(technique)
210                    .ok_or(EstimateError::CanonicalUnavailable { technique })?;
211                Ok(Self {
212                    id,
213                    technique,
214                    recipe,
215                    screens: screens_for(technique),
216                })
217            }
218        }
219    }
220
221    /// The integer-ambiguity identity policy (P3) this strategy resolves under,
222    /// parameterized by the runtime ratio threshold and (RTK only) partial-set
223    /// floor. `None` for SPP, which carries no integer ambiguities.
224    pub fn ambiguity_id_policy(
225        &self,
226        ratio_threshold: f64,
227        partial_min_ambiguities: usize,
228    ) -> Option<AmbiguityIdPolicy> {
229        match self.technique {
230            Technique::Spp => None,
231            Technique::Rtk => Some(AmbiguityIdPolicy::rtk_static(
232                ratio_threshold,
233                partial_min_ambiguities,
234            )),
235            Technique::Ppp => Some(AmbiguityIdPolicy::ppp(ratio_threshold)),
236        }
237    }
238}
239
240/// The residual-screen families a technique applies (P3 `ScreenKind`). RTK lists
241/// both the static fixed-baseline validation and the sequential-filter innovation
242/// screen, the two members of its screen family.
243const fn screens_for(technique: Technique) -> &'static [ScreenKind] {
244    match technique {
245        Technique::Spp => &[ScreenKind::RaimChiSquare],
246        Technique::Rtk => &[
247            ScreenKind::RtkFixedResidualValidation,
248            ScreenKind::RtkSequentialInnovation,
249        ],
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            &[
362                ScreenKind::RtkFixedResidualValidation,
363                ScreenKind::RtkSequentialInnovation,
364            ]
365        );
366        assert_eq!(screens_for(Technique::Spp), &[ScreenKind::RaimChiSquare]);
367        assert_eq!(
368            screens_for(Technique::Ppp),
369            &[ScreenKind::PppFloatLeaveOneOut]
370        );
371    }
372
373    #[test]
374    fn resolve_reference_strategies_to_their_recipe_and_screens() {
375        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
376        assert_eq!(spp.technique, Technique::Spp);
377        assert_eq!(spp.recipe, EstimationRecipe::spp());
378        assert_eq!(spp.screens, &[ScreenKind::RaimChiSquare]);
379        assert!(spp.ambiguity_id_policy(3.0, 1).is_none());
380
381        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
382        assert_eq!(rtk.technique, Technique::Rtk);
383        assert_eq!(rtk.recipe, EstimationRecipe::rtk());
384        let rtk_policy = rtk.ambiguity_id_policy(3.0, 4).unwrap();
385        assert_eq!(rtk_policy, AmbiguityIdPolicy::rtk_static(3.0, 4));
386
387        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
388        assert_eq!(ppp.technique, Technique::Ppp);
389        assert_eq!(ppp.recipe, EstimationRecipe::ppp());
390        assert_eq!(ppp.screens, &[ScreenKind::PppFloatLeaveOneOut]);
391        let ppp_policy = ppp.ambiguity_id_policy(2.5, 0).unwrap();
392        assert_eq!(ppp_policy, AmbiguityIdPolicy::ppp(2.5));
393    }
394
395    #[test]
396    fn each_resolved_strategy_screen_uses_its_own_residual_norm() {
397        // Each resolved screen maps to its committed normalization recipe: the
398        // RTK static baseline to the inverse-sigma residual, the RTK sequential
399        // filter to the inverse-variance innovation, PPP to the inverse-sigma
400        // root. SPP's aggregate RAIM screen has no per-residual recipe.
401        let rtk = ResolvedStrategy::resolve(StrategyId::rtk_reference()).unwrap();
402        assert_eq!(
403            rtk.screens
404                .iter()
405                .map(|screen| screen.residual_norm())
406                .collect::<Vec<_>>(),
407            vec![
408                Some(ResidualNormRecipe::RtkInverseSigmaResidual),
409                Some(ResidualNormRecipe::RtkInverseVarianceInnovation),
410            ]
411        );
412        let ppp = ResolvedStrategy::resolve(StrategyId::ppp_reference()).unwrap();
413        assert_eq!(
414            ppp.screens[0].residual_norm(),
415            Some(ResidualNormRecipe::PppInverseSigmaMagnitude)
416        );
417        let spp = ResolvedStrategy::resolve(StrategyId::spp_reference()).unwrap();
418        assert_eq!(spp.screens[0].residual_norm(), None);
419    }
420
421    #[test]
422    fn resolve_owned_deterministic_spp_selects_the_owned_solver() {
423        use crate::estimation::recipe::SolverRecipe;
424
425        let owned = ResolvedStrategy::resolve(StrategyId::spp_owned_deterministic()).unwrap();
426        assert_eq!(owned.technique, Technique::Spp);
427        assert_eq!(owned.recipe.solver, SolverRecipe::OwnedDeterministicTrf);
428        assert_eq!(owned.recipe, EstimationRecipe::spp_owned_deterministic());
429        // Same SPP screen policy as the Skyfield reference strategy.
430        assert_eq!(owned.screens, &[ScreenKind::RaimChiSquare]);
431    }
432
433    #[test]
434    fn resolve_rejects_incompatible_technique_target_pairs() {
435        for (technique, target) in [
436            (Technique::Spp, ReferenceTarget::Rtklib),
437            (Technique::Spp, ReferenceTarget::Scipy),
438            (Technique::Rtk, ReferenceTarget::OwnedDeterministic),
439            (Technique::Ppp, ReferenceTarget::Skyfield),
440        ] {
441            let err =
442                ResolvedStrategy::resolve(StrategyId::Reference { technique, target }).unwrap_err();
443            match err {
444                EstimateError::IncompatibleTarget {
445                    technique: t,
446                    target: g,
447                } => {
448                    assert_eq!(t, technique);
449                    assert_eq!(g, target);
450                }
451                other => {
452                    panic!("{technique:?} + {target:?} should be IncompatibleTarget, got {other:?}")
453                }
454            }
455        }
456    }
457
458    #[test]
459    fn canonical_spp_resolves_to_the_canonical_recipe() {
460        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
461            technique: Technique::Spp,
462        })
463        .expect("canonical SPP resolves");
464        assert_eq!(resolved.technique, Technique::Spp);
465        assert_eq!(resolved.recipe, EstimationRecipe::canonical_spp());
466        // Canonical SPP carries the SPP screen policy (no integer ambiguities).
467        assert_eq!(resolved.screens, &[ScreenKind::RaimChiSquare]);
468        assert!(resolved.ambiguity_id_policy(3.0, 1).is_none());
469    }
470
471    #[test]
472    fn canonical_rtk_resolves_to_the_canonical_recipe() {
473        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
474            technique: Technique::Rtk,
475        })
476        .expect("canonical RTK resolves");
477        assert_eq!(resolved.technique, Technique::Rtk);
478        assert_eq!(resolved.recipe, EstimationRecipe::canonical_rtk());
479        // The owned Cholesky square-root information solve, not the reference
480        // first-tie Gaussian elimination.
481        assert_eq!(
482            resolved.recipe.normal,
483            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
484        );
485        assert_eq!(
486            resolved.recipe.solver,
487            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
488        );
489    }
490
491    #[test]
492    fn canonical_ppp_resolves_to_the_canonical_recipe() {
493        let resolved = ResolvedStrategy::resolve(StrategyId::Canonical {
494            technique: Technique::Ppp,
495        })
496        .expect("canonical PPP resolves");
497        assert_eq!(resolved.technique, Technique::Ppp);
498        assert_eq!(resolved.recipe, EstimationRecipe::canonical_ppp());
499        // The owned Cholesky square-root information solve on the dense PPP normal
500        // system, not the reference dense last-tie Gaussian elimination.
501        assert_eq!(
502            resolved.recipe.normal,
503            crate::estimation::recipe::NormalRecipe::CanonicalSquareRoot
504        );
505        assert_eq!(
506            resolved.recipe.solver,
507            crate::estimation::recipe::SolverRecipe::OwnedDeterministicCholesky
508        );
509        // Canonical PPP carries the PPP screen policy.
510        assert_eq!(resolved.screens, &[ScreenKind::PppFloatLeaveOneOut]);
511        let policy = resolved.ambiguity_id_policy(2.5, 0).unwrap();
512        assert_eq!(policy, AmbiguityIdPolicy::ppp(2.5));
513    }
514
515    #[test]
516    fn default_options_select_spp_reference() {
517        let resolved = ResolvedStrategy::resolve(EstimateOptions::default().strategy).unwrap();
518        assert_eq!(
519            resolved.id,
520            StrategyId::Reference {
521                technique: Technique::Spp,
522                target: ReferenceTarget::Skyfield,
523            }
524        );
525        assert_eq!(resolved.recipe, EstimationRecipe::spp());
526    }
527}