Skip to main content

sidereon_core/precise_positioning/
fixed.rs

1//! Static integer-fixed PPP solve.
2//!
3//! This leaf owns the fixed-ambiguity orchestration: the LAMBDA integer search
4//! from a float solution, the ambiguity-conditioned multi-epoch re-solve, the
5//! post-fit residual rows, and the cycle/metre ambiguity conversions. The shared
6//! measurement model lives in [`super::model`], the dense normal-equation kernel
7//! in [`super::normal`], and the row staging / shared scalar helpers in [`super`].
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use crate::ambiguity::AmbiguityId;
12use crate::astro::math::vec3;
13use crate::estimation::recipe::{EstimationRecipe, NormalRecipe};
14use crate::estimation::substrate::ambiguity::resolve_integer_lattice;
15use crate::observables::ObservableEphemerisSource;
16
17use super::normal::{
18    ambiguity_covariance_from_normal, clock_eliminated_normal_equations, ppp_position_covariance,
19    solve_normal_equations, PppNormalLayout,
20};
21use super::rows::{build_rows, residual_rows, AmbiguityBinding, PppRowError};
22use super::temporal::{estimate_temporal_correlation, temporal_position_covariance};
23use super::{
24    apply_elevation_cutoff, estimates_tropo_gradients, estimates_ztd, max_abs,
25    residual_ionosphere_unknown_count, rms, state_from_solution, tropo_gradient_unknown_count,
26    validate_fixed_solve_boundary, weighted_rms, ztd_unknown_count, AmbiguitySearch,
27    FixedIntegerMetadata, FixedSolution, FixedSolveConfig, FixedSolveError, FloatEpoch,
28    FloatSolution, FloatSolveError, FloatSolveOptions, FloatState, FloatStatus, IntegerStatus,
29    ModelContext, TroposphereOptions,
30};
31
32/// Search integer ambiguities from an existing float PPP solution and re-solve
33/// position/clocks with those ambiguities held fixed.
34pub fn solve_fixed_from_float(
35    source: &dyn ObservableEphemerisSource,
36    epochs: &[FloatEpoch],
37    float_solution: FloatSolution,
38    config: FixedSolveConfig,
39) -> Result<FixedSolution, FixedSolveError> {
40    validate_fixed_solve_boundary(epochs, &float_solution, &config)?;
41    use crate::estimation::recipe::StrategyId;
42    use crate::estimation::strategies::{
43        estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
44    };
45    match estimate(
46        EstimateInput::PppFixed {
47            source,
48            epochs,
49            float_solution,
50            config,
51        },
52        EstimateOptions::new(StrategyId::ppp_reference()),
53    ) {
54        Ok(EstimateOutput::PppFixed(solution)) => Ok(*solution),
55        Err(EstimateError::PppFixed(error)) => Err(error),
56        Ok(_) | Err(_) => {
57            unreachable!(
58                "the PPP reference strategy yields a PPP fixed solution or a PPP fixed error"
59            )
60        }
61    }
62}
63
64/// Drive the integer-fixed PPP re-solve from a resolved [`EstimationRecipe`]: the
65/// shared 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_fixed_from_float(
71    recipe: &EstimationRecipe,
72    source: &dyn ObservableEphemerisSource,
73    epochs: &[FloatEpoch],
74    float_solution: FloatSolution,
75    config: FixedSolveConfig,
76) -> Result<FixedSolution, FixedSolveError> {
77    validate_fixed_solve_boundary(epochs, &float_solution, &config)?;
78    let initial_state = fixed_state_from_float(&float_solution);
79    let filtered_epochs;
80    let solve_epochs = if let Some(cutoff_deg) = config.elevation_cutoff_deg {
81        filtered_epochs = apply_elevation_cutoff(
82            source,
83            epochs,
84            &initial_state,
85            cutoff_deg,
86            config.tropo,
87            config.estimate_residual_ionosphere,
88        )
89        .map_err(FixedSolveError::Float)?;
90        filtered_epochs.as_slice()
91    } else {
92        epochs
93    };
94    let active_order;
95    let search_order = if config.elevation_cutoff_deg.is_some() {
96        active_order = active_ambiguity_ids(solve_epochs);
97        Some(active_order.as_slice())
98    } else {
99        None
100    };
101    let fixed_meta =
102        search_integer_ambiguities(source, solve_epochs, &float_solution, &config, search_order)?;
103    let fixed_m = fixed_ambiguities_m(
104        &fixed_meta.fixed_cycles,
105        &config.ambiguity.wavelengths_m,
106        &config.ambiguity.offsets_m,
107    )?;
108    let ctx = ModelContext {
109        source,
110        weights: config.weights,
111        tropo: config.tropo,
112        corrections: &config.corrections,
113        normal: recipe.normal,
114        estimate_residual_ionosphere: config.estimate_residual_ionosphere,
115    };
116    let resolve = iterate_fixed_multi(ctx, solve_epochs, &fixed_m, initial_state, config.opts, 1)?;
117    finalize_fixed_multi(
118        ctx,
119        solve_epochs,
120        fixed_meta,
121        fixed_m,
122        float_solution,
123        resolve,
124    )
125}
126
127struct FixedSearchResult {
128    order: Vec<AmbiguityId>,
129    fixed_cycles: BTreeMap<String, i64>,
130    integer: FixedIntegerMetadata,
131}
132
133/// Converged state from the ambiguity-conditioned re-solve, carried from
134/// [`iterate_fixed_multi`] into [`finalize_fixed_multi`].
135struct FixedResolve {
136    state: FloatState,
137    iterations: usize,
138    converged: bool,
139    status: FloatStatus,
140}
141
142impl From<FloatSolveError> for FixedSolveError {
143    fn from(value: FloatSolveError) -> Self {
144        Self::Float(value)
145    }
146}
147
148fn search_integer_ambiguities(
149    source: &dyn ObservableEphemerisSource,
150    epochs: &[FloatEpoch],
151    float_solution: &FloatSolution,
152    config: &FixedSolveConfig,
153    active_order: Option<&[AmbiguityId]>,
154) -> Result<FixedSearchResult, FixedSolveError> {
155    let order: Vec<AmbiguityId> = active_order.map_or_else(
156        || {
157            float_solution
158                .used_sats
159                .iter()
160                .map(|sat| AmbiguityId::new(sat.clone()))
161                .collect()
162        },
163        |order| order.to_vec(),
164    );
165    let covariance_cycles =
166        ambiguity_covariance_cycles(source, epochs, &order, float_solution, config)?;
167    let float_cycles = float_ambiguities_cycles(
168        float_solution,
169        &config.ambiguity.wavelengths_m,
170        &config.ambiguity.offsets_m,
171    )?;
172    let floats: Vec<f64> = order
173        .iter()
174        .map(|id| float_cycles.get(id.as_str()).copied().unwrap())
175        .collect();
176    let result = resolve_integer_lattice(
177        &floats,
178        &covariance_cycles,
179        config.ambiguity.ratio_threshold,
180    )
181    .map_err(FixedSolveError::Integer)?;
182    let fixed_cycles = order
183        .iter()
184        .map(|id| id.as_str().to_string())
185        .zip(result.fixed.iter().copied())
186        .collect::<BTreeMap<_, _>>();
187    let search_order: Vec<String> = order.iter().map(|id| id.as_str().to_string()).collect();
188    Ok(FixedSearchResult {
189        order,
190        fixed_cycles,
191        integer: FixedIntegerMetadata {
192            integer_status: if result.fixed_status {
193                IntegerStatus::Fixed
194            } else {
195                IntegerStatus::NotFixed
196            },
197            integer_ratio: result.ratio,
198            integer_best_score: result.best_score,
199            integer_second_best_score: result.second_best_score,
200            integer_candidates: result.candidates_evaluated,
201            ambiguity_search: AmbiguitySearch {
202                order: search_order,
203                float_cycles,
204                covariance_cycles: result.covariance,
205                covariance_inverse_cycles: result.covariance_inverse,
206            },
207        },
208    })
209}
210
211fn active_ambiguity_ids(epochs: &[FloatEpoch]) -> Vec<AmbiguityId> {
212    epochs
213        .iter()
214        .flat_map(|e| {
215            e.observations
216                .iter()
217                .map(|o| AmbiguityId::new(o.ambiguity_id.clone()))
218        })
219        .collect::<BTreeSet<_>>()
220        .into_iter()
221        .collect()
222}
223
224fn iterate_fixed_multi(
225    ctx: ModelContext,
226    epochs: &[FloatEpoch],
227    fixed_m: &BTreeMap<String, f64>,
228    state: FloatState,
229    opts: FloatSolveOptions,
230    iter: usize,
231) -> Result<FixedResolve, FixedSolveError> {
232    let mut current = state;
233    let mut iteration = iter;
234    let max_iterations = opts.max_iterations;
235
236    loop {
237        let binding = AmbiguityBinding::Held { values: fixed_m };
238        let rows = build_rows(ctx, epochs, &binding, &current).map_err(PppRowError::into_fixed)?;
239        let layout = PppNormalLayout::new(
240            epochs.len(),
241            ztd_unknown_count(ctx.tropo),
242            tropo_gradient_unknown_count(ctx.tropo),
243            residual_ionosphere_unknown_count(ctx.estimate_residual_ionosphere, fixed_m.len()),
244            0,
245        );
246        let dx = solve_normal_equations(&rows, layout, ctx.normal)?;
247        let next = apply_fixed_multi_delta(
248            &current,
249            epochs.len(),
250            fixed_m,
251            &dx,
252            ctx.tropo,
253            ctx.estimate_residual_ionosphere,
254        );
255        let (pos_step, clock_step, ztd_step, gradient_step) = fixed_multi_step_norms(
256            &dx,
257            ctx.tropo,
258            ctx.estimate_residual_ionosphere,
259            fixed_m.len(),
260        );
261
262        if pos_step <= opts.position_tolerance_m
263            && clock_step <= opts.clock_tolerance_m
264            && ztd_step <= opts.ztd_tolerance_m
265            && gradient_step <= opts.ztd_tolerance_m
266        {
267            return Ok(FixedResolve {
268                state: next,
269                iterations: iteration,
270                converged: true,
271                status: FloatStatus::StateTolerance,
272            });
273        }
274
275        if iteration >= max_iterations {
276            return Ok(FixedResolve {
277                state: next,
278                iterations: iteration,
279                converged: false,
280                status: FloatStatus::MaxIterations,
281            });
282        }
283
284        current = next;
285        iteration += 1;
286    }
287}
288
289fn apply_fixed_multi_delta(
290    state: &FloatState,
291    n_epochs: usize,
292    fixed_m: &BTreeMap<String, f64>,
293    dx: &[f64],
294    tropo: TroposphereOptions,
295    estimate_residual_ionosphere: bool,
296) -> FloatState {
297    let mut idx = 3;
298    let clock_deltas = &dx[idx..idx + n_epochs];
299    idx += n_epochs;
300    let ztd_delta = if estimates_ztd(tropo) {
301        let v = dx[idx];
302        idx += 1;
303        v
304    } else {
305        0.0
306    };
307    let (tropo_gradient_north_delta, tropo_gradient_east_delta) =
308        if estimates_tropo_gradients(tropo) {
309            let north = dx[idx];
310            let east = dx[idx + 1];
311            idx += 2;
312            (north, east)
313        } else {
314            (0.0, 0.0)
315        };
316    let mut residual_ionosphere_m = BTreeMap::new();
317    if estimate_residual_ionosphere {
318        let ionosphere_deltas = &dx[idx..idx + fixed_m.len()];
319        for ((id, _), delta) in fixed_m.iter().zip(ionosphere_deltas) {
320            let prior = state.residual_ionosphere_m.get(id).copied().unwrap_or(0.0);
321            residual_ionosphere_m.insert(id.clone(), prior + delta);
322        }
323    }
324    let clocks_m = state
325        .clocks_m
326        .iter()
327        .zip(clock_deltas)
328        .map(|(clock, delta)| clock + delta)
329        .collect();
330    FloatState {
331        position_m: [
332            state.position_m[0] + dx[0],
333            state.position_m[1] + dx[1],
334            state.position_m[2] + dx[2],
335        ],
336        clocks_m,
337        ambiguities_m: BTreeMap::new(),
338        ztd_m: state.ztd_m + ztd_delta,
339        tropo_gradient_north_m: state.tropo_gradient_north_m + tropo_gradient_north_delta,
340        tropo_gradient_east_m: state.tropo_gradient_east_m + tropo_gradient_east_delta,
341        residual_ionosphere_m,
342    }
343}
344
345fn fixed_multi_step_norms(
346    dx: &[f64],
347    tropo: TroposphereOptions,
348    estimate_residual_ionosphere: bool,
349    n_residual_ionosphere: usize,
350) -> (f64, f64, f64, f64) {
351    let pos = vec3::norm3([dx[0], dx[1], dx[2]]);
352    let n_ztd = ztd_unknown_count(tropo);
353    let n_gradients = tropo_gradient_unknown_count(tropo);
354    let n_ionosphere =
355        residual_ionosphere_unknown_count(estimate_residual_ionosphere, n_residual_ionosphere);
356    let n_clocks = dx.len() - 3 - n_ztd - n_gradients - n_ionosphere;
357    let clock = max_abs(&dx[3..3 + n_clocks]);
358    let mut idx = 3 + n_clocks;
359    let ztd = if estimates_ztd(tropo) {
360        let v = dx[idx].abs();
361        idx += 1;
362        v
363    } else {
364        0.0
365    };
366    let gradient = if estimates_tropo_gradients(tropo) {
367        let v = max_abs(&dx[idx..idx + 2]);
368        idx += 2;
369        v
370    } else {
371        0.0
372    };
373    let ionosphere = if estimate_residual_ionosphere {
374        max_abs(&dx[idx..idx + n_residual_ionosphere])
375    } else {
376        0.0
377    };
378    (pos, clock, ztd, gradient.max(ionosphere))
379}
380
381fn finalize_fixed_multi(
382    ctx: ModelContext,
383    epochs: &[FloatEpoch],
384    search: FixedSearchResult,
385    fixed_m: BTreeMap<String, f64>,
386    float_solution: FloatSolution,
387    resolve: FixedResolve,
388) -> Result<FixedSolution, FixedSolveError> {
389    let FixedResolve {
390        state,
391        iterations,
392        converged,
393        status,
394    } = resolve;
395    let residuals =
396        residual_rows(ctx, epochs, &fixed_m, &state).map_err(PppRowError::into_fixed)?;
397    let binding = AmbiguityBinding::Held { values: &fixed_m };
398    let rows = build_rows(ctx, epochs, &binding, &state).map_err(PppRowError::into_fixed)?;
399    let covariance = ppp_position_covariance(
400        &rows,
401        PppNormalLayout::new(
402            epochs.len(),
403            ztd_unknown_count(ctx.tropo),
404            tropo_gradient_unknown_count(ctx.tropo),
405            residual_ionosphere_unknown_count(ctx.estimate_residual_ionosphere, fixed_m.len()),
406            0,
407        ),
408        state.position_m,
409    )?;
410    let code: Vec<f64> = residuals.iter().map(|r| r.code_m).collect();
411    let phase: Vec<f64> = residuals.iter().map(|r| r.phase_m).collect();
412    let temporal_correlation = estimate_temporal_correlation(&residuals, epochs);
413    let (temporal_position_covariance, temporal_position_covariance_scale_factor) =
414        temporal_position_covariance(
415            covariance.formal,
416            covariance.posterior_variance_factor,
417            temporal_correlation,
418        );
419    Ok(FixedSolution {
420        position_m: state.position_m,
421        position_covariance: covariance.scaled,
422        formal_position_covariance: covariance.formal,
423        posterior_variance_factor: covariance.posterior_variance_factor,
424        position_covariance_scale_factor: covariance.covariance_scale_factor,
425        temporal_position_covariance,
426        temporal_position_covariance_scale_factor,
427        temporal_correlation,
428        epoch_clocks_m: state.clocks_m,
429        fixed_ambiguities_cycles: search.fixed_cycles,
430        fixed_ambiguities_m: fixed_m,
431        residual_ionosphere_m: if ctx.estimate_residual_ionosphere {
432            state.residual_ionosphere_m
433        } else {
434            BTreeMap::new()
435        },
436        ztd_residual_m: if estimates_ztd(ctx.tropo) {
437            Some(state.ztd_m)
438        } else {
439            None
440        },
441        tropo_gradient_north_m: if estimates_tropo_gradients(ctx.tropo) {
442            Some(state.tropo_gradient_north_m)
443        } else {
444            None
445        },
446        tropo_gradient_east_m: if estimates_tropo_gradients(ctx.tropo) {
447            Some(state.tropo_gradient_east_m)
448        } else {
449            None
450        },
451        tropo_gradient_covariance_m2: covariance.tropo_gradient_scaled_m2,
452        formal_tropo_gradient_covariance_m2: covariance.tropo_gradient_formal_m2,
453        float_solution,
454        residuals_m: residuals.clone(),
455        used_sats: search
456            .order
457            .into_iter()
458            .map(AmbiguityId::into_string)
459            .collect(),
460        iterations,
461        converged,
462        status,
463        code_rms_m: rms(&code),
464        phase_rms_m: rms(&phase),
465        weighted_rms_m: weighted_rms(&residuals, ctx.weights),
466        integer: search.integer,
467    })
468}
469
470fn fixed_state_from_float(solution: &FloatSolution) -> FloatState {
471    FloatState {
472        position_m: solution.position_m,
473        clocks_m: solution.epoch_clocks_m.clone(),
474        ambiguities_m: BTreeMap::new(),
475        ztd_m: solution.ztd_residual_m.unwrap_or(0.0),
476        tropo_gradient_north_m: solution.tropo_gradient_north_m.unwrap_or(0.0),
477        tropo_gradient_east_m: solution.tropo_gradient_east_m.unwrap_or(0.0),
478        residual_ionosphere_m: solution.residual_ionosphere_m.clone(),
479    }
480}
481
482fn float_ambiguities_cycles(
483    solution: &FloatSolution,
484    wavelengths_m: &BTreeMap<String, f64>,
485    offsets_m: &BTreeMap<String, f64>,
486) -> Result<BTreeMap<String, f64>, FixedSolveError> {
487    let mut out = BTreeMap::new();
488    for sat in &solution.used_sats {
489        let wavelength = wavelengths_m
490            .get(sat)
491            .copied()
492            .ok_or_else(|| FixedSolveError::MissingWavelength(sat.clone()))?;
493        let offset = offsets_m
494            .get(sat)
495            .copied()
496            .ok_or_else(|| FixedSolveError::MissingOffset(sat.clone()))?;
497        let ambiguity_m = solution.ambiguities_m.get(sat).copied().ok_or_else(|| {
498            FixedSolveError::Float(FloatSolveError::MissingAmbiguity(sat.clone()))
499        })?;
500        out.insert(sat.clone(), (ambiguity_m - offset) / wavelength);
501    }
502    Ok(out)
503}
504
505fn fixed_ambiguities_m(
506    fixed_cycles: &BTreeMap<String, i64>,
507    wavelengths_m: &BTreeMap<String, f64>,
508    offsets_m: &BTreeMap<String, f64>,
509) -> Result<BTreeMap<String, f64>, FixedSolveError> {
510    let mut out = BTreeMap::new();
511    for (sat, cycles) in fixed_cycles {
512        let wavelength = wavelengths_m
513            .get(sat)
514            .copied()
515            .ok_or_else(|| FixedSolveError::MissingWavelength(sat.clone()))?;
516        let offset = offsets_m
517            .get(sat)
518            .copied()
519            .ok_or_else(|| FixedSolveError::MissingOffset(sat.clone()))?;
520        out.insert(sat.clone(), offset + *cycles as f64 * wavelength);
521    }
522    Ok(out)
523}
524
525fn ambiguity_covariance_cycles(
526    source: &dyn ObservableEphemerisSource,
527    epochs: &[FloatEpoch],
528    ambiguity_ids: &[AmbiguityId],
529    float_solution: &FloatSolution,
530    config: &FixedSolveConfig,
531) -> Result<Vec<Vec<f64>>, FixedSolveError> {
532    let state = state_from_solution(float_solution, &FloatState::default_for_epochs(epochs));
533    let layout = PppNormalLayout::new(
534        epochs.len(),
535        ztd_unknown_count(config.tropo),
536        tropo_gradient_unknown_count(config.tropo),
537        residual_ionosphere_unknown_count(config.estimate_residual_ionosphere, ambiguity_ids.len()),
538        ambiguity_ids.len(),
539    );
540    let start = layout.reduced_ambiguity_offset();
541    let ctx = ModelContext {
542        source,
543        weights: config.weights,
544        tropo: config.tropo,
545        corrections: &config.corrections,
546        // Covariance assembly uses the const last-tie assembler directly; the
547        // recipe field is the PPP reference and unused on this path.
548        normal: NormalRecipe::PppDenseLastTie,
549        estimate_residual_ionosphere: config.estimate_residual_ionosphere,
550    };
551    let binding = AmbiguityBinding::Estimated {
552        ids: ambiguity_ids,
553        values: &state.ambiguities_m,
554    };
555    let rows = build_rows(ctx, epochs, &binding, &state)
556        .map_err(|e| FixedSolveError::from(e.into_float()))?;
557    let (normal, _rhs) = clock_eliminated_normal_equations(&rows, layout)?;
558    let covariance_m = ambiguity_covariance_from_normal(&normal, start, ambiguity_ids.len())?;
559    let mut covariance_cycles = vec![vec![0.0; ambiguity_ids.len()]; ambiguity_ids.len()];
560    for i in 0..ambiguity_ids.len() {
561        let lambda_i = config
562            .ambiguity
563            .wavelengths_m
564            .get(ambiguity_ids[i].as_str())
565            .copied()
566            .ok_or_else(|| {
567                FixedSolveError::MissingWavelength(ambiguity_ids[i].as_str().to_string())
568            })?;
569        for j in 0..ambiguity_ids.len() {
570            let lambda_j = config
571                .ambiguity
572                .wavelengths_m
573                .get(ambiguity_ids[j].as_str())
574                .copied()
575                .ok_or_else(|| {
576                    FixedSolveError::MissingWavelength(ambiguity_ids[j].as_str().to_string())
577                })?;
578            covariance_cycles[i][j] = covariance_m[i][j] / (lambda_i * lambda_j);
579        }
580    }
581    Ok(covariance_cycles)
582}