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