Skip to main content

sidereon_core/precise_positioning/
auto_init.rs

1//! SPP-seeded auto-initialization driver for static multi-epoch PPP arcs.
2//!
3//! This leaf owns the high-level "raw epochs in, solved arc out" orchestration
4//! that previously lived only in the Elixir binding. It seeds the static PPP
5//! float state from the existing single-point-positioning solver and the raw
6//! observations, then runs the existing static float solve ([`solve_float_epochs`])
7//! and, for the fixed driver, the existing integer-fixed re-solve
8//! ([`solve_fixed_from_float`]). It re-implements no solve numerics; only the
9//! seeding policy moves here so every binding can delegate to one driver.
10//!
11//! The seeding policy reproduces the Elixir reference
12//! (`Sidereon.GNSS.PrecisePositioning.solve_float_epochs/3`) exactly so a later
13//! binding delegation is bit-for-bit:
14//!
15//! 1. **SPP seed** - per epoch, a code-only single-point solve with the
16//!    ionosphere correction off (the troposphere optional), each epoch using the
17//!    caller's cold-start guess. The first SPP failure aborts the whole driver.
18//! 2. **Mean-position seed** - the static position seed is the unweighted
19//!    arithmetic mean of every epoch's SPP position, summed in reverse-epoch
20//!    order to match the reference's floating-point reduction.
21//! 3. **Per-epoch clock seed** - each epoch keeps its own SPP receiver clock
22//!    (seconds times the speed of light), in arc order.
23//! 4. **Phase-minus-code ambiguity seed** - each ambiguity id is seeded with
24//!    `phase_m - code_m` (metres) from the first epoch, in sorted-observation
25//!    order, where that id is seen.
26//! 5. **ZTD seed** - the zenith-total-delay residual seeds to zero.
27//!
28//! When the caller supplies an explicit initial guess, the SPP/mean stages are
29//! skipped and that position with its clock (duplicated across epochs) is the
30//! seed, again matching the reference.
31
32use std::collections::BTreeMap;
33
34use crate::astro::time::{day_of_year, second_of_day};
35use crate::constants::C_M_S;
36use crate::estimation::recipe::{StrategyId, Technique};
37use crate::estimation::strategies::{
38    estimate, EstimateError, EstimateInput, EstimateOptions, EstimateOutput,
39};
40use crate::observables::ObservableEphemerisSource;
41use crate::spp::{
42    self, Corrections, EphemerisSource, KlobucharCoeffs, Observation as SppObservation, SppError,
43    SurfaceMet,
44};
45
46use super::{
47    solve_fixed_from_float, solve_float_epochs, FixedSolution, FixedSolveConfig, FixedSolveError,
48    FloatEpoch, FloatSolution, FloatSolveConfig, FloatSolveError, FloatState,
49};
50
51/// Explicit static-position/clock seed that bypasses the SPP auto-init stages.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct PppInitialGuess {
54    /// Static receiver position seed (ECEF metres).
55    pub position_m: [f64; 3],
56    /// Receiver clock seed (metres), duplicated across every epoch.
57    pub clock_m: f64,
58}
59
60/// Auto-initialization policy for the raw-epochs PPP driver.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct PppAutoInitOptions {
63    /// Explicit seed. `Some` skips the SPP/mean stages entirely (the Elixir
64    /// `:initial_guess`); `None` runs the per-epoch SPP auto-init.
65    pub initial_guess: Option<PppInitialGuess>,
66    /// SPP cold-start guess `[x_m, y_m, z_m, b_m]` for every per-epoch seed solve
67    /// (the Elixir `:spp_initial_guess`, default all-zero).
68    pub spp_initial_guess: [f64; 4],
69    /// Apply the troposphere correction in the SPP seed solve (the Elixir
70    /// `:troposphere`, default off). The ionosphere is always off in the seed.
71    pub spp_troposphere: bool,
72    /// Surface meteorology used by the SPP seed troposphere (when enabled).
73    pub spp_met: SurfaceMet,
74}
75
76impl Default for PppAutoInitOptions {
77    /// Canonical auto-init defaults mirroring the Elixir reference: no explicit
78    /// guess, an all-zero SPP cold start, the troposphere off, and standard
79    /// surface meteorology (1013.25 hPa, 288.15 K, 0.5 relative humidity).
80    fn default() -> Self {
81        Self {
82            initial_guess: None,
83            spp_initial_guess: [0.0; 4],
84            spp_troposphere: false,
85            spp_met: SurfaceMet {
86                pressure_hpa: 1013.25,
87                temperature_k: 288.15,
88                relative_humidity: 0.5,
89            },
90        }
91    }
92}
93
94/// Runtime strategy selector for the PPP auto-init drivers.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub enum PppAutoInitStrategy {
97    /// Reference static PPP solve path.
98    #[default]
99    Reference,
100    /// Canonical static PPP solve path.
101    Canonical,
102}
103
104impl PppAutoInitStrategy {
105    const fn strategy_id(self) -> StrategyId {
106        match self {
107            Self::Reference => StrategyId::ppp_reference(),
108            Self::Canonical => StrategyId::Canonical {
109                technique: Technique::Ppp,
110            },
111        }
112    }
113}
114
115/// Why the raw-epochs PPP driver could not complete.
116#[derive(Debug, Clone)]
117pub enum PppAutoInitError {
118    /// The arc has no epochs.
119    EmptyEpochs,
120    /// A per-epoch SPP seed solve failed; the whole driver aborts on the first
121    /// failure (the Elixir `:code_seed_failed`).
122    CodeSeedFailed {
123        /// Index of the failing epoch in the input arc.
124        epoch_index: usize,
125        /// The SPP failure.
126        source: SppError,
127    },
128    /// The seeded static float solve failed.
129    Float(FloatSolveError),
130    /// The integer-fixed re-solve failed.
131    Fixed(FixedSolveError),
132}
133
134impl core::fmt::Display for PppAutoInitError {
135    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136        match self {
137            Self::EmptyEpochs => write!(f, "PPP auto-init requires at least one epoch"),
138            Self::CodeSeedFailed {
139                epoch_index,
140                source,
141            } => write!(f, "PPP code seed failed at epoch {epoch_index}: {source}"),
142            Self::Float(error) => write!(f, "{error}"),
143            Self::Fixed(error) => write!(f, "{error}"),
144        }
145    }
146}
147
148impl std::error::Error for PppAutoInitError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        match self {
151            Self::CodeSeedFailed { source, .. } => Some(source),
152            Self::Float(error) => Some(error),
153            Self::Fixed(error) => Some(error),
154            Self::EmptyEpochs => None,
155        }
156    }
157}
158
159/// Solve a static multi-epoch float PPP arc from raw epochs, auto-initializing
160/// the float state from the SPP seed described on the module.
161///
162/// This is the float raw-epochs driver: it seeds the state and then calls the
163/// existing [`solve_float_epochs`]. The `source` is used both as the SPP seed
164/// ephemeris and as the PPP observable ephemeris (`Sp3` and the broadcast
165/// ephemeris implement both traits).
166pub fn solve_ppp_auto_init_float<S>(
167    source: &S,
168    epochs: &[FloatEpoch],
169    options: PppAutoInitOptions,
170    config: FloatSolveConfig,
171) -> Result<FloatSolution, PppAutoInitError>
172where
173    S: EphemerisSource + ObservableEphemerisSource,
174{
175    let initial_state = seed_state(source, epochs, options)?;
176    solve_float_epochs(source, epochs, initial_state, config).map_err(PppAutoInitError::Float)
177}
178
179/// Solve a static multi-epoch float PPP arc from raw epochs, selecting the PPP
180/// strategy after the auto-init seed is built.
181pub fn solve_ppp_auto_init_float_with_strategy<S>(
182    source: &S,
183    epochs: &[FloatEpoch],
184    options: PppAutoInitOptions,
185    config: FloatSolveConfig,
186    strategy: PppAutoInitStrategy,
187) -> Result<FloatSolution, PppAutoInitError>
188where
189    S: EphemerisSource + ObservableEphemerisSource,
190{
191    let initial_state = seed_state(source, epochs, options)?;
192    solve_float_with_strategy(source, epochs, initial_state, config, strategy)
193}
194
195/// Solve a static integer-fixed PPP arc from raw epochs: auto-init seed, the
196/// float solve, then the LAMBDA integer fix and ambiguity-conditioned re-solve.
197///
198/// This reproduces the Elixir `solve_fixed_epochs/3` order: the float arc is
199/// solved first (from the same auto-init seed as [`solve_ppp_auto_init_float`]),
200/// then [`solve_fixed_from_float`] runs the integer search and fixed re-solve.
201pub fn solve_ppp_auto_init_fixed<S>(
202    source: &S,
203    epochs: &[FloatEpoch],
204    options: PppAutoInitOptions,
205    float_config: FloatSolveConfig,
206    fixed_config: FixedSolveConfig,
207) -> Result<FixedSolution, PppAutoInitError>
208where
209    S: EphemerisSource + ObservableEphemerisSource,
210{
211    let float_solution = solve_ppp_auto_init_float(source, epochs, options, float_config)?;
212    solve_fixed_from_float(source, epochs, float_solution, fixed_config)
213        .map_err(PppAutoInitError::Fixed)
214}
215
216/// Solve a static integer-fixed PPP arc from raw epochs, selecting the PPP
217/// strategy for both the float seed solve and the fixed re-solve.
218pub fn solve_ppp_auto_init_fixed_with_strategy<S>(
219    source: &S,
220    epochs: &[FloatEpoch],
221    options: PppAutoInitOptions,
222    float_config: FloatSolveConfig,
223    fixed_config: FixedSolveConfig,
224    strategy: PppAutoInitStrategy,
225) -> Result<FixedSolution, PppAutoInitError>
226where
227    S: EphemerisSource + ObservableEphemerisSource,
228{
229    let float_solution =
230        solve_ppp_auto_init_float_with_strategy(source, epochs, options, float_config, strategy)?;
231    solve_fixed_with_strategy(source, epochs, float_solution, fixed_config, strategy)
232}
233
234fn solve_float_with_strategy(
235    source: &dyn ObservableEphemerisSource,
236    epochs: &[FloatEpoch],
237    initial_state: FloatState,
238    config: FloatSolveConfig,
239    strategy: PppAutoInitStrategy,
240) -> Result<FloatSolution, PppAutoInitError> {
241    match estimate(
242        EstimateInput::PppFloat {
243            source,
244            epochs,
245            initial_state,
246            config,
247        },
248        EstimateOptions::new(strategy.strategy_id()),
249    ) {
250        Ok(EstimateOutput::PppFloat(solution)) => Ok(*solution),
251        Err(EstimateError::PppFloat(error)) => Err(PppAutoInitError::Float(error)),
252        Ok(_) | Err(_) => {
253            unreachable!("PPP float strategy produces a PPP float result or error")
254        }
255    }
256}
257
258fn solve_fixed_with_strategy(
259    source: &dyn ObservableEphemerisSource,
260    epochs: &[FloatEpoch],
261    float_solution: FloatSolution,
262    config: FixedSolveConfig,
263    strategy: PppAutoInitStrategy,
264) -> Result<FixedSolution, PppAutoInitError> {
265    match estimate(
266        EstimateInput::PppFixed {
267            source,
268            epochs,
269            float_solution,
270            config,
271        },
272        EstimateOptions::new(strategy.strategy_id()),
273    ) {
274        Ok(EstimateOutput::PppFixed(solution)) => Ok(*solution),
275        Err(EstimateError::PppFixed(error)) => Err(PppAutoInitError::Fixed(error)),
276        Ok(_) | Err(_) => {
277            unreachable!("PPP fixed strategy produces a PPP fixed result or error")
278        }
279    }
280}
281
282/// Build the auto-initialized float state (position, per-epoch clocks, float
283/// ambiguities, zero ZTD residual) from the raw epochs.
284fn seed_state<S>(
285    source: &S,
286    epochs: &[FloatEpoch],
287    options: PppAutoInitOptions,
288) -> Result<FloatState, PppAutoInitError>
289where
290    S: EphemerisSource + ObservableEphemerisSource,
291{
292    if epochs.is_empty() {
293        return Err(PppAutoInitError::EmptyEpochs);
294    }
295    let (position_m, clocks_m) = match options.initial_guess {
296        Some(guess) => (guess.position_m, vec![guess.clock_m; epochs.len()]),
297        None => spp_seed(source, epochs, options)?,
298    };
299    Ok(FloatState {
300        position_m,
301        clocks_m,
302        ambiguities_m: initial_ambiguities(epochs),
303        ztd_m: 0.0,
304        tropo_gradient_north_m: 0.0,
305        tropo_gradient_east_m: 0.0,
306        residual_ionosphere_m: BTreeMap::new(),
307    })
308}
309
310/// Per-epoch SPP code seed: returns the mean static position (summed in
311/// reverse-epoch order, matching the Elixir reduction) and the per-epoch clock
312/// seeds in arc order. Aborts on the first SPP failure.
313fn spp_seed<S>(
314    source: &S,
315    epochs: &[FloatEpoch],
316    options: PppAutoInitOptions,
317) -> Result<([f64; 3], Vec<f64>), PppAutoInitError>
318where
319    S: EphemerisSource + ObservableEphemerisSource,
320{
321    let mut positions = Vec::with_capacity(epochs.len());
322    let mut clocks = Vec::with_capacity(epochs.len());
323    for (epoch_index, epoch) in epochs.iter().enumerate() {
324        let inputs = spp_seed_inputs(epoch, options);
325        let solution = spp::solve(source, &inputs, false).map_err(|source| {
326            PppAutoInitError::CodeSeedFailed {
327                epoch_index,
328                source,
329            }
330        })?;
331        positions.push(solution.position.as_array());
332        clocks.push(solution.rx_clock_s * C_M_S);
333    }
334    Ok((mean_position(&positions), clocks))
335}
336
337/// Build the SPP seed inputs for one epoch: code-only pseudoranges, ionosphere
338/// off, the optional troposphere, and the caller's cold-start guess.
339fn spp_seed_inputs(epoch: &FloatEpoch, options: PppAutoInitOptions) -> spp::SolveInputs {
340    let observations = epoch
341        .observations
342        .iter()
343        .map(|obs| SppObservation {
344            satellite_id: obs.sat,
345            pseudorange_m: obs.code_m,
346        })
347        .collect();
348    spp::SolveInputs {
349        observations,
350        t_rx_j2000_s: epoch.t_rx_j2000_s,
351        t_rx_second_of_day_s: second_of_day(
352            i32::from(epoch.epoch.hour),
353            i32::from(epoch.epoch.minute),
354            epoch.epoch.second,
355        ),
356        day_of_year: day_of_year(
357            epoch.epoch.year,
358            i32::from(epoch.epoch.month),
359            i32::from(epoch.epoch.day),
360            i32::from(epoch.epoch.hour),
361            i32::from(epoch.epoch.minute),
362            epoch.epoch.second,
363        ),
364        initial_guess: options.spp_initial_guess,
365        corrections: Corrections {
366            ionosphere: false,
367            troposphere: options.spp_troposphere,
368        },
369        klobuchar: KlobucharCoeffs {
370            alpha: [0.0; 4],
371            beta: [0.0; 4],
372        },
373        beidou_klobuchar: None,
374        galileo_nequick: None,
375        sbas_iono: None,
376        glonass_channels: BTreeMap::new(),
377        met: options.spp_met,
378        robust: None,
379    }
380}
381
382/// Unweighted arithmetic mean of the per-epoch SPP positions.
383///
384/// The sum walks the positions in reverse-epoch order: the Elixir reference
385/// accumulates SPP positions onto a reversed list and reduces that list, so the
386/// floating-point addition order is last-epoch-first. Replicating it keeps the
387/// seed bit-for-bit.
388fn mean_position(positions: &[[f64; 3]]) -> [f64; 3] {
389    let mut sum = [0.0_f64; 3];
390    for position in positions.iter().rev() {
391        sum[0] += position[0];
392        sum[1] += position[1];
393        sum[2] += position[2];
394    }
395    let n = positions.len() as f64;
396    [sum[0] / n, sum[1] / n, sum[2] / n]
397}
398
399/// Phase-minus-code float ambiguity seed per ambiguity id.
400///
401/// Folds over every observation in arc-then-observation order and keeps the
402/// first-sighting `phase_m - code_m` (metres) for each ambiguity id, matching
403/// the Elixir `Map.put_new` fold. The [`BTreeMap`] result is the column key set
404/// the float solve indexes by.
405fn initial_ambiguities(epochs: &[FloatEpoch]) -> BTreeMap<String, f64> {
406    let mut ambiguities = BTreeMap::new();
407    for epoch in epochs {
408        for obs in &epoch.observations {
409            ambiguities
410                .entry(obs.ambiguity_id.clone())
411                .or_insert(obs.phase_m - obs.code_m);
412        }
413    }
414    ambiguities
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::astro::math::vec3::{norm3, sub3};
421    use crate::constants::F_L1_HZ;
422    use crate::estimation::strategies::{
423        estimate as estimate_with_strategy, EstimateInput, EstimateOptions, EstimateOutput,
424    };
425    use crate::observables::{predict, ObservableState, ObservablesError, PredictOptions};
426    use crate::ppp_corrections::CivilDateTime;
427    use crate::precise_positioning::{
428        FixedAmbiguityOptions, FixedSolveConfig, FloatObservation, FloatSolution,
429        FloatSolveOptions, MeasurementWeights, RangeCorrections, TroposphereOptions,
430    };
431    use crate::{GnssSatelliteId, GnssSystem};
432    use std::collections::BTreeMap;
433
434    /// Time-invariant ephemeris implementing both the SPP and observable traits.
435    struct SeedSource {
436        states: BTreeMap<GnssSatelliteId, [f64; 3]>,
437    }
438
439    impl ObservableEphemerisSource for SeedSource {
440        fn observable_state_at_j2000_s(
441            &self,
442            sat: GnssSatelliteId,
443            _t_j2000_s: f64,
444        ) -> Result<ObservableState, ObservablesError> {
445            Ok(ObservableState {
446                position_ecef_m: self
447                    .states
448                    .get(&sat)
449                    .copied()
450                    .ok_or(ObservablesError::NoEphemeris)?,
451                clock_s: Some(0.0),
452            })
453        }
454    }
455
456    impl EphemerisSource for SeedSource {
457        fn position_clock_at_j2000_s(
458            &self,
459            sat: GnssSatelliteId,
460            _t_j2000_s: f64,
461        ) -> Option<([f64; 3], f64)> {
462            self.states
463                .get(&sat)
464                .copied()
465                .map(|position| (position, 0.0))
466        }
467    }
468
469    fn sat_layout() -> [(u8, [f64; 3]); 6] {
470        // Six satellites all well above the SPP elevation mask at the truth
471        // receiver (elevations ~66-90 deg), so the per-epoch SPP seed keeps all
472        // six and the geometry is well conditioned.
473        [
474            (1, [14_350_000.0, 3_190_000.0, 21_440_000.0]),
475            (2, [20_000_000.0, 3_000_000.0, 18_000_000.0]),
476            (3, [9_000_000.0, 9_000_000.0, 22_000_000.0]),
477            (4, [16_000_000.0, -4_000_000.0, 21_000_000.0]),
478            (5, [10_000_000.0, -2_000_000.0, 24_000_000.0]),
479            (6, [19_000_000.0, 8_000_000.0, 17_000_000.0]),
480        ]
481    }
482
483    fn source_and_ids() -> (SeedSource, Vec<GnssSatelliteId>) {
484        let layout = sat_layout();
485        let ids: Vec<GnssSatelliteId> = layout
486            .iter()
487            .map(|(prn, _)| GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid prn"))
488            .collect();
489        let states = ids
490            .iter()
491            .zip(layout.iter())
492            .map(|(id, (_, position))| (*id, *position))
493            .collect();
494        (SeedSource { states }, ids)
495    }
496
497    fn make_epoch(
498        source: &SeedSource,
499        ids: &[GnssSatelliteId],
500        truth: [f64; 3],
501        clock_m: f64,
502        ambiguities_m: &BTreeMap<String, f64>,
503        t_rx_j2000_s: f64,
504    ) -> FloatEpoch {
505        let observations = ids
506            .iter()
507            .map(|id| {
508                let prediction = predict(
509                    source,
510                    *id,
511                    truth,
512                    t_rx_j2000_s,
513                    PredictOptions {
514                        carrier_hz: F_L1_HZ,
515                        light_time: true,
516                        sagnac: true,
517                    },
518                )
519                .expect("prediction");
520                let code_m = prediction.geometric_range_m + clock_m;
521                let ambiguity_m = ambiguities_m[&id.to_string()];
522                FloatObservation {
523                    sat: *id,
524                    satellite_id: id.to_string(),
525                    ambiguity_id: id.to_string(),
526                    code_m,
527                    phase_m: code_m + ambiguity_m,
528                    freq1_hz: 0.0,
529                    freq2_hz: 0.0,
530                    glonass_channel: None,
531                }
532            })
533            .collect();
534        FloatEpoch {
535            epoch: CivilDateTime {
536                year: 2020,
537                month: 6,
538                day: 24,
539                hour: 12,
540                minute: 0,
541                second: 0.0,
542            },
543            jd_whole: 2_459_024.5,
544            jd_fraction: 0.5,
545            t_rx_j2000_s,
546            observations,
547        }
548    }
549
550    fn float_config() -> FloatSolveConfig {
551        FloatSolveConfig {
552            weights: MeasurementWeights {
553                code: 1.0,
554                phase: 100.0,
555                elevation_weighting: false,
556            },
557            tropo: TroposphereOptions::disabled(),
558            corrections: RangeCorrections::disabled(),
559            opts: FloatSolveOptions::default(),
560            elevation_cutoff_deg: None,
561            residual_screen: false,
562            estimate_residual_ionosphere: false,
563        }
564    }
565
566    fn fixed_config(ids: &[GnssSatelliteId], wavelength_m: f64) -> FixedSolveConfig {
567        let wavelengths_m: BTreeMap<String, f64> = ids
568            .iter()
569            .map(|id| (id.to_string(), wavelength_m))
570            .collect();
571        let offsets_m: BTreeMap<String, f64> = ids.iter().map(|id| (id.to_string(), 0.0)).collect();
572        FixedSolveConfig {
573            weights: float_config().weights,
574            tropo: float_config().tropo,
575            corrections: float_config().corrections,
576            opts: float_config().opts,
577            elevation_cutoff_deg: None,
578            ambiguity: FixedAmbiguityOptions {
579                wavelengths_m,
580                offsets_m,
581                ratio_threshold: super::super::defaults::RATIO_THRESHOLD,
582            },
583            estimate_residual_ionosphere: false,
584        }
585    }
586
587    fn manual_float_with_strategy(
588        source: &SeedSource,
589        epochs: &[FloatEpoch],
590        strategy: PppAutoInitStrategy,
591    ) -> FloatSolution {
592        let initial_state =
593            seed_state(source, epochs, PppAutoInitOptions::default()).expect("seed builds");
594        match estimate_with_strategy(
595            EstimateInput::PppFloat {
596                source,
597                epochs,
598                initial_state,
599                config: float_config(),
600            },
601            EstimateOptions::new(strategy.strategy_id()),
602        )
603        .expect("manual float strategy")
604        {
605            EstimateOutput::PppFloat(solution) => *solution,
606            _ => unreachable!("PPP float estimate returns PPP float output"),
607        }
608    }
609
610    #[test]
611    fn auto_init_float_recovers_truth() {
612        let (source, ids) = source_and_ids();
613        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
614        let ambiguities_m: BTreeMap<String, f64> = ids
615            .iter()
616            .enumerate()
617            .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
618            .collect();
619        let clocks = [12.5, 13.0, 11.8];
620        let epochs: Vec<FloatEpoch> = clocks
621            .iter()
622            .enumerate()
623            .map(|(idx, &clock_m)| {
624                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
625            })
626            .collect();
627
628        let solution = solve_ppp_auto_init_float(
629            &source,
630            &epochs,
631            PppAutoInitOptions::default(),
632            float_config(),
633        )
634        .expect("float arc solves");
635
636        let error_m = norm3(sub3(solution.position_m, truth));
637        assert!(error_m < 1.0e-3, "position error {error_m} m too large");
638        for (idx, id) in ids.iter().enumerate() {
639            let recovered = solution.ambiguities_m[&id.to_string()];
640            let expected = 0.25 + idx as f64 * 0.1;
641            assert!(
642                (recovered - expected).abs() < 1.0e-3,
643                "ambiguity {id} recovered {recovered} expected {expected}"
644            );
645        }
646    }
647
648    #[test]
649    fn auto_init_matches_explicit_float_solve() {
650        // The driver is a thin seed in front of `solve_float_epochs`: seeding by
651        // hand from the same SPP policy and calling the existing solver must give
652        // the identical solution, proving the driver adds no solve behavior.
653        let (source, ids) = source_and_ids();
654        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
655        let ambiguities_m: BTreeMap<String, f64> = ids
656            .iter()
657            .enumerate()
658            .map(|(idx, id)| (id.to_string(), 0.4 + idx as f64 * 0.05))
659            .collect();
660        let clocks = [9.5, 10.25];
661        let epochs: Vec<FloatEpoch> = clocks
662            .iter()
663            .enumerate()
664            .map(|(idx, &clock_m)| {
665                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
666            })
667            .collect();
668
669        let driven = solve_ppp_auto_init_float(
670            &source,
671            &epochs,
672            PppAutoInitOptions::default(),
673            float_config(),
674        )
675        .expect("driver solves");
676
677        let hand_state =
678            seed_state(&source, &epochs, PppAutoInitOptions::default()).expect("seed builds");
679        let by_hand =
680            solve_float_epochs(&source, &epochs, hand_state, float_config()).expect("hand solve");
681        assert_eq!(driven, by_hand);
682    }
683
684    #[test]
685    fn auto_init_float_with_strategy_matches_manual_strategy_composition() {
686        let (source, ids) = source_and_ids();
687        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
688        let ambiguities_m: BTreeMap<String, f64> = ids
689            .iter()
690            .enumerate()
691            .map(|(idx, id)| (id.to_string(), 0.35 + idx as f64 * 0.07))
692            .collect();
693        let epochs: Vec<FloatEpoch> = [8.5, 9.25, 8.9]
694            .iter()
695            .enumerate()
696            .map(|(idx, &clock_m)| {
697                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
698            })
699            .collect();
700
701        for strategy in [
702            PppAutoInitStrategy::Reference,
703            PppAutoInitStrategy::Canonical,
704        ] {
705            let driven = solve_ppp_auto_init_float_with_strategy(
706                &source,
707                &epochs,
708                PppAutoInitOptions::default(),
709                float_config(),
710                strategy,
711            )
712            .expect("strategy driver solves");
713            let manual = manual_float_with_strategy(&source, &epochs, strategy);
714            assert_eq!(driven, manual);
715        }
716    }
717
718    #[test]
719    fn auto_init_fixed_holds_integers() {
720        let (source, ids) = source_and_ids();
721        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
722        let wavelength_m = C_M_S / F_L1_HZ;
723        // Integer-cycle ambiguities so the LAMBDA fix has an exact lattice point.
724        let cycles = [5i64, -3, 8, 2, -6, 4];
725        let ambiguities_m: BTreeMap<String, f64> = ids
726            .iter()
727            .zip(cycles.iter())
728            .map(|(id, &n)| (id.to_string(), n as f64 * wavelength_m))
729            .collect();
730        let clocks = [12.5, 12.7, 12.6];
731        let epochs: Vec<FloatEpoch> = clocks
732            .iter()
733            .enumerate()
734            .map(|(idx, &clock_m)| {
735                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
736            })
737            .collect();
738
739        let fixed_config = fixed_config(&ids, wavelength_m);
740
741        let fixed = solve_ppp_auto_init_fixed(
742            &source,
743            &epochs,
744            PppAutoInitOptions::default(),
745            float_config(),
746            fixed_config,
747        )
748        .expect("fixed arc solves");
749
750        let error_m = norm3(sub3(fixed.position_m, truth));
751        assert!(
752            error_m < 1.0e-3,
753            "fixed position error {error_m} m too large"
754        );
755        for (id, &n) in ids.iter().zip(cycles.iter()) {
756            let held = fixed.fixed_ambiguities_cycles[&id.to_string()];
757            assert_eq!(held, n, "satellite {id} integer cycle");
758        }
759    }
760
761    #[test]
762    fn auto_init_fixed_with_strategy_matches_manual_strategy_composition() {
763        let (source, ids) = source_and_ids();
764        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
765        let wavelength_m = C_M_S / F_L1_HZ;
766        let cycles = [4i64, -2, 6, 1, -5, 3];
767        let ambiguities_m: BTreeMap<String, f64> = ids
768            .iter()
769            .zip(cycles.iter())
770            .map(|(id, &n)| (id.to_string(), n as f64 * wavelength_m))
771            .collect();
772        let epochs: Vec<FloatEpoch> = [11.5, 11.7, 11.6]
773            .iter()
774            .enumerate()
775            .map(|(idx, &clock_m)| {
776                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
777            })
778            .collect();
779        let strategy = PppAutoInitStrategy::Canonical;
780        let fixed_config = fixed_config(&ids, wavelength_m);
781
782        let driven = solve_ppp_auto_init_fixed_with_strategy(
783            &source,
784            &epochs,
785            PppAutoInitOptions::default(),
786            float_config(),
787            fixed_config.clone(),
788            strategy,
789        )
790        .expect("strategy fixed driver solves");
791        let manual_float = manual_float_with_strategy(&source, &epochs, strategy);
792        let manual = match estimate_with_strategy(
793            EstimateInput::PppFixed {
794                source: &source,
795                epochs: &epochs,
796                float_solution: manual_float,
797                config: fixed_config,
798            },
799            EstimateOptions::new(strategy.strategy_id()),
800        )
801        .expect("manual fixed strategy")
802        {
803            EstimateOutput::PppFixed(solution) => *solution,
804            _ => unreachable!("PPP fixed estimate returns PPP fixed output"),
805        };
806
807        assert_eq!(driven, manual);
808    }
809}