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