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