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