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;
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::{
23    estimates_ztd, max_abs, rms, state_from_solution, validate_fixed_solve_boundary, weighted_rms,
24    ztd_unknown_count, AmbiguitySearch, FixedIntegerMetadata, FixedSolution, FixedSolveConfig,
25    FixedSolveError, FloatEpoch, FloatSolution, FloatSolveError, FloatSolveOptions, FloatState,
26    FloatStatus, IntegerStatus, ModelContext, TroposphereOptions,
27};
28
29/// Search integer ambiguities from an existing float PPP solution and re-solve
30/// position/clocks with those ambiguities held fixed.
31pub fn solve_fixed_from_float(
32    source: &dyn ObservableEphemerisSource,
33    epochs: &[FloatEpoch],
34    float_solution: FloatSolution,
35    config: FixedSolveConfig,
36) -> Result<FixedSolution, FixedSolveError> {
37    validate_fixed_solve_boundary(epochs, &float_solution, &config)?;
38    use crate::estimation::recipe::StrategyId;
39    use crate::estimation::strategies::{
40        estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
41    };
42    match estimate(
43        EstimateInput::PppFixed {
44            source,
45            epochs,
46            float_solution,
47            config,
48        },
49        EstimateOptions::new(StrategyId::ppp_reference()),
50    ) {
51        Ok(EstimateOutput::PppFixed(solution)) => Ok(*solution),
52        Err(EstimateError::PppFixed(error)) => Err(error),
53        Ok(_) | Err(_) => {
54            unreachable!(
55                "the PPP reference strategy yields a PPP fixed solution or a PPP fixed error"
56            )
57        }
58    }
59}
60
61/// Drive the integer-fixed PPP re-solve from a resolved [`EstimationRecipe`]: the
62/// shared per-technique implementation that
63/// [`crate::estimation::strategies::estimate`] dispatches to. The recipe's
64/// [`NormalRecipe`] reaches the solve seam through [`ModelContext::normal`]; for
65/// the PPP reference recipe (`NormalRecipe::PppDenseLastTie`) this is
66/// bit-identical to the legacy path.
67pub(crate) fn run_fixed_from_float(
68    recipe: &EstimationRecipe,
69    source: &dyn ObservableEphemerisSource,
70    epochs: &[FloatEpoch],
71    float_solution: FloatSolution,
72    config: FixedSolveConfig,
73) -> Result<FixedSolution, FixedSolveError> {
74    validate_fixed_solve_boundary(epochs, &float_solution, &config)?;
75    let fixed_meta = search_integer_ambiguities(source, epochs, &float_solution, &config)?;
76    let fixed_m = fixed_ambiguities_m(
77        &fixed_meta.fixed_cycles,
78        &config.ambiguity.wavelengths_m,
79        &config.ambiguity.offsets_m,
80    )?;
81    let initial_state = fixed_state_from_float(&float_solution);
82    let ctx = ModelContext {
83        source,
84        weights: config.weights,
85        tropo: config.tropo,
86        corrections: &config.corrections,
87        normal: recipe.normal,
88    };
89    let resolve = iterate_fixed_multi(ctx, epochs, &fixed_m, initial_state, config.opts, 1)?;
90    finalize_fixed_multi(ctx, epochs, fixed_meta, fixed_m, float_solution, resolve)
91}
92
93struct FixedSearchResult {
94    order: Vec<AmbiguityId>,
95    fixed_cycles: BTreeMap<String, i64>,
96    integer: FixedIntegerMetadata,
97}
98
99/// Converged state from the ambiguity-conditioned re-solve, carried from
100/// [`iterate_fixed_multi`] into [`finalize_fixed_multi`].
101struct FixedResolve {
102    state: FloatState,
103    iterations: usize,
104    converged: bool,
105    status: FloatStatus,
106}
107
108impl From<FloatSolveError> for FixedSolveError {
109    fn from(value: FloatSolveError) -> Self {
110        Self::Float(value)
111    }
112}
113
114fn search_integer_ambiguities(
115    source: &dyn ObservableEphemerisSource,
116    epochs: &[FloatEpoch],
117    float_solution: &FloatSolution,
118    config: &FixedSolveConfig,
119) -> Result<FixedSearchResult, FixedSolveError> {
120    let order: Vec<AmbiguityId> = float_solution
121        .used_sats
122        .iter()
123        .map(|sat| AmbiguityId::new(sat.clone()))
124        .collect();
125    let covariance_cycles =
126        ambiguity_covariance_cycles(source, epochs, &order, float_solution, config)?;
127    let float_cycles = float_ambiguities_cycles(
128        float_solution,
129        &config.ambiguity.wavelengths_m,
130        &config.ambiguity.offsets_m,
131    )?;
132    let floats: Vec<f64> = order
133        .iter()
134        .map(|id| float_cycles.get(id.as_str()).copied().unwrap())
135        .collect();
136    let result = resolve_integer_lattice(
137        &floats,
138        &covariance_cycles,
139        config.ambiguity.ratio_threshold,
140    )
141    .map_err(FixedSolveError::Integer)?;
142    let fixed_cycles = order
143        .iter()
144        .map(|id| id.as_str().to_string())
145        .zip(result.fixed.iter().copied())
146        .collect::<BTreeMap<_, _>>();
147    let search_order: Vec<String> = order.iter().map(|id| id.as_str().to_string()).collect();
148    Ok(FixedSearchResult {
149        order,
150        fixed_cycles,
151        integer: FixedIntegerMetadata {
152            integer_status: if result.fixed_status {
153                IntegerStatus::Fixed
154            } else {
155                IntegerStatus::NotFixed
156            },
157            integer_ratio: result.ratio,
158            integer_best_score: result.best_score,
159            integer_second_best_score: result.second_best_score,
160            integer_candidates: result.candidates_evaluated,
161            ambiguity_search: AmbiguitySearch {
162                order: search_order,
163                float_cycles,
164                covariance_cycles: result.covariance,
165                covariance_inverse_cycles: result.covariance_inverse,
166            },
167        },
168    })
169}
170
171fn iterate_fixed_multi(
172    ctx: ModelContext,
173    epochs: &[FloatEpoch],
174    fixed_m: &BTreeMap<String, f64>,
175    state: FloatState,
176    opts: FloatSolveOptions,
177    iter: usize,
178) -> Result<FixedResolve, FixedSolveError> {
179    let mut current = state;
180    let mut iteration = iter;
181    let max_iterations = opts.max_iterations;
182
183    loop {
184        let binding = AmbiguityBinding::Held { values: fixed_m };
185        let rows = build_rows(ctx, epochs, &binding, &current).map_err(PppRowError::into_fixed)?;
186        let layout = PppNormalLayout::new(epochs.len(), ztd_unknown_count(ctx.tropo), 0);
187        let dx = solve_normal_equations(&rows, layout, ctx.normal)?;
188        let next = apply_fixed_multi_delta(&current, epochs.len(), &dx, ctx.tropo);
189        let (pos_step, clock_step, ztd_step) = fixed_multi_step_norms(&dx, ctx.tropo);
190
191        if pos_step <= opts.position_tolerance_m
192            && clock_step <= opts.clock_tolerance_m
193            && ztd_step <= opts.ztd_tolerance_m
194        {
195            return Ok(FixedResolve {
196                state: next,
197                iterations: iteration,
198                converged: true,
199                status: FloatStatus::StateTolerance,
200            });
201        }
202
203        if iteration >= max_iterations {
204            return Ok(FixedResolve {
205                state: next,
206                iterations: iteration,
207                converged: false,
208                status: FloatStatus::MaxIterations,
209            });
210        }
211
212        current = next;
213        iteration += 1;
214    }
215}
216
217fn apply_fixed_multi_delta(
218    state: &FloatState,
219    n_epochs: usize,
220    dx: &[f64],
221    tropo: TroposphereOptions,
222) -> FloatState {
223    let mut idx = 3;
224    let clock_deltas = &dx[idx..idx + n_epochs];
225    idx += n_epochs;
226    let ztd_delta = if estimates_ztd(tropo) { dx[idx] } else { 0.0 };
227    let clocks_m = state
228        .clocks_m
229        .iter()
230        .zip(clock_deltas)
231        .map(|(clock, delta)| clock + delta)
232        .collect();
233    FloatState {
234        position_m: [
235            state.position_m[0] + dx[0],
236            state.position_m[1] + dx[1],
237            state.position_m[2] + dx[2],
238        ],
239        clocks_m,
240        ambiguities_m: BTreeMap::new(),
241        ztd_m: state.ztd_m + ztd_delta,
242    }
243}
244
245fn fixed_multi_step_norms(dx: &[f64], tropo: TroposphereOptions) -> (f64, f64, f64) {
246    let pos = vec3::norm3([dx[0], dx[1], dx[2]]);
247    let n_ztd = ztd_unknown_count(tropo);
248    let n_clocks = dx.len() - 3 - n_ztd;
249    let clock = max_abs(&dx[3..3 + n_clocks]);
250    let ztd = if estimates_ztd(tropo) {
251        dx[3 + n_clocks].abs()
252    } else {
253        0.0
254    };
255    (pos, clock, ztd)
256}
257
258fn finalize_fixed_multi(
259    ctx: ModelContext,
260    epochs: &[FloatEpoch],
261    search: FixedSearchResult,
262    fixed_m: BTreeMap<String, f64>,
263    float_solution: FloatSolution,
264    resolve: FixedResolve,
265) -> Result<FixedSolution, FixedSolveError> {
266    let FixedResolve {
267        state,
268        iterations,
269        converged,
270        status,
271    } = resolve;
272    let residuals =
273        residual_rows(ctx, epochs, &fixed_m, &state).map_err(PppRowError::into_fixed)?;
274    let binding = AmbiguityBinding::Held { values: &fixed_m };
275    let rows = build_rows(ctx, epochs, &binding, &state).map_err(PppRowError::into_fixed)?;
276    let covariance = ppp_position_covariance(
277        &rows,
278        PppNormalLayout::new(epochs.len(), ztd_unknown_count(ctx.tropo), 0),
279        state.position_m,
280    )?;
281    let code: Vec<f64> = residuals.iter().map(|r| r.code_m).collect();
282    let phase: Vec<f64> = residuals.iter().map(|r| r.phase_m).collect();
283    Ok(FixedSolution {
284        position_m: state.position_m,
285        position_covariance: covariance.scaled,
286        formal_position_covariance: covariance.formal,
287        posterior_variance_factor: covariance.posterior_variance_factor,
288        position_covariance_scale_factor: covariance.covariance_scale_factor,
289        epoch_clocks_m: state.clocks_m,
290        fixed_ambiguities_cycles: search.fixed_cycles,
291        fixed_ambiguities_m: fixed_m,
292        ztd_residual_m: if estimates_ztd(ctx.tropo) {
293            Some(state.ztd_m)
294        } else {
295            None
296        },
297        float_solution,
298        residuals_m: residuals.clone(),
299        used_sats: search
300            .order
301            .into_iter()
302            .map(AmbiguityId::into_string)
303            .collect(),
304        iterations,
305        converged,
306        status,
307        code_rms_m: rms(&code),
308        phase_rms_m: rms(&phase),
309        weighted_rms_m: weighted_rms(&residuals, ctx.weights),
310        integer: search.integer,
311    })
312}
313
314fn fixed_state_from_float(solution: &FloatSolution) -> FloatState {
315    FloatState {
316        position_m: solution.position_m,
317        clocks_m: solution.epoch_clocks_m.clone(),
318        ambiguities_m: BTreeMap::new(),
319        ztd_m: solution.ztd_residual_m.unwrap_or(0.0),
320    }
321}
322
323fn float_ambiguities_cycles(
324    solution: &FloatSolution,
325    wavelengths_m: &BTreeMap<String, f64>,
326    offsets_m: &BTreeMap<String, f64>,
327) -> Result<BTreeMap<String, f64>, FixedSolveError> {
328    let mut out = BTreeMap::new();
329    for sat in &solution.used_sats {
330        let wavelength = wavelengths_m
331            .get(sat)
332            .copied()
333            .ok_or_else(|| FixedSolveError::MissingWavelength(sat.clone()))?;
334        let offset = offsets_m
335            .get(sat)
336            .copied()
337            .ok_or_else(|| FixedSolveError::MissingOffset(sat.clone()))?;
338        let ambiguity_m = solution.ambiguities_m.get(sat).copied().ok_or_else(|| {
339            FixedSolveError::Float(FloatSolveError::MissingAmbiguity(sat.clone()))
340        })?;
341        out.insert(sat.clone(), (ambiguity_m - offset) / wavelength);
342    }
343    Ok(out)
344}
345
346fn fixed_ambiguities_m(
347    fixed_cycles: &BTreeMap<String, i64>,
348    wavelengths_m: &BTreeMap<String, f64>,
349    offsets_m: &BTreeMap<String, f64>,
350) -> Result<BTreeMap<String, f64>, FixedSolveError> {
351    let mut out = BTreeMap::new();
352    for (sat, cycles) in fixed_cycles {
353        let wavelength = wavelengths_m
354            .get(sat)
355            .copied()
356            .ok_or_else(|| FixedSolveError::MissingWavelength(sat.clone()))?;
357        let offset = offsets_m
358            .get(sat)
359            .copied()
360            .ok_or_else(|| FixedSolveError::MissingOffset(sat.clone()))?;
361        out.insert(sat.clone(), offset + *cycles as f64 * wavelength);
362    }
363    Ok(out)
364}
365
366fn ambiguity_covariance_cycles(
367    source: &dyn ObservableEphemerisSource,
368    epochs: &[FloatEpoch],
369    ambiguity_ids: &[AmbiguityId],
370    float_solution: &FloatSolution,
371    config: &FixedSolveConfig,
372) -> Result<Vec<Vec<f64>>, FixedSolveError> {
373    let state = state_from_solution(float_solution, &FloatState::default_for_epochs(epochs));
374    let layout = PppNormalLayout::new(
375        epochs.len(),
376        ztd_unknown_count(config.tropo),
377        ambiguity_ids.len(),
378    );
379    let start = layout.reduced_ambiguity_offset();
380    let ctx = ModelContext {
381        source,
382        weights: config.weights,
383        tropo: config.tropo,
384        corrections: &config.corrections,
385        // Covariance assembly uses the const last-tie assembler directly; the
386        // recipe field is the PPP reference and unused on this path.
387        normal: NormalRecipe::PppDenseLastTie,
388    };
389    let binding = AmbiguityBinding::Estimated {
390        ids: ambiguity_ids,
391        values: &state.ambiguities_m,
392    };
393    let rows = build_rows(ctx, epochs, &binding, &state)
394        .map_err(|e| FixedSolveError::from(e.into_float()))?;
395    let (normal, _rhs) = clock_eliminated_normal_equations(&rows, layout)?;
396    let covariance_m = ambiguity_covariance_from_normal(&normal, start, ambiguity_ids.len())?;
397    let mut covariance_cycles = vec![vec![0.0; ambiguity_ids.len()]; ambiguity_ids.len()];
398    for i in 0..ambiguity_ids.len() {
399        let lambda_i = config
400            .ambiguity
401            .wavelengths_m
402            .get(ambiguity_ids[i].as_str())
403            .copied()
404            .ok_or_else(|| {
405                FixedSolveError::MissingWavelength(ambiguity_ids[i].as_str().to_string())
406            })?;
407        for j in 0..ambiguity_ids.len() {
408            let lambda_j = config
409                .ambiguity
410                .wavelengths_m
411                .get(ambiguity_ids[j].as_str())
412                .copied()
413                .ok_or_else(|| {
414                    FixedSolveError::MissingWavelength(ambiguity_ids[j].as_str().to_string())
415                })?;
416            covariance_cycles[i][j] = covariance_m[i][j] / (lambda_i * lambda_j);
417        }
418    }
419    Ok(covariance_cycles)
420}