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::{
22    estimates_ztd, max_abs, rms, state_from_solution, validate_float_solution_output,
23    validate_float_solve_boundary, weighted_rms, ztd_unknown_count, FloatEpoch, FloatSolution,
24    FloatSolveConfig, FloatSolveError, FloatSolveOptions, FloatState, FloatStatus, ModelContext,
25    TroposphereOptions,
26};
27
28const RESIDUAL_SCREEN_THRESHOLD: f64 = 4.0;
29const RESIDUAL_SCREEN_MAX_PASSES: usize = 8;
30const RESIDUAL_SCREEN_ACCEPT_FACTOR: f64 = 2.0;
31const SINGLE_EPOCH_AMBIGUITY_TOLERANCE_M: f64 = f64::MAX;
32
33/// Solve a static multi-epoch float PPP arc.
34pub fn solve_float_epochs(
35    source: &dyn ObservableEphemerisSource,
36    epochs: &[FloatEpoch],
37    initial_state: FloatState,
38    config: FloatSolveConfig,
39) -> Result<FloatSolution, FloatSolveError> {
40    validate_float_solve_boundary(epochs, &initial_state, &config)?;
41    use crate::estimation::recipe::StrategyId;
42    use crate::estimation::strategies::{
43        estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
44    };
45    match estimate(
46        EstimateInput::PppFloat {
47            source,
48            epochs,
49            initial_state,
50            config,
51        },
52        EstimateOptions::new(StrategyId::ppp_reference()),
53    ) {
54        Ok(EstimateOutput::PppFloat(solution)) => Ok(*solution),
55        Err(EstimateError::PppFloat(error)) => Err(error),
56        Ok(_) | Err(_) => {
57            unreachable!(
58                "the PPP reference strategy yields a PPP float solution or a PPP float error"
59            )
60        }
61    }
62}
63
64/// Drive the static float PPP arc from a resolved [`EstimationRecipe`]: the shared
65/// per-technique implementation that
66/// [`crate::estimation::strategies::estimate`] dispatches to. The recipe's
67/// [`NormalRecipe`] reaches the solve seam through [`ModelContext::normal`]; for
68/// the PPP reference recipe (`NormalRecipe::PppDenseLastTie`) the static path
69/// uses clock-eliminated reduced normals, pinned equivalent to the legacy dense
70/// solve (solution and covariance oracles in `precise_positioning::normal`).
71pub(crate) fn run_float_epochs(
72    recipe: &EstimationRecipe,
73    source: &dyn ObservableEphemerisSource,
74    epochs: &[FloatEpoch],
75    initial_state: FloatState,
76    config: FloatSolveConfig,
77) -> Result<FloatSolution, FloatSolveError> {
78    solve_float_multi_screened(source, epochs, initial_state, config, recipe.normal)
79}
80
81/// Solve one float PPP epoch with the same state shape as Sidereon' historical
82/// single-epoch API: receiver position, one receiver clock, and one ambiguity
83/// per observation.
84pub fn solve_float_epoch(
85    source: &dyn ObservableEphemerisSource,
86    epoch: FloatEpoch,
87    initial_state: FloatState,
88    mut config: FloatSolveConfig,
89) -> Result<FloatSolution, FloatSolveError> {
90    let epochs = [epoch];
91    validate_float_solve_boundary(&epochs, &initial_state, &config)?;
92    let ambiguity_ids = epochs[0]
93        .observations
94        .iter()
95        .map(|obs| AmbiguityId::new(obs.ambiguity_id.clone()))
96        .collect::<Vec<_>>();
97    config.opts.ambiguity_tolerance_m = SINGLE_EPOCH_AMBIGUITY_TOLERANCE_M;
98    let ctx = ModelContext {
99        source,
100        weights: config.weights,
101        tropo: config.tropo,
102        corrections: &config.corrections,
103        normal: NormalRecipe::PppDenseLastTie,
104    };
105    iterate_multi(ctx, &epochs, &ambiguity_ids, initial_state, config.opts, 1)
106}
107
108fn solve_float_multi_screened(
109    source: &dyn ObservableEphemerisSource,
110    epochs: &[FloatEpoch],
111    state: FloatState,
112    config: FloatSolveConfig,
113    normal: NormalRecipe,
114) -> Result<FloatSolution, FloatSolveError> {
115    validate_float_solve_boundary(epochs, &state, &config)?;
116    let FloatSolveConfig {
117        weights,
118        tropo,
119        corrections,
120        opts,
121        residual_screen,
122    } = config;
123    let ctx = ModelContext {
124        source,
125        weights,
126        tropo,
127        corrections: &corrections,
128        normal,
129    };
130    let ambiguity_ids = multi_ambiguity_ids(epochs);
131    let solution = iterate_multi(ctx, epochs, &ambiguity_ids, state.clone(), opts, 1)?;
132
133    if !residual_screen {
134        return Ok(solution);
135    }
136
137    let unscreened_wrms = solution_weighted_rms(ctx, epochs, &solution, &state);
138    match run_residual_screen(ctx, epochs.to_vec(), state, opts, solution.clone(), 1)? {
139        ScreenResult::Clean => Ok(solution),
140        ScreenResult::Screened {
141            solution: screened,
142            epochs: retained,
143        } => {
144            let screened_wrms = solution_weighted_rms(
145                ctx,
146                &retained,
147                screened.as_ref(),
148                &state_from_solution(&screened, &FloatState::default_for_epochs(&retained)),
149            );
150            if screened_wrms.is_finite()
151                && unscreened_wrms.is_finite()
152                && screened_wrms * RESIDUAL_SCREEN_ACCEPT_FACTOR < unscreened_wrms
153            {
154                Ok(*screened)
155            } else {
156                Ok(solution)
157            }
158        }
159    }
160}
161
162enum ScreenResult {
163    Clean,
164    Screened {
165        solution: Box<FloatSolution>,
166        epochs: Vec<FloatEpoch>,
167    },
168}
169
170fn run_residual_screen(
171    ctx: ModelContext,
172    epochs: Vec<FloatEpoch>,
173    seed_state: FloatState,
174    opts: FloatSolveOptions,
175    solution: FloatSolution,
176    pass: usize,
177) -> Result<ScreenResult, FloatSolveError> {
178    if pass > RESIDUAL_SCREEN_MAX_PASSES {
179        return Ok(ScreenResult::Screened {
180            solution: Box::new(solution),
181            epochs,
182        });
183    }
184
185    let candidate_state = state_from_solution(&solution, &seed_state);
186    match worst_multi_residual(ctx, &epochs, &candidate_state)? {
187        Some((epoch_idx, sat)) => {
188            let pruned = exclude_observation(&epochs, epoch_idx, &sat);
189            if !multi_enough_after_prune(&pruned, ctx.tropo) {
190                return Ok(ScreenResult::Screened {
191                    solution: Box::new(solution),
192                    epochs,
193                });
194            }
195            let ambiguity_ids = multi_ambiguity_ids(&pruned);
196            let candidate = iterate_multi(
197                ctx,
198                &pruned,
199                &ambiguity_ids,
200                reseed_state(&seed_state, &pruned),
201                opts,
202                1,
203            )?;
204            run_residual_screen(ctx, pruned, seed_state, opts, candidate, pass + 1)
205        }
206        None => {
207            if pass == 1 {
208                Ok(ScreenResult::Clean)
209            } else {
210                Ok(ScreenResult::Screened {
211                    solution: Box::new(solution),
212                    epochs,
213                })
214            }
215        }
216    }
217}
218
219fn iterate_multi(
220    ctx: ModelContext,
221    epochs: &[FloatEpoch],
222    ambiguity_ids: &[AmbiguityId],
223    state: FloatState,
224    opts: FloatSolveOptions,
225    iter: usize,
226) -> Result<FloatSolution, FloatSolveError> {
227    let mut current = state;
228    let mut iteration = iter;
229    let max_iterations = opts.max_iterations;
230
231    loop {
232        let binding = AmbiguityBinding::Estimated {
233            ids: ambiguity_ids,
234            values: &current.ambiguities_m,
235        };
236        let rows = build_rows(ctx, epochs, &binding, &current).map_err(PppRowError::into_float)?;
237        let layout = PppNormalLayout::new(
238            epochs.len(),
239            ztd_unknown_count(ctx.tropo),
240            ambiguity_ids.len(),
241        );
242        let dx = solve_normal_equations(&rows, layout, ctx.normal)?;
243        let next = apply_multi_delta(&current, epochs.len(), ambiguity_ids, &dx, ctx.tropo)?;
244        let (pos_step, clock_step, ztd_step, ambiguity_step) =
245            multi_step_norms(&dx, epochs.len(), ctx.tropo);
246
247        if pos_step <= opts.position_tolerance_m
248            && clock_step <= opts.clock_tolerance_m
249            && ztd_step <= opts.ztd_tolerance_m
250            && ambiguity_step <= opts.ambiguity_tolerance_m
251        {
252            return finalize_multi(
253                ctx,
254                epochs,
255                ambiguity_ids,
256                next,
257                iteration,
258                true,
259                FloatStatus::StateTolerance,
260            );
261        }
262
263        if iteration >= max_iterations {
264            return finalize_multi(
265                ctx,
266                epochs,
267                ambiguity_ids,
268                next,
269                iteration,
270                false,
271                FloatStatus::MaxIterations,
272            );
273        }
274
275        current = next;
276        iteration += 1;
277    }
278}
279
280fn apply_multi_delta(
281    state: &FloatState,
282    n_epochs: usize,
283    ambiguity_ids: &[AmbiguityId],
284    dx: &[f64],
285    tropo: TroposphereOptions,
286) -> Result<FloatState, FloatSolveError> {
287    let mut idx = 3;
288    let clock_deltas = &dx[idx..idx + n_epochs];
289    idx += n_epochs;
290    let ztd_delta = if estimates_ztd(tropo) {
291        let v = dx[idx];
292        idx += 1;
293        v
294    } else {
295        0.0
296    };
297    let ambiguity_deltas = &dx[idx..];
298    let clocks_m = state
299        .clocks_m
300        .iter()
301        .zip(clock_deltas)
302        .map(|(clock, delta)| clock + delta)
303        .collect();
304    let mut ambiguities_m = BTreeMap::new();
305    for (id, delta) in ambiguity_ids.iter().zip(ambiguity_deltas) {
306        let prior = state
307            .ambiguities_m
308            .get(id.as_str())
309            .copied()
310            .ok_or_else(|| FloatSolveError::MissingAmbiguity(id.as_str().to_string()))?;
311        ambiguities_m.insert(id.as_str().to_string(), prior + delta);
312    }
313    Ok(FloatState {
314        position_m: [
315            state.position_m[0] + dx[0],
316            state.position_m[1] + dx[1],
317            state.position_m[2] + dx[2],
318        ],
319        clocks_m,
320        ambiguities_m,
321        ztd_m: state.ztd_m + ztd_delta,
322    })
323}
324
325fn multi_step_norms(
326    dx: &[f64],
327    n_epochs: usize,
328    tropo: TroposphereOptions,
329) -> (f64, f64, f64, f64) {
330    let pos = vec3::norm3([dx[0], dx[1], dx[2]]);
331    let mut idx = 3;
332    let clock = max_abs(&dx[idx..idx + n_epochs]);
333    idx += n_epochs;
334    let ztd = if estimates_ztd(tropo) {
335        let v = dx[idx].abs();
336        idx += 1;
337        v
338    } else {
339        0.0
340    };
341    let ambiguity = max_abs(&dx[idx..]);
342    (pos, clock, ztd, ambiguity)
343}
344
345fn finalize_multi(
346    ctx: ModelContext,
347    epochs: &[FloatEpoch],
348    ambiguity_ids: &[AmbiguityId],
349    state: FloatState,
350    iterations: usize,
351    converged: bool,
352    status: FloatStatus,
353) -> Result<FloatSolution, FloatSolveError> {
354    let residuals = residual_rows(ctx, epochs, &state.ambiguities_m, &state)
355        .map_err(PppRowError::into_float)?;
356    let binding = AmbiguityBinding::Estimated {
357        ids: ambiguity_ids,
358        values: &state.ambiguities_m,
359    };
360    let rows = build_rows(ctx, epochs, &binding, &state).map_err(PppRowError::into_float)?;
361    let covariance = ppp_position_covariance(
362        &rows,
363        PppNormalLayout::new(
364            epochs.len(),
365            ztd_unknown_count(ctx.tropo),
366            ambiguity_ids.len(),
367        ),
368        state.position_m,
369    )?;
370    let code: Vec<f64> = residuals.iter().map(|r| r.code_m).collect();
371    let phase: Vec<f64> = residuals.iter().map(|r| r.phase_m).collect();
372    let solution = FloatSolution {
373        position_m: state.position_m,
374        position_covariance: covariance.scaled,
375        formal_position_covariance: covariance.formal,
376        posterior_variance_factor: covariance.posterior_variance_factor,
377        position_covariance_scale_factor: covariance.covariance_scale_factor,
378        epoch_clocks_m: state.clocks_m,
379        ambiguities_m: state.ambiguities_m,
380        ztd_residual_m: if estimates_ztd(ctx.tropo) {
381            Some(state.ztd_m)
382        } else {
383            None
384        },
385        residuals_m: residuals.clone(),
386        used_sats: ambiguity_ids
387            .iter()
388            .map(|id| id.as_str().to_string())
389            .collect(),
390        iterations,
391        converged,
392        status,
393        code_rms_m: rms(&code),
394        phase_rms_m: rms(&phase),
395        weighted_rms_m: weighted_rms(&residuals, ctx.weights),
396    };
397    validate_float_solution_output(&solution, epochs.len())?;
398    Ok(solution)
399}
400
401fn solution_weighted_rms(
402    ctx: ModelContext,
403    epochs: &[FloatEpoch],
404    solution: &FloatSolution,
405    seed_state: &FloatState,
406) -> f64 {
407    let state = state_from_solution(solution, seed_state);
408    match residual_rows(ctx, epochs, &state.ambiguities_m, &state) {
409        Ok(rows) => weighted_rms(&rows, ctx.weights),
410        Err(_) => f64::INFINITY,
411    }
412}
413
414fn worst_multi_residual(
415    ctx: ModelContext,
416    epochs: &[FloatEpoch],
417    state: &FloatState,
418) -> Result<Option<(usize, String)>, FloatSolveError> {
419    let rows =
420        residual_rows(ctx, epochs, &state.ambiguities_m, state).map_err(PppRowError::into_float)?;
421    let candidate = rows
422        .iter()
423        .flat_map(|r| {
424            [
425                (
426                    normalized_residual(
427                        ResidualNormRecipe::PppInverseSigmaMagnitude,
428                        r.code_m,
429                        r.code_weight,
430                    ),
431                    r.epoch_index,
432                    r.satellite_id.clone(),
433                ),
434                (
435                    normalized_residual(
436                        ResidualNormRecipe::PppInverseSigmaMagnitude,
437                        r.phase_m,
438                        r.phase_weight,
439                    ),
440                    r.epoch_index,
441                    r.satellite_id.clone(),
442                ),
443            ]
444        })
445        .max_by(|a, b| a.0.total_cmp(&b.0));
446    Ok(match candidate {
447        Some((normalized, epoch_idx, sat)) if normalized > RESIDUAL_SCREEN_THRESHOLD => {
448            Some((epoch_idx, sat))
449        }
450        _ => None,
451    })
452}
453
454fn exclude_observation(
455    epochs: &[FloatEpoch],
456    drop_epoch_idx: usize,
457    drop_sat: &str,
458) -> Vec<FloatEpoch> {
459    epochs
460        .iter()
461        .enumerate()
462        .filter_map(|(epoch_idx, epoch)| {
463            let mut epoch = epoch.clone();
464            if epoch_idx == drop_epoch_idx {
465                epoch
466                    .observations
467                    .retain(|obs| obs.satellite_id != drop_sat);
468            }
469            if epoch.observations.is_empty() {
470                None
471            } else {
472                Some(epoch)
473            }
474        })
475        .collect()
476}
477
478fn multi_enough_after_prune(epochs: &[FloatEpoch], tropo: TroposphereOptions) -> bool {
479    if epochs.len() < 2 {
480        return false;
481    }
482    let n_sats = multi_ambiguity_ids(epochs).len();
483    let n_obs: usize = epochs.iter().map(|e| e.observations.len()).sum();
484    let equations = 2 * n_obs;
485    let unknowns = ParameterLayout::ppp(epochs.len(), ztd_unknown_count(tropo), n_sats).dim();
486    n_sats >= 4 && equations >= unknowns
487}
488
489fn reseed_state(state: &FloatState, epochs: &[FloatEpoch]) -> FloatState {
490    FloatState {
491        position_m: state.position_m,
492        clocks_m: vec![state.clocks_m[0]; epochs.len()],
493        ambiguities_m: initial_ambiguities(epochs),
494        ztd_m: state.ztd_m,
495    }
496}
497
498pub(super) fn initial_ambiguities(epochs: &[FloatEpoch]) -> BTreeMap<String, f64> {
499    let mut out = BTreeMap::new();
500    for obs in epochs.iter().flat_map(|e| e.observations.iter()) {
501        out.entry(obs.ambiguity_id.clone())
502            .or_insert(obs.phase_m - obs.code_m);
503    }
504    out
505}
506
507fn multi_ambiguity_ids(epochs: &[FloatEpoch]) -> Vec<AmbiguityId> {
508    epochs
509        .iter()
510        .flat_map(|e| {
511            e.observations
512                .iter()
513                .map(|o| AmbiguityId::new(o.ambiguity_id.clone()))
514        })
515        .collect::<BTreeSet<_>>()
516        .into_iter()
517        .collect()
518}