1use 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#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct PppInitialGuess {
54 pub position_m: [f64; 3],
56 pub clock_m: f64,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct PppAutoInitOptions {
63 pub initial_guess: Option<PppInitialGuess>,
66 pub spp_initial_guess: [f64; 4],
69 pub spp_troposphere: bool,
72 pub spp_met: SurfaceMet,
74}
75
76impl Default for PppAutoInitOptions {
77 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub enum PppAutoInitStrategy {
97 #[default]
99 Reference,
100 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#[derive(Debug, Clone)]
117pub enum PppAutoInitError {
118 EmptyEpochs,
120 CodeSeedFailed {
123 epoch_index: usize,
125 source: SppError,
127 },
128 Float(FloatSolveError),
130 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
159pub 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
179pub 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
195pub 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
216pub 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
282fn 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
310fn 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
337fn 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
382fn 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
399fn 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 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 [
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 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 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}