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