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