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    })
305}
306
307/// Per-epoch SPP code seed: returns the mean static position (summed in
308/// reverse-epoch order, matching the Elixir reduction) and the per-epoch clock
309/// seeds in arc order. Aborts on the first SPP failure.
310fn spp_seed<S>(
311    source: &S,
312    epochs: &[FloatEpoch],
313    options: PppAutoInitOptions,
314) -> Result<([f64; 3], Vec<f64>), PppAutoInitError>
315where
316    S: EphemerisSource + ObservableEphemerisSource,
317{
318    let mut positions = Vec::with_capacity(epochs.len());
319    let mut clocks = Vec::with_capacity(epochs.len());
320    for (epoch_index, epoch) in epochs.iter().enumerate() {
321        let inputs = spp_seed_inputs(epoch, options);
322        let solution = spp::solve(source, &inputs, false).map_err(|source| {
323            PppAutoInitError::CodeSeedFailed {
324                epoch_index,
325                source,
326            }
327        })?;
328        positions.push(solution.position.as_array());
329        clocks.push(solution.rx_clock_s * C_M_S);
330    }
331    Ok((mean_position(&positions), clocks))
332}
333
334/// Build the SPP seed inputs for one epoch: code-only pseudoranges, ionosphere
335/// off, the optional troposphere, and the caller's cold-start guess.
336fn spp_seed_inputs(epoch: &FloatEpoch, options: PppAutoInitOptions) -> spp::SolveInputs {
337    let observations = epoch
338        .observations
339        .iter()
340        .map(|obs| SppObservation {
341            satellite_id: obs.sat,
342            pseudorange_m: obs.code_m,
343        })
344        .collect();
345    spp::SolveInputs {
346        observations,
347        t_rx_j2000_s: epoch.t_rx_j2000_s,
348        t_rx_second_of_day_s: second_of_day(
349            i32::from(epoch.epoch.hour),
350            i32::from(epoch.epoch.minute),
351            epoch.epoch.second,
352        ),
353        day_of_year: day_of_year(
354            epoch.epoch.year,
355            i32::from(epoch.epoch.month),
356            i32::from(epoch.epoch.day),
357            i32::from(epoch.epoch.hour),
358            i32::from(epoch.epoch.minute),
359            epoch.epoch.second,
360        ),
361        initial_guess: options.spp_initial_guess,
362        corrections: Corrections {
363            ionosphere: false,
364            troposphere: options.spp_troposphere,
365        },
366        klobuchar: KlobucharCoeffs {
367            alpha: [0.0; 4],
368            beta: [0.0; 4],
369        },
370        beidou_klobuchar: None,
371        galileo_nequick: None,
372        sbas_iono: None,
373        glonass_channels: BTreeMap::new(),
374        met: options.spp_met,
375        robust: None,
376    }
377}
378
379/// Unweighted arithmetic mean of the per-epoch SPP positions.
380///
381/// The sum walks the positions in reverse-epoch order: the Elixir reference
382/// accumulates SPP positions onto a reversed list and reduces that list, so the
383/// floating-point addition order is last-epoch-first. Replicating it keeps the
384/// seed bit-for-bit.
385fn mean_position(positions: &[[f64; 3]]) -> [f64; 3] {
386    let mut sum = [0.0_f64; 3];
387    for position in positions.iter().rev() {
388        sum[0] += position[0];
389        sum[1] += position[1];
390        sum[2] += position[2];
391    }
392    let n = positions.len() as f64;
393    [sum[0] / n, sum[1] / n, sum[2] / n]
394}
395
396/// Phase-minus-code float ambiguity seed per ambiguity id.
397///
398/// Folds over every observation in arc-then-observation order and keeps the
399/// first-sighting `phase_m - code_m` (metres) for each ambiguity id, matching
400/// the Elixir `Map.put_new` fold. The [`BTreeMap`] result is the column key set
401/// the float solve indexes by.
402fn initial_ambiguities(epochs: &[FloatEpoch]) -> BTreeMap<String, f64> {
403    let mut ambiguities = BTreeMap::new();
404    for epoch in epochs {
405        for obs in &epoch.observations {
406            ambiguities
407                .entry(obs.ambiguity_id.clone())
408                .or_insert(obs.phase_m - obs.code_m);
409        }
410    }
411    ambiguities
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::astro::math::vec3::{norm3, sub3};
418    use crate::constants::F_L1_HZ;
419    use crate::estimation::strategies::{
420        estimate as estimate_with_strategy, EstimateInput, EstimateOptions, EstimateOutput,
421    };
422    use crate::observables::{predict, ObservableState, ObservablesError, PredictOptions};
423    use crate::ppp_corrections::CivilDateTime;
424    use crate::precise_positioning::{
425        FixedAmbiguityOptions, FixedSolveConfig, FloatObservation, FloatSolution,
426        FloatSolveOptions, MeasurementWeights, RangeCorrections, TroposphereOptions,
427    };
428    use crate::{GnssSatelliteId, GnssSystem};
429    use std::collections::BTreeMap;
430
431    /// Time-invariant ephemeris implementing both the SPP and observable traits.
432    struct SeedSource {
433        states: BTreeMap<GnssSatelliteId, [f64; 3]>,
434    }
435
436    impl ObservableEphemerisSource for SeedSource {
437        fn observable_state_at_j2000_s(
438            &self,
439            sat: GnssSatelliteId,
440            _t_j2000_s: f64,
441        ) -> Result<ObservableState, ObservablesError> {
442            Ok(ObservableState {
443                position_ecef_m: self
444                    .states
445                    .get(&sat)
446                    .copied()
447                    .ok_or(ObservablesError::NoEphemeris)?,
448                clock_s: Some(0.0),
449            })
450        }
451    }
452
453    impl EphemerisSource for SeedSource {
454        fn position_clock_at_j2000_s(
455            &self,
456            sat: GnssSatelliteId,
457            _t_j2000_s: f64,
458        ) -> Option<([f64; 3], f64)> {
459            self.states
460                .get(&sat)
461                .copied()
462                .map(|position| (position, 0.0))
463        }
464    }
465
466    fn sat_layout() -> [(u8, [f64; 3]); 6] {
467        // Six satellites all well above the SPP elevation mask at the truth
468        // receiver (elevations ~66-90 deg), so the per-epoch SPP seed keeps all
469        // six and the geometry is well conditioned.
470        [
471            (1, [14_350_000.0, 3_190_000.0, 21_440_000.0]),
472            (2, [20_000_000.0, 3_000_000.0, 18_000_000.0]),
473            (3, [9_000_000.0, 9_000_000.0, 22_000_000.0]),
474            (4, [16_000_000.0, -4_000_000.0, 21_000_000.0]),
475            (5, [10_000_000.0, -2_000_000.0, 24_000_000.0]),
476            (6, [19_000_000.0, 8_000_000.0, 17_000_000.0]),
477        ]
478    }
479
480    fn source_and_ids() -> (SeedSource, Vec<GnssSatelliteId>) {
481        let layout = sat_layout();
482        let ids: Vec<GnssSatelliteId> = layout
483            .iter()
484            .map(|(prn, _)| GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid prn"))
485            .collect();
486        let states = ids
487            .iter()
488            .zip(layout.iter())
489            .map(|(id, (_, position))| (*id, *position))
490            .collect();
491        (SeedSource { states }, ids)
492    }
493
494    fn make_epoch(
495        source: &SeedSource,
496        ids: &[GnssSatelliteId],
497        truth: [f64; 3],
498        clock_m: f64,
499        ambiguities_m: &BTreeMap<String, f64>,
500        t_rx_j2000_s: f64,
501    ) -> FloatEpoch {
502        let observations = ids
503            .iter()
504            .map(|id| {
505                let prediction = predict(
506                    source,
507                    *id,
508                    truth,
509                    t_rx_j2000_s,
510                    PredictOptions {
511                        carrier_hz: F_L1_HZ,
512                        light_time: true,
513                        sagnac: true,
514                    },
515                )
516                .expect("prediction");
517                let code_m = prediction.geometric_range_m + clock_m;
518                let ambiguity_m = ambiguities_m[&id.to_string()];
519                FloatObservation {
520                    sat: *id,
521                    satellite_id: id.to_string(),
522                    ambiguity_id: id.to_string(),
523                    code_m,
524                    phase_m: code_m + ambiguity_m,
525                    freq1_hz: 0.0,
526                    freq2_hz: 0.0,
527                    glonass_channel: None,
528                }
529            })
530            .collect();
531        FloatEpoch {
532            epoch: CivilDateTime {
533                year: 2020,
534                month: 6,
535                day: 24,
536                hour: 12,
537                minute: 0,
538                second: 0.0,
539            },
540            jd_whole: 2_459_024.5,
541            jd_fraction: 0.5,
542            t_rx_j2000_s,
543            observations,
544        }
545    }
546
547    fn float_config() -> FloatSolveConfig {
548        FloatSolveConfig {
549            weights: MeasurementWeights {
550                code: 1.0,
551                phase: 100.0,
552                elevation_weighting: false,
553            },
554            tropo: TroposphereOptions::disabled(),
555            corrections: RangeCorrections::disabled(),
556            opts: FloatSolveOptions::default(),
557            residual_screen: false,
558        }
559    }
560
561    fn fixed_config(ids: &[GnssSatelliteId], wavelength_m: f64) -> FixedSolveConfig {
562        let wavelengths_m: BTreeMap<String, f64> = ids
563            .iter()
564            .map(|id| (id.to_string(), wavelength_m))
565            .collect();
566        let offsets_m: BTreeMap<String, f64> = ids.iter().map(|id| (id.to_string(), 0.0)).collect();
567        FixedSolveConfig {
568            weights: float_config().weights,
569            tropo: float_config().tropo,
570            corrections: float_config().corrections,
571            opts: float_config().opts,
572            ambiguity: FixedAmbiguityOptions {
573                wavelengths_m,
574                offsets_m,
575                ratio_threshold: super::super::defaults::RATIO_THRESHOLD,
576            },
577        }
578    }
579
580    fn manual_float_with_strategy(
581        source: &SeedSource,
582        epochs: &[FloatEpoch],
583        strategy: PppAutoInitStrategy,
584    ) -> FloatSolution {
585        let initial_state =
586            seed_state(source, epochs, PppAutoInitOptions::default()).expect("seed builds");
587        match estimate_with_strategy(
588            EstimateInput::PppFloat {
589                source,
590                epochs,
591                initial_state,
592                config: float_config(),
593            },
594            EstimateOptions::new(strategy.strategy_id()),
595        )
596        .expect("manual float strategy")
597        {
598            EstimateOutput::PppFloat(solution) => *solution,
599            _ => unreachable!("PPP float estimate returns PPP float output"),
600        }
601    }
602
603    #[test]
604    fn auto_init_float_recovers_truth() {
605        let (source, ids) = source_and_ids();
606        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
607        let ambiguities_m: BTreeMap<String, f64> = ids
608            .iter()
609            .enumerate()
610            .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
611            .collect();
612        let clocks = [12.5, 13.0, 11.8];
613        let epochs: Vec<FloatEpoch> = clocks
614            .iter()
615            .enumerate()
616            .map(|(idx, &clock_m)| {
617                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
618            })
619            .collect();
620
621        let solution = solve_ppp_auto_init_float(
622            &source,
623            &epochs,
624            PppAutoInitOptions::default(),
625            float_config(),
626        )
627        .expect("float arc solves");
628
629        let error_m = norm3(sub3(solution.position_m, truth));
630        assert!(error_m < 1.0e-3, "position error {error_m} m too large");
631        for (idx, id) in ids.iter().enumerate() {
632            let recovered = solution.ambiguities_m[&id.to_string()];
633            let expected = 0.25 + idx as f64 * 0.1;
634            assert!(
635                (recovered - expected).abs() < 1.0e-3,
636                "ambiguity {id} recovered {recovered} expected {expected}"
637            );
638        }
639    }
640
641    #[test]
642    fn auto_init_matches_explicit_float_solve() {
643        // The driver is a thin seed in front of `solve_float_epochs`: seeding by
644        // hand from the same SPP policy and calling the existing solver must give
645        // the identical solution, proving the driver adds no solve behavior.
646        let (source, ids) = source_and_ids();
647        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
648        let ambiguities_m: BTreeMap<String, f64> = ids
649            .iter()
650            .enumerate()
651            .map(|(idx, id)| (id.to_string(), 0.4 + idx as f64 * 0.05))
652            .collect();
653        let clocks = [9.5, 10.25];
654        let epochs: Vec<FloatEpoch> = clocks
655            .iter()
656            .enumerate()
657            .map(|(idx, &clock_m)| {
658                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
659            })
660            .collect();
661
662        let driven = solve_ppp_auto_init_float(
663            &source,
664            &epochs,
665            PppAutoInitOptions::default(),
666            float_config(),
667        )
668        .expect("driver solves");
669
670        let hand_state =
671            seed_state(&source, &epochs, PppAutoInitOptions::default()).expect("seed builds");
672        let by_hand =
673            solve_float_epochs(&source, &epochs, hand_state, float_config()).expect("hand solve");
674        assert_eq!(driven, by_hand);
675    }
676
677    #[test]
678    fn auto_init_float_with_strategy_matches_manual_strategy_composition() {
679        let (source, ids) = source_and_ids();
680        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
681        let ambiguities_m: BTreeMap<String, f64> = ids
682            .iter()
683            .enumerate()
684            .map(|(idx, id)| (id.to_string(), 0.35 + idx as f64 * 0.07))
685            .collect();
686        let epochs: Vec<FloatEpoch> = [8.5, 9.25, 8.9]
687            .iter()
688            .enumerate()
689            .map(|(idx, &clock_m)| {
690                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
691            })
692            .collect();
693
694        for strategy in [
695            PppAutoInitStrategy::Reference,
696            PppAutoInitStrategy::Canonical,
697        ] {
698            let driven = solve_ppp_auto_init_float_with_strategy(
699                &source,
700                &epochs,
701                PppAutoInitOptions::default(),
702                float_config(),
703                strategy,
704            )
705            .expect("strategy driver solves");
706            let manual = manual_float_with_strategy(&source, &epochs, strategy);
707            assert_eq!(driven, manual);
708        }
709    }
710
711    #[test]
712    fn auto_init_fixed_holds_integers() {
713        let (source, ids) = source_and_ids();
714        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
715        let wavelength_m = C_M_S / F_L1_HZ;
716        // Integer-cycle ambiguities so the LAMBDA fix has an exact lattice point.
717        let cycles = [5i64, -3, 8, 2, -6, 4];
718        let ambiguities_m: BTreeMap<String, f64> = ids
719            .iter()
720            .zip(cycles.iter())
721            .map(|(id, &n)| (id.to_string(), n as f64 * wavelength_m))
722            .collect();
723        let clocks = [12.5, 12.7, 12.6];
724        let epochs: Vec<FloatEpoch> = clocks
725            .iter()
726            .enumerate()
727            .map(|(idx, &clock_m)| {
728                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
729            })
730            .collect();
731
732        let fixed_config = fixed_config(&ids, wavelength_m);
733
734        let fixed = solve_ppp_auto_init_fixed(
735            &source,
736            &epochs,
737            PppAutoInitOptions::default(),
738            float_config(),
739            fixed_config,
740        )
741        .expect("fixed arc solves");
742
743        let error_m = norm3(sub3(fixed.position_m, truth));
744        assert!(
745            error_m < 1.0e-3,
746            "fixed position error {error_m} m too large"
747        );
748        for (id, &n) in ids.iter().zip(cycles.iter()) {
749            let held = fixed.fixed_ambiguities_cycles[&id.to_string()];
750            assert_eq!(held, n, "satellite {id} integer cycle");
751        }
752    }
753
754    #[test]
755    fn auto_init_fixed_with_strategy_matches_manual_strategy_composition() {
756        let (source, ids) = source_and_ids();
757        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
758        let wavelength_m = C_M_S / F_L1_HZ;
759        let cycles = [4i64, -2, 6, 1, -5, 3];
760        let ambiguities_m: BTreeMap<String, f64> = ids
761            .iter()
762            .zip(cycles.iter())
763            .map(|(id, &n)| (id.to_string(), n as f64 * wavelength_m))
764            .collect();
765        let epochs: Vec<FloatEpoch> = [11.5, 11.7, 11.6]
766            .iter()
767            .enumerate()
768            .map(|(idx, &clock_m)| {
769                make_epoch(&source, &ids, truth, clock_m, &ambiguities_m, idx as f64)
770            })
771            .collect();
772        let strategy = PppAutoInitStrategy::Canonical;
773        let fixed_config = fixed_config(&ids, wavelength_m);
774
775        let driven = solve_ppp_auto_init_fixed_with_strategy(
776            &source,
777            &epochs,
778            PppAutoInitOptions::default(),
779            float_config(),
780            fixed_config.clone(),
781            strategy,
782        )
783        .expect("strategy fixed driver solves");
784        let manual_float = manual_float_with_strategy(&source, &epochs, strategy);
785        let manual = match estimate_with_strategy(
786            EstimateInput::PppFixed {
787                source: &source,
788                epochs: &epochs,
789                float_solution: manual_float,
790                config: fixed_config,
791            },
792            EstimateOptions::new(strategy.strategy_id()),
793        )
794        .expect("manual fixed strategy")
795        {
796            EstimateOutput::PppFixed(solution) => *solution,
797            _ => unreachable!("PPP fixed estimate returns PPP fixed output"),
798        };
799
800        assert_eq!(driven, manual);
801    }
802}