Skip to main content

sidereon_core/precise_positioning/
float.rs

1//! Static multi-epoch float PPP solve and the iterated Gauss-Newton update.
2//!
3//! This leaf owns the float-only orchestration: the public multi-epoch and
4//! single-epoch entry points, the iterated normal-equation solve, the design
5//! rows and state delta, the post-fit residual rows, and the leave-one-out
6//! residual screening loop. The shared measurement model lives in
7//! [`super::model`], the dense normal-equation kernel in [`super::normal`], and
8//! the row staging / shared scalar helpers in [`super`].
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::ambiguity::AmbiguityId;
13use crate::astro::math::vec3;
14use crate::estimation::recipe::{EstimationRecipe, NormalRecipe, ResidualNormRecipe};
15use crate::estimation::substrate::parameters::ParameterLayout;
16use crate::estimation::substrate::qc::normalized_residual;
17use crate::observables::ObservableEphemerisSource;
18
19use super::normal::{ppp_position_covariance, solve_normal_equations, PppNormalLayout};
20use super::rows::{build_rows, residual_rows, AmbiguityBinding, PppRowError};
21use super::temporal::{estimate_temporal_correlation, temporal_position_covariance};
22use super::{
23    apply_elevation_cutoff, estimates_tropo_gradients, estimates_ztd, max_abs,
24    residual_ionosphere_unknown_count, rms, state_from_solution, tropo_gradient_unknown_count,
25    validate_float_solution_output, validate_float_solve_boundary, weighted_rms, ztd_unknown_count,
26    FloatEpoch, FloatSolution, FloatSolveConfig, FloatSolveError, FloatSolveOptions, FloatState,
27    FloatStatus, ModelContext, TroposphereOptions,
28};
29
30const RESIDUAL_SCREEN_THRESHOLD: f64 = 4.0;
31const RESIDUAL_SCREEN_MAX_PASSES: usize = 8;
32const RESIDUAL_SCREEN_ACCEPT_FACTOR: f64 = 2.0;
33const SINGLE_EPOCH_AMBIGUITY_TOLERANCE_M: f64 = f64::MAX;
34
35/// Solve a static multi-epoch float PPP arc.
36pub fn solve_float_epochs(
37    source: &dyn ObservableEphemerisSource,
38    epochs: &[FloatEpoch],
39    initial_state: FloatState,
40    config: FloatSolveConfig,
41) -> Result<FloatSolution, FloatSolveError> {
42    validate_float_solve_boundary(epochs, &initial_state, &config)?;
43    use crate::estimation::recipe::StrategyId;
44    use crate::estimation::strategies::{
45        estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
46    };
47    match estimate(
48        EstimateInput::PppFloat {
49            source,
50            epochs,
51            initial_state,
52            config,
53        },
54        EstimateOptions::new(StrategyId::ppp_reference()),
55    ) {
56        Ok(EstimateOutput::PppFloat(solution)) => Ok(*solution),
57        Err(EstimateError::PppFloat(error)) => Err(error),
58        Ok(_) | Err(_) => {
59            unreachable!(
60                "the PPP reference strategy yields a PPP float solution or a PPP float error"
61            )
62        }
63    }
64}
65
66/// Drive the static float PPP arc from a resolved [`EstimationRecipe`]: the shared
67/// per-technique implementation that
68/// [`crate::estimation::strategies::estimate`] dispatches to. The recipe's
69/// [`NormalRecipe`] reaches the solve seam through [`ModelContext::normal`]; for
70/// the PPP reference recipe (`NormalRecipe::PppDenseLastTie`) the static path
71/// uses clock-eliminated reduced normals, pinned equivalent to the legacy dense
72/// solve (solution and covariance oracles in `precise_positioning::normal`).
73pub(crate) fn run_float_epochs(
74    recipe: &EstimationRecipe,
75    source: &dyn ObservableEphemerisSource,
76    epochs: &[FloatEpoch],
77    initial_state: FloatState,
78    config: FloatSolveConfig,
79) -> Result<FloatSolution, FloatSolveError> {
80    solve_float_multi_screened(source, epochs, initial_state, config, recipe.normal)
81}
82
83/// Solve one float PPP epoch with the same state shape as Sidereon' historical
84/// single-epoch API: receiver position, one receiver clock, and one ambiguity
85/// per observation.
86pub fn solve_float_epoch(
87    source: &dyn ObservableEphemerisSource,
88    epoch: FloatEpoch,
89    initial_state: FloatState,
90    mut config: FloatSolveConfig,
91) -> Result<FloatSolution, FloatSolveError> {
92    let epochs = [epoch];
93    validate_float_solve_boundary(&epochs, &initial_state, &config)?;
94    let filtered_epochs;
95    let solve_epochs = if let Some(cutoff_deg) = config.elevation_cutoff_deg {
96        filtered_epochs = apply_elevation_cutoff(
97            source,
98            &epochs,
99            &initial_state,
100            cutoff_deg,
101            config.tropo,
102            config.estimate_residual_ionosphere,
103        )?;
104        filtered_epochs.as_slice()
105    } else {
106        &epochs
107    };
108    let ambiguity_ids = solve_epochs[0]
109        .observations
110        .iter()
111        .map(|obs| AmbiguityId::new(obs.ambiguity_id.clone()))
112        .collect::<Vec<_>>();
113    config.opts.ambiguity_tolerance_m = SINGLE_EPOCH_AMBIGUITY_TOLERANCE_M;
114    let ctx = ModelContext {
115        source,
116        weights: config.weights,
117        tropo: config.tropo,
118        corrections: &config.corrections,
119        normal: NormalRecipe::PppDenseLastTie,
120        estimate_residual_ionosphere: config.estimate_residual_ionosphere,
121    };
122    iterate_multi(
123        ctx,
124        solve_epochs,
125        &ambiguity_ids,
126        initial_state,
127        config.opts,
128        1,
129    )
130}
131
132fn solve_float_multi_screened(
133    source: &dyn ObservableEphemerisSource,
134    epochs: &[FloatEpoch],
135    state: FloatState,
136    config: FloatSolveConfig,
137    normal: NormalRecipe,
138) -> Result<FloatSolution, FloatSolveError> {
139    validate_float_solve_boundary(epochs, &state, &config)?;
140    let FloatSolveConfig {
141        weights,
142        tropo,
143        corrections,
144        opts,
145        elevation_cutoff_deg,
146        residual_screen,
147        estimate_residual_ionosphere,
148    } = config;
149    let ctx = ModelContext {
150        source,
151        weights,
152        tropo,
153        corrections: &corrections,
154        normal,
155        estimate_residual_ionosphere,
156    };
157    let filtered_epochs;
158    let solve_epochs = if let Some(cutoff_deg) = elevation_cutoff_deg {
159        filtered_epochs = apply_elevation_cutoff(
160            source,
161            epochs,
162            &state,
163            cutoff_deg,
164            tropo,
165            estimate_residual_ionosphere,
166        )?;
167        filtered_epochs.as_slice()
168    } else {
169        epochs
170    };
171    let ambiguity_ids = multi_ambiguity_ids(solve_epochs);
172    let solution = iterate_multi(ctx, solve_epochs, &ambiguity_ids, state.clone(), opts, 1)?;
173
174    if !residual_screen {
175        return Ok(solution);
176    }
177
178    let unscreened_wrms = solution_weighted_rms(ctx, solve_epochs, &solution, &state);
179    match run_residual_screen(ctx, solve_epochs.to_vec(), state, opts, solution.clone(), 1)? {
180        ScreenResult::Clean => Ok(solution),
181        ScreenResult::Screened {
182            solution: screened,
183            epochs: retained,
184        } => {
185            let screened_wrms = solution_weighted_rms(
186                ctx,
187                &retained,
188                screened.as_ref(),
189                &state_from_solution(&screened, &FloatState::default_for_epochs(&retained)),
190            );
191            if screened_wrms.is_finite()
192                && unscreened_wrms.is_finite()
193                && screened_wrms * RESIDUAL_SCREEN_ACCEPT_FACTOR < unscreened_wrms
194            {
195                Ok(*screened)
196            } else {
197                Ok(solution)
198            }
199        }
200    }
201}
202
203enum ScreenResult {
204    Clean,
205    Screened {
206        solution: Box<FloatSolution>,
207        epochs: Vec<FloatEpoch>,
208    },
209}
210
211fn run_residual_screen(
212    ctx: ModelContext,
213    epochs: Vec<FloatEpoch>,
214    seed_state: FloatState,
215    opts: FloatSolveOptions,
216    solution: FloatSolution,
217    pass: usize,
218) -> Result<ScreenResult, FloatSolveError> {
219    if pass > RESIDUAL_SCREEN_MAX_PASSES {
220        return Ok(ScreenResult::Screened {
221            solution: Box::new(solution),
222            epochs,
223        });
224    }
225
226    let candidate_state = state_from_solution(&solution, &seed_state);
227    match worst_multi_residual(ctx, &epochs, &candidate_state)? {
228        Some((epoch_idx, sat)) => {
229            let pruned = exclude_observation(&epochs, epoch_idx, &sat);
230            if !multi_enough_after_prune(&pruned, ctx.tropo, ctx.estimate_residual_ionosphere) {
231                return Ok(ScreenResult::Screened {
232                    solution: Box::new(solution),
233                    epochs,
234                });
235            }
236            let ambiguity_ids = multi_ambiguity_ids(&pruned);
237            let candidate = iterate_multi(
238                ctx,
239                &pruned,
240                &ambiguity_ids,
241                reseed_state(&seed_state, &pruned),
242                opts,
243                1,
244            )?;
245            run_residual_screen(ctx, pruned, seed_state, opts, candidate, pass + 1)
246        }
247        None => {
248            if pass == 1 {
249                Ok(ScreenResult::Clean)
250            } else {
251                Ok(ScreenResult::Screened {
252                    solution: Box::new(solution),
253                    epochs,
254                })
255            }
256        }
257    }
258}
259
260fn iterate_multi(
261    ctx: ModelContext,
262    epochs: &[FloatEpoch],
263    ambiguity_ids: &[AmbiguityId],
264    state: FloatState,
265    opts: FloatSolveOptions,
266    iter: usize,
267) -> Result<FloatSolution, FloatSolveError> {
268    let mut current = state;
269    let mut iteration = iter;
270    let max_iterations = opts.max_iterations;
271
272    loop {
273        let binding = AmbiguityBinding::Estimated {
274            ids: ambiguity_ids,
275            values: &current.ambiguities_m,
276        };
277        let rows = build_rows(ctx, epochs, &binding, &current).map_err(PppRowError::into_float)?;
278        let layout = PppNormalLayout::new(
279            epochs.len(),
280            ztd_unknown_count(ctx.tropo),
281            tropo_gradient_unknown_count(ctx.tropo),
282            residual_ionosphere_unknown_count(
283                ctx.estimate_residual_ionosphere,
284                ambiguity_ids.len(),
285            ),
286            ambiguity_ids.len(),
287        );
288        let dx = solve_normal_equations(&rows, layout, ctx.normal)?;
289        let next = apply_multi_delta(
290            &current,
291            epochs.len(),
292            ambiguity_ids,
293            &dx,
294            ctx.tropo,
295            ctx.estimate_residual_ionosphere,
296        )?;
297        let (pos_step, clock_step, ztd_step, gradient_step, ambiguity_step) = multi_step_norms(
298            &dx,
299            epochs.len(),
300            ctx.tropo,
301            ctx.estimate_residual_ionosphere,
302            ambiguity_ids.len(),
303        );
304
305        if pos_step <= opts.position_tolerance_m
306            && clock_step <= opts.clock_tolerance_m
307            && ztd_step <= opts.ztd_tolerance_m
308            && gradient_step <= opts.ztd_tolerance_m
309            && ambiguity_step <= opts.ambiguity_tolerance_m
310        {
311            return finalize_multi(
312                ctx,
313                epochs,
314                ambiguity_ids,
315                next,
316                iteration,
317                true,
318                FloatStatus::StateTolerance,
319            );
320        }
321
322        if iteration >= max_iterations {
323            return finalize_multi(
324                ctx,
325                epochs,
326                ambiguity_ids,
327                next,
328                iteration,
329                false,
330                FloatStatus::MaxIterations,
331            );
332        }
333
334        current = next;
335        iteration += 1;
336    }
337}
338
339fn apply_multi_delta(
340    state: &FloatState,
341    n_epochs: usize,
342    ambiguity_ids: &[AmbiguityId],
343    dx: &[f64],
344    tropo: TroposphereOptions,
345    estimate_residual_ionosphere: bool,
346) -> Result<FloatState, FloatSolveError> {
347    let mut idx = 3;
348    let clock_deltas = &dx[idx..idx + n_epochs];
349    idx += n_epochs;
350    let ztd_delta = if estimates_ztd(tropo) {
351        let v = dx[idx];
352        idx += 1;
353        v
354    } else {
355        0.0
356    };
357    let (tropo_gradient_north_delta, tropo_gradient_east_delta) =
358        if estimates_tropo_gradients(tropo) {
359            let north = dx[idx];
360            let east = dx[idx + 1];
361            idx += 2;
362            (north, east)
363        } else {
364            (0.0, 0.0)
365        };
366    let mut residual_ionosphere_m = BTreeMap::new();
367    if estimate_residual_ionosphere {
368        let ionosphere_deltas = &dx[idx..idx + ambiguity_ids.len()];
369        idx += ambiguity_ids.len();
370        for (id, delta) in ambiguity_ids.iter().zip(ionosphere_deltas) {
371            let prior = state
372                .residual_ionosphere_m
373                .get(id.as_str())
374                .copied()
375                .unwrap_or(0.0);
376            residual_ionosphere_m.insert(id.as_str().to_string(), prior + delta);
377        }
378    }
379    let ambiguity_deltas = &dx[idx..];
380    let clocks_m = state
381        .clocks_m
382        .iter()
383        .zip(clock_deltas)
384        .map(|(clock, delta)| clock + delta)
385        .collect();
386    let mut ambiguities_m = BTreeMap::new();
387    for (id, delta) in ambiguity_ids.iter().zip(ambiguity_deltas) {
388        let prior = state
389            .ambiguities_m
390            .get(id.as_str())
391            .copied()
392            .ok_or_else(|| FloatSolveError::MissingAmbiguity(id.as_str().to_string()))?;
393        ambiguities_m.insert(id.as_str().to_string(), prior + delta);
394    }
395    Ok(FloatState {
396        position_m: [
397            state.position_m[0] + dx[0],
398            state.position_m[1] + dx[1],
399            state.position_m[2] + dx[2],
400        ],
401        clocks_m,
402        ambiguities_m,
403        ztd_m: state.ztd_m + ztd_delta,
404        tropo_gradient_north_m: state.tropo_gradient_north_m + tropo_gradient_north_delta,
405        tropo_gradient_east_m: state.tropo_gradient_east_m + tropo_gradient_east_delta,
406        residual_ionosphere_m,
407    })
408}
409
410fn multi_step_norms(
411    dx: &[f64],
412    n_epochs: usize,
413    tropo: TroposphereOptions,
414    estimate_residual_ionosphere: bool,
415    n_ambiguities: usize,
416) -> (f64, f64, f64, f64, f64) {
417    let pos = vec3::norm3([dx[0], dx[1], dx[2]]);
418    let mut idx = 3;
419    let clock = max_abs(&dx[idx..idx + n_epochs]);
420    idx += n_epochs;
421    let ztd = if estimates_ztd(tropo) {
422        let v = dx[idx].abs();
423        idx += 1;
424        v
425    } else {
426        0.0
427    };
428    let gradient = if estimates_tropo_gradients(tropo) {
429        let v = max_abs(&dx[idx..idx + 2]);
430        idx += 2;
431        v
432    } else {
433        0.0
434    };
435    let ionosphere = if estimate_residual_ionosphere {
436        let v = max_abs(&dx[idx..idx + n_ambiguities]);
437        idx += n_ambiguities;
438        v
439    } else {
440        0.0
441    };
442    let ambiguity = max_abs(&dx[idx..]);
443    (pos, clock, ztd, gradient, ambiguity.max(ionosphere))
444}
445
446fn finalize_multi(
447    ctx: ModelContext,
448    epochs: &[FloatEpoch],
449    ambiguity_ids: &[AmbiguityId],
450    state: FloatState,
451    iterations: usize,
452    converged: bool,
453    status: FloatStatus,
454) -> Result<FloatSolution, FloatSolveError> {
455    let residuals = residual_rows(ctx, epochs, &state.ambiguities_m, &state)
456        .map_err(PppRowError::into_float)?;
457    let binding = AmbiguityBinding::Estimated {
458        ids: ambiguity_ids,
459        values: &state.ambiguities_m,
460    };
461    let rows = build_rows(ctx, epochs, &binding, &state).map_err(PppRowError::into_float)?;
462    let covariance = ppp_position_covariance(
463        &rows,
464        PppNormalLayout::new(
465            epochs.len(),
466            ztd_unknown_count(ctx.tropo),
467            tropo_gradient_unknown_count(ctx.tropo),
468            residual_ionosphere_unknown_count(
469                ctx.estimate_residual_ionosphere,
470                ambiguity_ids.len(),
471            ),
472            ambiguity_ids.len(),
473        ),
474        state.position_m,
475    )?;
476    let code: Vec<f64> = residuals.iter().map(|r| r.code_m).collect();
477    let phase: Vec<f64> = residuals.iter().map(|r| r.phase_m).collect();
478    let temporal_correlation = estimate_temporal_correlation(&residuals, epochs);
479    let (temporal_position_covariance, temporal_position_covariance_scale_factor) =
480        temporal_position_covariance(
481            covariance.formal,
482            covariance.posterior_variance_factor,
483            temporal_correlation,
484        );
485    let solution = FloatSolution {
486        position_m: state.position_m,
487        position_covariance: covariance.scaled,
488        formal_position_covariance: covariance.formal,
489        posterior_variance_factor: covariance.posterior_variance_factor,
490        position_covariance_scale_factor: covariance.covariance_scale_factor,
491        temporal_position_covariance,
492        temporal_position_covariance_scale_factor,
493        temporal_correlation,
494        epoch_clocks_m: state.clocks_m,
495        ambiguities_m: state.ambiguities_m,
496        residual_ionosphere_m: if ctx.estimate_residual_ionosphere {
497            state.residual_ionosphere_m
498        } else {
499            BTreeMap::new()
500        },
501        ztd_residual_m: if estimates_ztd(ctx.tropo) {
502            Some(state.ztd_m)
503        } else {
504            None
505        },
506        tropo_gradient_north_m: if estimates_tropo_gradients(ctx.tropo) {
507            Some(state.tropo_gradient_north_m)
508        } else {
509            None
510        },
511        tropo_gradient_east_m: if estimates_tropo_gradients(ctx.tropo) {
512            Some(state.tropo_gradient_east_m)
513        } else {
514            None
515        },
516        tropo_gradient_covariance_m2: covariance.tropo_gradient_scaled_m2,
517        formal_tropo_gradient_covariance_m2: covariance.tropo_gradient_formal_m2,
518        residuals_m: residuals.clone(),
519        used_sats: ambiguity_ids
520            .iter()
521            .map(|id| id.as_str().to_string())
522            .collect(),
523        iterations,
524        converged,
525        status,
526        code_rms_m: rms(&code),
527        phase_rms_m: rms(&phase),
528        weighted_rms_m: weighted_rms(&residuals, ctx.weights),
529    };
530    validate_float_solution_output(&solution, epochs.len())?;
531    Ok(solution)
532}
533
534fn solution_weighted_rms(
535    ctx: ModelContext,
536    epochs: &[FloatEpoch],
537    solution: &FloatSolution,
538    seed_state: &FloatState,
539) -> f64 {
540    let state = state_from_solution(solution, seed_state);
541    match residual_rows(ctx, epochs, &state.ambiguities_m, &state) {
542        Ok(rows) => weighted_rms(&rows, ctx.weights),
543        Err(_) => f64::INFINITY,
544    }
545}
546
547fn worst_multi_residual(
548    ctx: ModelContext,
549    epochs: &[FloatEpoch],
550    state: &FloatState,
551) -> Result<Option<(usize, String)>, FloatSolveError> {
552    let rows =
553        residual_rows(ctx, epochs, &state.ambiguities_m, state).map_err(PppRowError::into_float)?;
554    let candidate = rows
555        .iter()
556        .flat_map(|r| {
557            [
558                (
559                    normalized_residual(
560                        ResidualNormRecipe::PppInverseSigmaMagnitude,
561                        r.code_m,
562                        r.code_weight,
563                    ),
564                    r.epoch_index,
565                    r.satellite_id.clone(),
566                ),
567                (
568                    normalized_residual(
569                        ResidualNormRecipe::PppInverseSigmaMagnitude,
570                        r.phase_m,
571                        r.phase_weight,
572                    ),
573                    r.epoch_index,
574                    r.satellite_id.clone(),
575                ),
576            ]
577        })
578        .max_by(|a, b| a.0.total_cmp(&b.0));
579    Ok(match candidate {
580        Some((normalized, epoch_idx, sat)) if normalized > RESIDUAL_SCREEN_THRESHOLD => {
581            Some((epoch_idx, sat))
582        }
583        _ => None,
584    })
585}
586
587fn exclude_observation(
588    epochs: &[FloatEpoch],
589    drop_epoch_idx: usize,
590    drop_sat: &str,
591) -> Vec<FloatEpoch> {
592    epochs
593        .iter()
594        .enumerate()
595        .filter_map(|(epoch_idx, epoch)| {
596            let mut epoch = epoch.clone();
597            if epoch_idx == drop_epoch_idx {
598                epoch
599                    .observations
600                    .retain(|obs| obs.satellite_id != drop_sat);
601            }
602            if epoch.observations.is_empty() {
603                None
604            } else {
605                Some(epoch)
606            }
607        })
608        .collect()
609}
610
611fn multi_enough_after_prune(
612    epochs: &[FloatEpoch],
613    tropo: TroposphereOptions,
614    estimate_residual_ionosphere: bool,
615) -> bool {
616    if epochs.len() < 2 {
617        return false;
618    }
619    let n_sats = multi_ambiguity_ids(epochs).len();
620    let n_obs: usize = epochs.iter().map(|e| e.observations.len()).sum();
621    let equations = 2 * n_obs;
622    let unknowns = ParameterLayout::ppp(
623        epochs.len(),
624        ztd_unknown_count(tropo),
625        tropo_gradient_unknown_count(tropo),
626        residual_ionosphere_unknown_count(estimate_residual_ionosphere, n_sats),
627        n_sats,
628    )
629    .dim();
630    n_sats >= 4 && equations >= unknowns
631}
632
633fn reseed_state(state: &FloatState, epochs: &[FloatEpoch]) -> FloatState {
634    FloatState {
635        position_m: state.position_m,
636        clocks_m: vec![state.clocks_m[0]; epochs.len()],
637        ambiguities_m: initial_ambiguities(epochs),
638        ztd_m: state.ztd_m,
639        tropo_gradient_north_m: state.tropo_gradient_north_m,
640        tropo_gradient_east_m: state.tropo_gradient_east_m,
641        residual_ionosphere_m: BTreeMap::new(),
642    }
643}
644
645pub(super) fn initial_ambiguities(epochs: &[FloatEpoch]) -> BTreeMap<String, f64> {
646    let mut out = BTreeMap::new();
647    for obs in epochs.iter().flat_map(|e| e.observations.iter()) {
648        out.entry(obs.ambiguity_id.clone())
649            .or_insert(obs.phase_m - obs.code_m);
650    }
651    out
652}
653
654fn multi_ambiguity_ids(epochs: &[FloatEpoch]) -> Vec<AmbiguityId> {
655    epochs
656        .iter()
657        .flat_map(|e| {
658            e.observations
659                .iter()
660                .map(|o| AmbiguityId::new(o.ambiguity_id.clone()))
661        })
662        .collect::<BTreeSet<_>>()
663        .into_iter()
664        .collect()
665}