Skip to main content

sidereon/
lib.rs

1//! # sidereon
2//!
3//! A thin, ergonomic API over the [`sidereon_core`] engine. It does not model
4//! anything itself: every function here delegates to a `sidereon_core` reference
5//! entry point and re-exports its result structs, so the numerical behavior is
6//! identical to calling the core directly. The value it adds is a small, human
7//! surface:
8//!
9//! - [`load_sp3`] parses a precise SP3 ephemeris product,
10//! - [`parse_antex`] / [`load_antex`] parse ANTEX antenna calibration products,
11//! - [`parse_rinex_nav`] / [`load_rinex_nav`] parse RINEX broadcast navigation
12//!   products into a queryable broadcast ephemeris store,
13//! - [`parse_rinex_obs`] / [`load_rinex_obs`] parse RINEX observation products,
14//! - [`lint_rinex_obs`] / [`lint_rinex_nav`] check RINEX observation/navigation
15//!   text and [`repair_rinex_obs`] / [`repair_rinex_nav`] apply mechanical fixes,
16//! - [`parse_rinex_clock`] / [`load_rinex_clock`] parse RINEX clock products,
17//!   with lossy variants for best-effort recovery,
18//! - [`decode_crinex`] / [`load_crinex`] expand Hatanaka-compressed
19//!   observation files, and [`encode_crinex`] compacts plain RINEX OBS text,
20//! - [`spp_inputs_from_rinex_obs`] assembles parsed RINEX observations into SPP
21//!   solve inputs, with [`solve_spp_from_rinex_obs`] as the serial batch
22//!   convenience,
23//! - [`solve_spp`] runs single-point positioning,
24//! - [`araim`] exposes multi-hypothesis protection levels from supplied
25//!   geometry and integrity support data,
26//! - [`solve_velocity`] solves receiver ECEF velocity and clock drift from
27//!   range-rate or Doppler observations,
28//! - [`solve_rtk_float_with`] / [`solve_rtk_fixed_with`] solve static RTK
29//!   baselines from typed configs,
30//! - [`solve_ppp_float_with`] / [`solve_ppp_fixed_with`] solve static PPP arcs
31//!   from typed configs,
32//! - [`solve_static`] solves multi-epoch static pseudorange batches with
33//!   covariance and leave-one-out diagnostics,
34//! - GNSS utility modules such as [`frequencies`], [`combinations`],
35//!   [`quality`], [`carrier_phase`], [`signal`], [`velocity`],
36//!   [`broadcast_comparison`], [`constants`], [`navigation`], [`geometry`],
37//!   [`data`], [`dgnss`], [`constellation`], [`ppp_corrections`], [`rtk`],
38//!   [`staleness`], [`tides`], [`ils`], and [`terrain`] expose the core helper
39//!   surface,
40//! - [`astro`] exposes time/frame conversions, Sun/Moon positions, RF link
41//!   budgets, solar beta angles, equinoctial element transforms, eclipse
42//!   events, conjunction/covariance utilities, CDM/OMM/TDM parsing, TCA
43//!   screening, and orbit propagation,
44//! - [`tle`], [`sgp4`], [`passes`], and [`tca`] remain root-level shortcuts for
45//!   SGP4/TLE propagation, topocentric az/el/range over a ground station, and
46//!   close-approach screening,
47//! - one [`Error`] enum unifies product parsing/loading and every solve failure.
48//!
49//! The input, result, and ephemeris types each function takes and returns are
50//! re-exported from `sidereon_core` under [`antex`], [`rinex`], [`astro`],
51//! [`ephemeris`], [`positioning`], [`observables`], and the curated
52//! [`rtk_filter`] / [`precise_positioning`] facades, so a consumer imports the
53//! ergonomic API types from this one crate. Lower-level RTK/PPP internals remain
54//! available under [`raw`] with their native core errors.
55//!
56//! ```
57//! // Malformed input surfaces a single error type.
58//! let parsed = sidereon::load_sp3(b"not a valid sp3 file");
59//! assert!(matches!(parsed, Err(sidereon::Error::Sp3(_))));
60//! ```
61//!
62//! Lower-level helpers re-exported through modules such as [`rinex`] keep their
63//! native core error type. Map them explicitly at wrapper boundaries; there is
64//! intentionally no blanket conversion into [`Error`].
65//!
66//! ```compile_fail
67//! fn decode_from_reexported_core() -> sidereon::Result<()> {
68//!     sidereon::rinex::decode_crinex("not a CRINEX file\n")?;
69//!     Ok(())
70//! }
71//! ```
72//!
73//! Low-level RTK and PPP core modules live behind the explicit [`raw`] escape
74//! hatch, not the ergonomic facades.
75//!
76//! ```
77//! let _ = sidereon::raw::rtk_filter::RtkFilterScratch::new();
78//! ```
79//!
80//! Hot-path RTK filter APIs are intentionally not part of the ergonomic
81//! `sidereon::rtk_filter` facade.
82//!
83//! ```compile_fail
84//! use sidereon::rtk_filter::{update_epoch_with_scratch, RtkFilterScratch};
85//! ```
86//!
87//! PPP preparation and raw solver APIs are intentionally not part of the
88//! ergonomic `sidereon::precise_positioning` facade.
89//!
90//! ```compile_fail
91//! use sidereon::precise_positioning::{prepare_widelane_fixed_epochs, solve_float_epochs};
92//! ```
93//!
94//! RTK config builders consume the config. Dropping the returned value must not
95//! leave an unchanged copy available for solving.
96//!
97//! ```compile_fail
98//! use sidereon::{
99//!     rtk_filter::{FloatSolveOpts, MeasModel, StochasticModel},
100//!     RtkFloatConfig,
101//! };
102//!
103//! let epochs = Vec::new();
104//! let ambiguity_ids = Vec::<String>::new();
105//! let model = MeasModel {
106//!     code_sigma_m: 0.3,
107//!     phase_sigma_m: 0.003,
108//!     sagnac: true,
109//!     stochastic: StochasticModel::Rtklib,
110//! };
111//! let opts = FloatSolveOpts {
112//!     position_tol_m: 1.0e-4,
113//!     ambiguity_tol_m: 1.0e-4,
114//!     max_iterations: 1,
115//! };
116//! let config = RtkFloatConfig::new(&epochs, [0.0; 3], &ambiguity_ids, &model, opts);
117//! config.with_initial_baseline_m([1.0, 0.0, 0.0]);
118//! let _ = sidereon::solve_rtk_float_with(config);
119//! ```
120//!
121//! ```compile_fail
122//! use std::collections::BTreeMap;
123//!
124//! use sidereon::{
125//!     rtk_filter::{
126//!         AmbiguityScale, AmbiguitySet, FixedSolveOpts, FloatSolveOpts, MeasModel,
127//!         ResidualValidationOpts, StochasticModel, ValidatedFixedSolveOpts,
128//!     },
129//!     RtkFixedConfig,
130//! };
131//!
132//! let epochs = Vec::new();
133//! let ambiguity_ids = Vec::<String>::new();
134//! let ambiguity_satellites = BTreeMap::new();
135//! let wavelengths_m = BTreeMap::new();
136//! let offsets_m = BTreeMap::new();
137//! let float_only_systems = Vec::new();
138//! let ambiguity_set = AmbiguitySet {
139//!     ids: &ambiguity_ids,
140//!     satellites: &ambiguity_satellites,
141//!     scale: AmbiguityScale {
142//!         wavelengths_m: &wavelengths_m,
143//!         offsets_m: &offsets_m,
144//!     },
145//!     float_only_systems: &float_only_systems,
146//! };
147//! let model = MeasModel {
148//!     code_sigma_m: 0.3,
149//!     phase_sigma_m: 0.003,
150//!     sagnac: true,
151//!     stochastic: StochasticModel::Rtklib,
152//! };
153//! let opts = ValidatedFixedSolveOpts {
154//!     float: FloatSolveOpts {
155//!         position_tol_m: 1.0e-4,
156//!         ambiguity_tol_m: 1.0e-4,
157//!         max_iterations: 1,
158//!     },
159//!     fixed: FixedSolveOpts {
160//!         position_tol_m: 1.0e-4,
161//!         ambiguity_tol_m: 1.0e-4,
162//!         max_iterations: 1,
163//!         ratio_threshold: 3.0,
164//!         partial_ambiguity_resolution: false,
165//!         partial_min_ambiguities: 4,
166//!     },
167//!     residual: ResidualValidationOpts {
168//!         threshold_sigma: None,
169//!         max_exclusions: 0,
170//!     },
171//! };
172//! let config = RtkFixedConfig::new(&epochs, [0.0; 3], ambiguity_set, &model, opts);
173//! config.with_initial_baseline_m([1.0, 0.0, 0.0]);
174//! let _ = sidereon::solve_rtk_fixed_with(config);
175//! ```
176
177use core::fmt;
178use flate2::read::GzDecoder;
179use std::io::Read;
180use std::path::Path;
181
182// Re-export core domain modules whose public helpers are intentionally part of
183// the ergonomic crate surface.
184pub use sidereon_core::astro::forces::{
185    EarthRadiationPressure, SchwarzschildRelativity, SolarRadiationPressure,
186    SphericalHarmonicCoefficient, SphericalHarmonicGravity, SphericalHarmonicGravityConfig,
187    ThirdBodyBodies, ThirdBodyGravity, ZonalCoefficients, ZonalDegrees, ZonalGravity,
188    EGM96_DEGREE_ORDER_36, EGM96_EMBEDDED_MAX_DEGREE, EGM96_EMBEDDED_MAX_ORDER, EGM96_MU_KM3_S2,
189    EGM96_REFERENCE_RADIUS_KM,
190};
191pub use sidereon_core::astro::frames::transforms::{
192    gcrs_to_topocentric_compute, geodetic_from_ecef_proj, geodetic_to_itrs,
193    itrs_to_geodetic_compute, itrs_to_topocentric, FrameTransformError, GeodeticStationKm,
194};
195pub use sidereon_core::astro::frames::{
196    EarthOrientation, EarthOrientationProvider, TdbEarthOrientationProvider,
197};
198pub use sidereon_core::astro::propagator::{ForceModelComponents, ForceModelKind};
199pub use sidereon_core::geometry_quality::{
200    classify, GeometryQuality, GeometryQualityThresholds, ObservabilityTier,
201};
202pub use sidereon_core::positioning::{
203    solve_spp_from_rinex_obs, spp_inputs_from_rinex_obs, spp_inputs_from_rtcm_msm,
204    RinexSppAssemblySource, RinexSppBroadcastCorrections, RinexSppEpochInputs,
205    RinexSppEpochSolution, RinexSppError, RinexSppOptions, RinexSppSource, RtcmSppEpochInputs,
206};
207pub use sidereon_core::quality::{
208    reliability_araim, reliability_design, spp_robust_fde_driver, wtest_noncentrality,
209    wtest_noncentrality_components, ObservationReliability, RangeReliabilityRow,
210    ReliabilityOptions, ReliabilityReport, ReliabilitySummary, WtestNoncentralityComponents,
211};
212pub use sidereon_core::static_positioning::{
213    solve_static, StaticClockBias, StaticCovariance, StaticEpoch, StaticEpochInfluence,
214    StaticInfluenceStatus, StaticResidual, StaticSatelliteBatchInfluence, StaticSatelliteInfluence,
215    StaticSolution, StaticSolutionMetadata, StaticSolveError, StaticSolveOptions,
216};
217pub use sidereon_core::{
218    antex, araim, astro, atmosphere, bias, broadcast_comparison, carrier_phase, clock_stability,
219    combinations, constants, constellation, data, dgnss, dop, ephemeris, frame_catalog,
220    frequencies, fusion, geodesic, geodetic_time_series, geofence, geometry, geometry_quality, ils,
221    inertial, navigation, nmea, observables, orbit, positioning, ppp_corrections, qc_obs, quality,
222    rinex, rtcm, rtk, sbas, sbas_pl, sidereal, signal, source_localization, ssr, staleness,
223    static_positioning, terrain, terrain_store, tides, velocity,
224};
225pub use sidereon_core::{
226    catalog, catalog_entry, propagate_position, transform, transform_from_epoch, FrameCatalogError,
227    HelmertParameters, HelmertRates, HelmertTransform, TerrestrialFrame, TerrestrialPositionM,
228    TerrestrialState, TerrestrialVelocityMPerYear, TERRESTRIAL_FRAME_CATALOG,
229};
230pub use sidereon_core::{
231    containment, containment_probability, containment_probability_with_options, crossing,
232    crossing_probability, crossing_probability_with_options, distance_to_boundary, CrossingEvent,
233    CrossingKind, Fence, GeofenceError, GeofencePositionEstimate, PositionUncertainty,
234    ProbabilityHysteresis, ProbabilityMethod, ProbabilityOptions, GEOFENCE_BOUNDARY_TOLERANCE_M,
235    PLANAR_FAST_PATH_MAX_RADIUS_M,
236};
237pub use sidereon_core::{
238    emission_media_batch_at_j2000_s, observable_media_corrections, predict_batch_with_media,
239    predict_batch_with_media_parallel, predict_ranges_with_media, predict_with_media,
240    AppliedMediaCorrections, EmissionMediaBatch, EmissionMediaBatchOptions, EmissionMediaStatus,
241    MediaPredictOptions, MediaPredictedObservables, MediaRangePrediction,
242    ObservableIonosphereCorrection, ObservableMediaOptions, ObservableTroposphereCorrection,
243};
244pub use sidereon_core::{
245    error_ellipse_from_enu_m2, horizontal_radius_at, metrics_from_ecef_covariance_m2,
246    metrics_from_enu_covariance_m2, metrics_from_kinematic_solution,
247    metrics_from_position_covariance, spherical_radius_at, vertical_radius_at, ErrorEllipse,
248    ErrorMetricsError, PercentileRadius, PositionErrorMetrics,
249};
250pub use sidereon_core::{
251    gauss_markov_bias_decay, gauss_markov_bias_variance_increment, gravity_ecef_mps2,
252    mechanize_ecef, normal_gravity_mps2, rodrigues_delta_dcm, simulate_imu_samples,
253    simulate_imu_samples_from_increments, true_imu_increment_between, AttitudeQuaternion,
254    ConingCorrection, CorrectedImuIncrement, ImuBias, ImuCalibration, ImuErrorModel, ImuGrade,
255    ImuRateRandomWalk, ImuSample, ImuSampleKind, ImuSimulationOptions, ImuSimulationOutput,
256    ImuSimulator, ImuSpec, InertialError, MechanizationConfig, NavState, SimulatedImuSequence,
257    StrapdownMechanizer, DEFAULT_IMU_SIM_SEED, WGS84_NORMAL_GRAVITY_EQUATOR_MPS2,
258    WGS84_NORMAL_GRAVITY_POLE_MPS2, WGS84_SOMIGLIANA_K,
259};
260pub use sidereon_core::{
261    geodesic_direct, geodesic_inverse, geodetic_to_itrf, itrf_to_geodetic, FrameValueError,
262    GeodesicError, GnssSatelliteId, GnssSystem, ItrfPositionM, ItrfVelocityMS, ProtectionModel,
263    SatelliteIdError, Wgs84Geodetic,
264};
265pub use sidereon_core::{
266    orbit_repeat_lag, periodicity_strength, periodicity_strength_with_sample_interval,
267    repeat_period, sidereal_filter, solar_day_period, SiderealFilterError, SiderealFilterOptions,
268    SiderealFilterOutput, SiderealTemplateMethod, SIDEREAL_DAY_NANOS, SIDEREAL_DAY_SECONDS,
269};
270pub use sidereon_core::{
271    sbas_protection_levels, AirborneModel, DegradationParams, ProtectionGeometry, ProtectionRow,
272    SbasErrorModel, SbasKMultipliers, SbasPlError, SbasProtection, SbasSisError,
273};
274pub use sidereon_core::{
275    ukf_correct_closed_loop, F64Bits, FusionFilterKind, FusionStateCodecError,
276    SerializableErrorStateLayout, SerializableFusionSnapshot, SerializableFusionState,
277    SerializableImuSample, SerializableImuSampleKind, SerializableInsFilterState,
278    SerializableLooseMeasurement, SerializableNavState, SerializableRateEndpoint,
279    SerializableSatelliteId, SerializableStoredCheckpoint, SerializableStoredGnssMeasurement,
280    SerializableStoredImuSample, SerializableTightCarrierPhaseObservation,
281    SerializableTightFilterState, SerializableTightGnssEpoch, SerializableTightGnssObservation,
282    SerializableTightRangeRateObservation, SerializableTimeSyncHistory,
283    SerializableTimeSyncHistoryConfig, UkfUpdateOptions, UnscentedTransformOptions,
284    FUSION_STATE_CODEC_VERSION,
285};
286pub use sidereon_core::{RejectedSat, RejectionReason};
287
288/// Stable RTK input, result, option, status, and error types used by the
289/// ergonomic RTK solve wrappers.
290pub mod rtk_filter {
291    pub use sidereon_core::rtk_filter::{
292        fix_wide_lane_rtk_arc, prepare_ionosphere_free_rtk_arc, solve_moving_baseline,
293        solve_moving_baseline_epoch, solve_rtk_arc, solve_static_rtk_arc,
294        solve_wide_lane_fixed_rtk_arc, AmbiguityScale, AmbiguitySearch, AmbiguitySet,
295        CycleSlipOptions, CycleSlipPolicy, CycleSlipSplitArc, Epoch, FixedBaselineSolution,
296        FixedSolveError, FixedSolveOpts, FloatBaselineSolution, FloatResidual, FloatSolveError,
297        FloatSolveOpts, FloatSolveStatus, FullSetIntegerSummary, IntegerSearchMeta, IntegerStatus,
298        IonosphereFreeBaselineError, MeasModel, MovingBaselineEpoch, MovingBaselineEpochSolution,
299        MovingBaselineError, MovingBaselineOpts, MovingBaselineSequenceError, MovingBaselineStatus,
300        PartialSearchMeta, ReceiverAntennaCalibration, ReceiverAntennaCorrections,
301        ReceiverAntennaError, ResidualComponentKind, ResidualValidationMeta,
302        ResidualValidationOpts, ResidualValidationOutlier, RtkArcConfig, RtkArcEpoch,
303        RtkArcEpochSolution, RtkArcError, RtkArcObservation, RtkArcPreprocessing, RtkArcSolution,
304        RtkDualCycleSlipConfig, RtkDualFrequencyArcEpoch, RtkDualFrequencyObservation,
305        RtkDualFrequencySatelliteObservation, RtkIonosphereFreeArcConfig,
306        RtkIonosphereFreeArcError, RtkIonosphereFreeArcSolution, RtkStaticArcConfig,
307        RtkStaticArcError, RtkStaticArcSolution, RtkWideLaneArcConfig, RtkWideLaneArcError,
308        RtkWideLaneArcSolution, RtkWideLaneFixedArcConfig, RtkWideLaneFixedArcError,
309        RtkWideLaneFixedArcIntegerMethod, RtkWideLaneFixedArcMetadata, RtkWideLaneFixedArcSolution,
310        RtkWideLaneFixedArcSolveConfig, RtkWideLaneFixedSequentialArcSolution,
311        RtkWideLaneFixedStaticArcSolution, SatMeas, StochasticModel,
312        ValidatedFixedBaselineSolution, ValidatedFixedSolveError, ValidatedFixedSolveOpts,
313        WideLaneError, WideLaneOptions,
314    };
315}
316
317/// Geoid undulation lookup and orthometric-height conversion: the
318/// [`sidereon_core::geoid`] surface re-exported on the ergonomic crate.
319pub mod geoid {
320    pub use sidereon_core::geoid::{
321        egm96_ellipsoidal_height_m, egm96_grid, egm96_orthometric_height_m, egm96_undulation,
322        egm96_undulations_deg, egm96_undulations_rad, ellipsoidal_height_m, geoid_undulation,
323        geoid_undulations_deg, geoid_undulations_rad, orthometric_height_m, Egm2008GridSpacing,
324        Egm2008RasterWindow, GeoidError, GeoidGrid, ProjVgridshiftArithmetic, ProjVgridshiftError,
325    };
326}
327
328/// Astronomical almanac events re-exported from the core crate.
329pub mod almanac {
330    pub use sidereon_core::astro::almanac::{
331        geocentric_ecliptic, lunar_solar_eclipses, meridian_transits, moon_phase_deg, moon_phases,
332        planetary_events, seasons, AlmanacError, CulminationEvent, CulminationKind, EclipseEvent,
333        EclipseKind, EclipticLonLat, EphemerisSource, MoonPhaseEvent, MoonPhaseKind, Planet,
334        PlanetaryEvent, PlanetaryEventKind, SeasonEvent, SeasonKind, TransitBody,
335    };
336}
337
338/// Stable PPP input, result, option, status, and error types used by the
339/// ergonomic PPP solve wrappers.
340pub mod precise_positioning {
341    pub use sidereon_core::precise_positioning::{
342        solve_ppp_auto_init_fixed, solve_ppp_auto_init_fixed_with_strategy,
343        solve_ppp_auto_init_float, solve_ppp_auto_init_float_with_strategy, AmbiguitySearch,
344        FixedAmbiguityOptions, FixedIntegerMetadata, FixedSolution, FixedSolveConfig,
345        FixedSolveError, FloatEpoch, FloatObservation, FloatResidual, FloatSolution,
346        FloatSolveConfig, FloatSolveError, FloatSolveOptions, FloatState, FloatStatus,
347        IntegerStatus, MeasurementWeights, MissingCorrection, NoEphemerisReason, PcvSample,
348        PositionCovariance, PppAutoInitError, PppAutoInitOptions, PppAutoInitStrategy,
349        PppCorrectionLookup, PppInitialGuess, RangeCorrections, ReceiverAntennaFrequency,
350        ReceiverAntennaOptions, SatelliteClockCorrections, TemporalCorrelationSummary,
351        TroposphereOptions,
352    };
353}
354
355/// Explicit escape hatch to the lower-level core RTK/PPP modules.
356///
357/// Items here keep their native `sidereon_core` error types and are outside the
358/// one-error ergonomic wrapper surface.
359pub mod raw {
360    pub use sidereon_core::{precise_positioning, rtk_filter};
361}
362
363// Root-level propagation shortcuts retained for compatibility. The complete
364// astrodynamics module tree is available as `sidereon::astro`.
365pub use sidereon_core::astro::anomaly::{
366    eccentric_to_mean, eccentric_to_true, mean_to_eccentric, mean_to_true, propagate_kepler,
367    solve_kepler, true_to_eccentric, true_to_mean, AnomalyError, KeplerSolution,
368};
369pub use sidereon_core::astro::bodies::{
370    find_moon_elevation_crossings, find_moon_transits, find_sun_elevation_crossings, moon_az_el,
371    moon_elevation_deg, moon_illumination, observe, observe_spk_body, sun_az_el, sun_elevation_deg,
372    BodyAzEl, BodyObservationError, Ecliptic, Equatorial, Horizontal, MoonElevationCrossing,
373    MoonElevationCrossingKind, MoonElevationOptions, MoonIllumination, MoonTransit,
374    MoonTransitKind, Observation, ObserveOptions, Refraction, SunElevationCrossing,
375    SunElevationCrossingKind, SunElevationOptions, Target,
376};
377pub use sidereon_core::astro::doppler::{
378    doppler_shift, range_rate_and_ratio, DopplerError, DopplerShift,
379};
380pub use sidereon_core::astro::passes::{
381    ground_track, look_angle, look_angle_arc, look_angle_batch_parallel, look_angle_batch_serial,
382    GroundStation, LookAngle, LookAngleError, PassError, PassPredictionOptions, PredictedPass,
383    UtcInstant, VisibleSatellite,
384};
385pub mod covariance {
386    pub use sidereon_core::astro::covariance::{
387        covariance6_km_to_m, covariance6_m_to_km, eci_to_rtn_covariance6,
388        interpolate_covariance_psd, rtn_to_eci, rtn_to_eci_covariance6, rtn_to_eci_rotation,
389        symmetric, Covariance6, Covariance6Error, Mat6, RtnFrameError,
390    };
391}
392pub use sidereon_core::astro::forces::{
393    DragForce, SourcedDragForce, SpaceWeather, SpaceWeatherSource,
394};
395pub use sidereon_core::astro::frames::transforms::{
396    gcrs_to_teme_compute, gcrs_to_true_of_date_matrix,
397};
398pub use sidereon_core::astro::sgp4::{DecayLatch, DecayLatchedError, Loss, XScale};
399pub use sidereon_core::astro::space_weather::{
400    ObservationClass, SpaceWeatherPolicy, SpaceWeatherSample, SpaceWeatherTable,
401};
402pub use sidereon_core::astro::{
403    omm, passes, propagator, sgp4, space_weather, state, tca, tdm, tle,
404};
405pub use sidereon_core::ephemeris::{
406    fit_precise_ephemeris_state_sample_orbit, fit_precise_ephemeris_state_sample_orbits,
407    precise_interpolant_store_checksum64, sp3_ecef_state_to_eci, MmapPreciseEphemerisInterpolant,
408    OrientedPreciseEphemerisStateSample, PreciseEphemerisInterpolant, PreciseEphemerisStateSample,
409    PreciseInterpolantStoreError,
410};
411
412/// Root-level shortcut for satellite-relative frames and CW propagation.
413///
414/// ```
415/// use sidereon::astro::state::CartesianState;
416/// use sidereon::relative;
417///
418/// let chief = CartesianState::new(0.0, [7000.0, 0.0, 0.0], [0.0, 7.546049108166282, 0.0]);
419/// let deputy = CartesianState::new(
420///     0.0,
421///     [7001.0, 0.2, 0.1],
422///     [0.001, 7.546549108166282, 0.0002],
423/// );
424///
425/// let rel = relative::relative_state(&chief, &deputy).unwrap();
426/// let rebuilt = relative::absolute_from_relative(&chief, &rel).unwrap();
427///
428/// assert!((rebuilt.position_km - deputy.position_km).norm() < 1.0e-9);
429/// assert!((rebuilt.velocity_km_s - deputy.velocity_km_s).norm() < 1.0e-12);
430/// ```
431pub use sidereon_core::astro::relative;
432
433/// Parameter-covariance primitives from the core least-squares substrate.
434///
435/// [`covariance_from_jacobian`](sidereon_core::astro::math::least_squares::covariance_from_jacobian)
436/// is the binding-facing entry point: it forms the fitted covariance straight
437/// from a design (Jacobian) matrix and the post-fit cost, with no report and no
438/// fabricated residual / parameter vectors. [`covariance_from_report`] keeps the
439/// converged-report path and [`normal_covariance`] the explicit-scale path.
440pub mod least_squares {
441    pub use sidereon_core::astro::math::least_squares::{
442        covariance_from_jacobian, covariance_from_report, normal_covariance,
443    };
444}
445
446/// RINEX observation/navigation lint and mechanical repair types.
447pub mod rinex_qc {
448    pub use sidereon_core::rinex::qc::{
449        AppliedEdit, Finding, FindingRef, HeaderEditError, LintReport, NavRepair, ObsHeaderEdit,
450        ObsRepair, RepairAction, RepairOptions, Severity,
451    };
452}
453
454use sidereon_core::antex::{Antex, AntexError};
455use sidereon_core::bias::{BiasError, BiasSet, CodeDcbOptions, Parsed as BiasParsed};
456use sidereon_core::ephemeris::{BroadcastEphemeris, Sp3};
457use sidereon_core::observables::ObservableEphemerisSource;
458use sidereon_core::positioning::{
459    EphemerisSource, ReceiverSolution, SolveInputs, SolvePolicy, SolvePolicyError,
460};
461use sidereon_core::precise_positioning::{
462    FixedSolution, FixedSolveConfig, FixedSolveError as PppFixedSolveError, FloatEpoch,
463    FloatSolution, FloatSolveConfig, FloatSolveError as PppFloatSolveError, FloatState,
464};
465use sidereon_core::rinex::clock::{RinexClock, RinexClockError};
466use sidereon_core::rinex::nav::NavParseError;
467use sidereon_core::rinex::observations::ObservationFile;
468use sidereon_core::rinex::qc::{LintReport, NavRepair, ObsRepair, RepairOptions};
469use sidereon_core::rtk_filter::{
470    AmbiguitySet, Epoch, FloatBaselineSolution, FloatSolveError as RtkFloatSolveError,
471    FloatSolveOpts, MeasModel, ReceiverAntennaCorrections, ValidatedFixedBaselineSolution,
472    ValidatedFixedSolveError, ValidatedFixedSolveOpts,
473};
474use sidereon_core::velocity::{
475    VelocityError, VelocityObservation, VelocitySolution, VelocitySolveOptions,
476};
477
478/// The one error type for the ergonomic API.
479///
480/// Each variant wraps the error of the `sidereon_core` reference entry point the
481/// corresponding function delegates to. The SP3 variant wraps the core's own
482/// [`sidereon_core::Error`] (which carries a human-readable parse message); the
483/// solve variants wrap their technique-specific error verbatim so no diagnostic
484/// detail is lost.
485#[derive(Debug)]
486pub enum Error {
487    /// [`load_sp3`] failed to parse the SP3 product.
488    Sp3(sidereon_core::Error),
489    /// [`parse_antex`] or [`load_antex`] failed to parse the ANTEX product.
490    Antex(AntexError),
491    /// [`parse_rinex_nav`] or [`load_rinex_nav`] failed to parse the NAV product.
492    RinexNav(NavParseError),
493    /// [`parse_rinex_obs`] or [`load_rinex_obs`] failed to parse the OBS product.
494    RinexObs(sidereon_core::Error),
495    /// [`parse_rinex_clock`] or [`load_rinex_clock`] failed to parse the clock product.
496    RinexClock(RinexClockError),
497    /// Bias-SINEX or CODE DCB parsing failed.
498    Bias(BiasError),
499    /// SSR decode or correction-store ingest failed.
500    Ssr(sidereon_core::Error),
501    /// [`decode_crinex`] or [`load_crinex`] failed to decode the CRINEX product.
502    Crinex(sidereon_core::Error),
503    /// A product file could not be read.
504    Io(std::io::Error),
505    /// [`solve_spp`] failed.
506    Spp(SolvePolicyError),
507    /// [`solve_velocity`] failed.
508    Velocity(VelocityError),
509    /// [`solve_rtk_float`] failed.
510    RtkFloat(RtkFloatSolveError),
511    /// [`solve_rtk_fixed`] failed.
512    RtkFixed(ValidatedFixedSolveError),
513    /// [`solve_ppp_float`] failed.
514    PppFloat(PppFloatSolveError),
515    /// [`solve_ppp_fixed`] failed.
516    PppFixed(PppFixedSolveError),
517}
518
519impl fmt::Display for Error {
520    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521        match self {
522            Error::Sp3(e) => write!(f, "SP3 parse failed: {e}"),
523            Error::Antex(e) => write!(f, "ANTEX parse failed: {e}"),
524            Error::RinexNav(e) => write!(f, "RINEX NAV parse failed: {e}"),
525            Error::RinexObs(e) => write!(f, "RINEX OBS parse failed: {e}"),
526            Error::RinexClock(e) => write!(f, "RINEX clock parse failed: {e}"),
527            Error::Bias(e) => write!(f, "bias product parse failed: {e}"),
528            Error::Ssr(e) => write!(f, "SSR ingest failed: {e}"),
529            Error::Crinex(e) => write!(f, "CRINEX decode failed: {e}"),
530            Error::Io(e) => write!(f, "product file read failed: {e}"),
531            Error::Spp(e) => write!(f, "{e}"),
532            Error::Velocity(e) => write!(f, "velocity solve failed: {e}"),
533            Error::RtkFloat(e) => write!(f, "{e}"),
534            Error::RtkFixed(e) => write!(f, "{e}"),
535            Error::PppFloat(e) => write!(f, "{e}"),
536            Error::PppFixed(e) => write!(f, "{e}"),
537        }
538    }
539}
540
541impl std::error::Error for Error {
542    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
543        match self {
544            Error::Sp3(e) => Some(e),
545            Error::Antex(e) => Some(e),
546            Error::RinexNav(e) => Some(e),
547            Error::RinexObs(e) => Some(e),
548            Error::RinexClock(e) => Some(e),
549            Error::Bias(e) => Some(e),
550            Error::Ssr(e) => Some(e),
551            Error::Crinex(e) => Some(e),
552            Error::Io(e) => Some(e),
553            Error::Spp(e) => Some(e),
554            Error::Velocity(e) => Some(e),
555            Error::RtkFloat(e) => Some(e),
556            Error::RtkFixed(e) => Some(e),
557            Error::PppFloat(e) => Some(e),
558            Error::PppFixed(e) => Some(e),
559        }
560    }
561}
562
563impl From<AntexError> for Error {
564    fn from(e: AntexError) -> Self {
565        Error::Antex(e)
566    }
567}
568
569impl From<NavParseError> for Error {
570    fn from(e: NavParseError) -> Self {
571        Error::RinexNav(e)
572    }
573}
574
575impl From<RinexClockError> for Error {
576    fn from(e: RinexClockError) -> Self {
577        Error::RinexClock(e)
578    }
579}
580
581impl From<BiasError> for Error {
582    fn from(e: BiasError) -> Self {
583        Error::Bias(e)
584    }
585}
586
587impl From<std::io::Error> for Error {
588    fn from(e: std::io::Error) -> Self {
589        Error::Io(e)
590    }
591}
592
593impl From<SolvePolicyError> for Error {
594    fn from(e: SolvePolicyError) -> Self {
595        Error::Spp(e)
596    }
597}
598
599impl From<VelocityError> for Error {
600    fn from(e: VelocityError) -> Self {
601        Error::Velocity(e)
602    }
603}
604
605impl From<RtkFloatSolveError> for Error {
606    fn from(e: RtkFloatSolveError) -> Self {
607        Error::RtkFloat(e)
608    }
609}
610
611impl From<ValidatedFixedSolveError> for Error {
612    fn from(e: ValidatedFixedSolveError) -> Self {
613        Error::RtkFixed(e)
614    }
615}
616
617impl From<PppFloatSolveError> for Error {
618    fn from(e: PppFloatSolveError) -> Self {
619        Error::PppFloat(e)
620    }
621}
622
623impl From<PppFixedSolveError> for Error {
624    fn from(e: PppFixedSolveError) -> Self {
625        Error::PppFixed(e)
626    }
627}
628
629/// Result alias for the ergonomic API.
630pub type Result<T> = core::result::Result<T, Error>;
631
632/// Typed input bundle for a static multi-epoch float RTK baseline solve.
633///
634/// Coordinates are Earth-fixed ECEF/ITRF metres. `base_ecef_m` is the known base
635/// receiver position, and `initial_baseline_m` is the rover-minus-base baseline
636/// seed `[dx, dy, dz]` in metres. Epoch observations are already-normalized core
637/// RTK epochs: code and carrier-phase observables are metres, satellite
638/// positions are ECEF metres, and each ambiguity id must align with the double
639/// differences built from `epochs`.
640#[derive(Clone)]
641pub struct RtkFloatConfig<'a> {
642    /// Normalized double-difference epochs for the static RTK arc.
643    pub epochs: &'a [Epoch],
644    /// Known base-station ECEF/ITRF position, metres.
645    pub base_ecef_m: [f64; 3],
646    /// Ordered float ambiguity state ids, one per non-reference ambiguity.
647    pub ambiguity_ids: &'a [String],
648    /// Rover-minus-base ECEF/ITRF baseline seed, metres.
649    pub initial_baseline_m: [f64; 3],
650    /// Code/phase sigmas, stochastic model, and Sagnac switch.
651    pub model: &'a MeasModel,
652    /// Iteration and convergence controls, in metres and iterations.
653    pub options: FloatSolveOpts,
654    /// Receiver antenna PCO/PCV corrections; `None` means no receiver correction.
655    pub receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
656}
657
658impl<'a> RtkFloatConfig<'a> {
659    /// Build a float RTK config with a zero rover-minus-base initial baseline
660    /// and no receiver antenna correction.
661    #[must_use]
662    pub fn new(
663        epochs: &'a [Epoch],
664        base_ecef_m: [f64; 3],
665        ambiguity_ids: &'a [String],
666        model: &'a MeasModel,
667        options: FloatSolveOpts,
668    ) -> Self {
669        Self {
670            epochs,
671            base_ecef_m,
672            ambiguity_ids,
673            initial_baseline_m: [0.0; 3],
674            model,
675            options,
676            receiver_antenna_corrections: None,
677        }
678    }
679
680    /// Set the rover-minus-base ECEF/ITRF baseline seed, metres.
681    #[must_use = "this builder consumes and returns the updated RTK float config"]
682    pub fn with_initial_baseline_m(mut self, initial_baseline_m: [f64; 3]) -> Self {
683        self.initial_baseline_m = initial_baseline_m;
684        self
685    }
686
687    /// Set receiver antenna PCO/PCV corrections; `None` leaves them disabled.
688    #[must_use = "this builder consumes and returns the updated RTK float config"]
689    pub fn with_receiver_antenna_corrections(
690        mut self,
691        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
692    ) -> Self {
693        self.receiver_antenna_corrections = receiver_antenna_corrections;
694        self
695    }
696}
697
698/// Typed input bundle for a static residual-validated fixed RTK baseline solve.
699///
700/// Coordinates are Earth-fixed ECEF/ITRF metres. `base_ecef_m` is the known base
701/// receiver position, and `initial_baseline_m` is the rover-minus-base baseline
702/// seed `[dx, dy, dz]` in metres. Ambiguity wavelengths and offsets live in
703/// [`AmbiguitySet::scale`] and are metres; fixed integer decisions are carrier
704/// cycles internally and converted back to metres in the returned solution.
705#[derive(Clone)]
706pub struct RtkFixedConfig<'a> {
707    /// Normalized double-difference epochs for the static RTK arc.
708    pub epochs: &'a [Epoch],
709    /// Known base-station ECEF/ITRF position, metres.
710    pub base_ecef_m: [f64; 3],
711    /// Ordered ambiguity ids, satellite mapping, wavelength/offset scale, and
712    /// constellations to leave float.
713    pub initial_ambiguities: AmbiguitySet<'a>,
714    /// Rover-minus-base ECEF/ITRF baseline seed, metres.
715    pub initial_baseline_m: [f64; 3],
716    /// Code/phase sigmas, stochastic model, and Sagnac switch.
717    pub model: &'a MeasModel,
718    /// Float solve, integer search, and residual-validation controls.
719    pub options: ValidatedFixedSolveOpts,
720    /// Receiver antenna PCO/PCV corrections; `None` means no receiver correction.
721    pub receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
722}
723
724impl<'a> RtkFixedConfig<'a> {
725    /// Build a fixed RTK config with a zero rover-minus-base initial baseline
726    /// and no receiver antenna correction.
727    #[must_use]
728    pub fn new(
729        epochs: &'a [Epoch],
730        base_ecef_m: [f64; 3],
731        initial_ambiguities: AmbiguitySet<'a>,
732        model: &'a MeasModel,
733        options: ValidatedFixedSolveOpts,
734    ) -> Self {
735        Self {
736            epochs,
737            base_ecef_m,
738            initial_ambiguities,
739            initial_baseline_m: [0.0; 3],
740            model,
741            options,
742            receiver_antenna_corrections: None,
743        }
744    }
745
746    /// Set the rover-minus-base ECEF/ITRF baseline seed, metres.
747    #[must_use = "this builder consumes and returns the updated RTK fixed config"]
748    pub fn with_initial_baseline_m(mut self, initial_baseline_m: [f64; 3]) -> Self {
749        self.initial_baseline_m = initial_baseline_m;
750        self
751    }
752
753    /// Set receiver antenna PCO/PCV corrections; `None` leaves them disabled.
754    #[must_use = "this builder consumes and returns the updated RTK fixed config"]
755    pub fn with_receiver_antenna_corrections(
756        mut self,
757        receiver_antenna_corrections: Option<&'a ReceiverAntennaCorrections>,
758    ) -> Self {
759        self.receiver_antenna_corrections = receiver_antenna_corrections;
760        self
761    }
762}
763
764/// Typed input bundle for a static multi-epoch float PPP solve.
765///
766/// The ephemeris source supplies satellite ECEF/ITRF positions in metres and
767/// clocks in seconds. `epochs` contain ionosphere-free code and carrier-phase
768/// observations in metres. `initial_state.position_m` is ECEF/ITRF metres;
769/// receiver clocks, ambiguities, and zenith tropospheric delay are represented
770/// in metres, matching the core PPP state vector.
771#[derive(Clone)]
772pub struct PppFloatConfig<'a> {
773    /// Observable ephemeris source, commonly an SP3 precise product.
774    pub source: &'a dyn ObservableEphemerisSource,
775    /// Static PPP epochs, ordered in time.
776    pub epochs: &'a [FloatEpoch],
777    /// Initial receiver position, per-epoch clocks, ambiguities, and ZTD.
778    pub initial_state: FloatState,
779    /// Measurement weights, corrections, troposphere, and iteration controls.
780    pub solve: FloatSolveConfig,
781}
782
783impl<'a> PppFloatConfig<'a> {
784    /// Build a float PPP config from the complete static-arc input bundle.
785    pub fn new(
786        source: &'a dyn ObservableEphemerisSource,
787        epochs: &'a [FloatEpoch],
788        initial_state: FloatState,
789        solve: FloatSolveConfig,
790    ) -> Self {
791        Self {
792            source,
793            epochs,
794            initial_state,
795            solve,
796        }
797    }
798}
799
800/// Typed input bundle for a static integer-fixed PPP solve.
801///
802/// The ephemeris source and epochs must describe the same static arc used for
803/// the supplied float solution. Coordinates are ECEF/ITRF metres; ambiguity
804/// wavelengths and offsets inside `solve.ambiguity` are metres, and fixed
805/// ambiguity decisions are reported in both carrier cycles and metres.
806#[derive(Clone)]
807pub struct PppFixedConfig<'a> {
808    /// Observable ephemeris source, commonly an SP3 precise product.
809    pub source: &'a dyn ObservableEphemerisSource,
810    /// Static PPP epochs, ordered in time.
811    pub epochs: &'a [FloatEpoch],
812    /// Float PPP solution used as the integer ambiguity-search prior.
813    pub float_solution: FloatSolution,
814    /// Measurement weights, corrections, troposphere, and integer controls.
815    pub solve: FixedSolveConfig,
816}
817
818impl<'a> PppFixedConfig<'a> {
819    /// Build a fixed PPP config from the complete static-arc input bundle.
820    pub fn new(
821        source: &'a dyn ObservableEphemerisSource,
822        epochs: &'a [FloatEpoch],
823        float_solution: FloatSolution,
824        solve: FixedSolveConfig,
825    ) -> Self {
826        Self {
827            source,
828            epochs,
829            float_solution,
830            solve,
831        }
832    }
833}
834
835/// Parse an SP3-c or SP3-d byte buffer into a precise-ephemeris product.
836///
837/// `bytes` is the full, already-decompressed file content. Delegates to
838/// [`Sp3::parse`]; malformed input is returned as [`Error::Sp3`].
839///
840/// ```
841/// // The parser rejects malformed input with `Error::Sp3`.
842/// assert!(sidereon::load_sp3(b"garbage").is_err());
843/// ```
844pub fn load_sp3(bytes: &[u8]) -> Result<Sp3> {
845    Sp3::parse(bytes).map_err(Error::Sp3)
846}
847
848/// Parse NMEA 0183 bytes into typed sentences with non-fatal diagnostics.
849pub fn parse_nmea(input: &[u8]) -> nmea::Parsed<nmea::NmeaLog> {
850    nmea::parse_nmea(input)
851}
852
853/// Parse and group NMEA 0183 bytes into completed epoch snapshots.
854pub fn nmea_epochs(input: &[u8]) -> (Vec<nmea::EpochSnapshot>, nmea::Diagnostics) {
855    let parsed = nmea::parse_nmea(input);
856    let epochs = nmea::group_epochs(&parsed.value);
857    (epochs, parsed.diagnostics)
858}
859
860/// Serialize a fixed-format, checksummed GGA sentence.
861pub fn write_gga(
862    talker: nmea::NmeaTalker,
863    gga: &nmea::Gga,
864) -> std::result::Result<String, nmea::NmeaError> {
865    nmea::write_gga(talker, gga)
866}
867
868/// Parse ANTEX text into receiver and satellite antenna calibrations.
869///
870/// Values are exposed in the core product's SI units: PCO/PCV are metres, with
871/// azimuth and zenith grids in degrees. Malformed input is returned as
872/// [`Error::Antex`].
873pub fn parse_antex(text: &str) -> Result<Antex> {
874    Antex::parse(text).map_err(Error::Antex)
875}
876
877/// Read and parse an ANTEX antenna calibration file.
878///
879/// Delegates to [`parse_antex`] after reading UTF-8 text from `path`.
880pub fn load_antex(path: impl AsRef<Path>) -> Result<Antex> {
881    let text = std::fs::read_to_string(path)?;
882    parse_antex(&text)
883}
884
885/// Parse a RINEX NAV file into a queryable broadcast ephemeris store.
886///
887/// The store applies the core's default navigation usability policy and
888/// implements [`EphemerisSource`], so it can feed [`solve_spp`].
889pub fn parse_rinex_nav(text: &str) -> Result<BroadcastEphemeris> {
890    BroadcastEphemeris::from_nav(text).map_err(Error::RinexNav)
891}
892
893/// Read and parse a RINEX NAV file into a queryable broadcast ephemeris store.
894pub fn load_rinex_nav(path: impl AsRef<Path>) -> Result<BroadcastEphemeris> {
895    let text = std::fs::read_to_string(path)?;
896    parse_rinex_nav(&text)
897}
898
899/// Build an SSR correction store from framed RTCM bytes.
900pub fn ssr_store_from_rtcm(
901    bytes: &[u8],
902    week: sidereon_core::astro::time::GnssWeekTow,
903) -> Result<sidereon_core::ssr::SsrCorrectionStore> {
904    let mut store = sidereon_core::ssr::SsrCorrectionStore::new();
905    let mut assembler = sidereon_core::rtcm::SsrStreamAssembler::new();
906    for decoded in assembler.push(bytes) {
907        let message = decoded.map_err(Error::Ssr)?;
908        store.ingest(&message, week).map_err(Error::Ssr)?;
909    }
910    Ok(store)
911}
912
913/// Parse RINEX OBS text into a typed observation product.
914pub fn parse_rinex_obs(text: &str) -> Result<ObservationFile> {
915    ObservationFile::parse(text).map_err(Error::RinexObs)
916}
917
918/// Read and parse a RINEX OBS file.
919pub fn load_rinex_obs(path: impl AsRef<Path>) -> Result<ObservationFile> {
920    let text = std::fs::read_to_string(path)?;
921    parse_rinex_obs(&text)
922}
923
924/// Lint RINEX OBS text, decoding CRINEX input when needed.
925pub fn lint_rinex_obs(text: &str) -> LintReport {
926    sidereon_core::rinex::qc::lint_obs_text(text)
927}
928
929/// Lint RINEX NAV text.
930pub fn lint_rinex_nav(text: &str) -> LintReport {
931    sidereon_core::rinex::qc::lint_nav_text(text)
932}
933
934/// Repair RINEX OBS text with mechanical fixes supported by the core writer.
935pub fn repair_rinex_obs(text: &str, options: &RepairOptions) -> Result<ObsRepair> {
936    sidereon_core::rinex::qc::repair_obs_text(text, options).map_err(Error::RinexObs)
937}
938
939/// Repair RINEX NAV text with mechanical fixes supported by the core writer.
940pub fn repair_rinex_nav(text: &str, options: &RepairOptions) -> Result<NavRepair> {
941    sidereon_core::rinex::qc::repair_nav_text(text, options).map_err(Error::RinexNav)
942}
943
944/// Strictly parse RINEX clock text into satellite clock-bias series.
945pub fn parse_rinex_clock(text: &str) -> Result<RinexClock> {
946    RinexClock::parse(text).map_err(Error::RinexClock)
947}
948
949/// Read and strictly parse a RINEX clock file.
950pub fn load_rinex_clock(path: impl AsRef<Path>) -> Result<RinexClock> {
951    let text = std::fs::read_to_string(path)?;
952    parse_rinex_clock(&text)
953}
954
955/// Parse RINEX clock text while skipping malformed and non-`AS` rows.
956pub fn parse_rinex_clock_lossy(text: &str) -> RinexClock {
957    RinexClock::parse_lossy(text)
958}
959
960/// Read and lossily parse a RINEX clock file.
961pub fn load_rinex_clock_lossy(path: impl AsRef<Path>) -> Result<RinexClock> {
962    let text = std::fs::read_to_string(path)?;
963    Ok(parse_rinex_clock_lossy(&text))
964}
965
966/// Parse Bias-SINEX bytes into an offline bias set.
967pub fn parse_bias_sinex(bytes: &[u8]) -> Result<BiasSet> {
968    Ok(parse_bias_sinex_lossy(bytes)?.value)
969}
970
971/// Parse Bias-SINEX bytes and return non-fatal diagnostics with the bias set.
972pub fn parse_bias_sinex_lossy(bytes: &[u8]) -> Result<BiasParsed<BiasSet>> {
973    BiasSet::parse_bias_sinex(bytes).map_err(Error::Bias)
974}
975
976/// Read and parse a Bias-SINEX product. Files ending in `.gz` are decompressed.
977pub fn load_bias_sinex(path: impl AsRef<Path>) -> Result<BiasSet> {
978    let bytes = read_maybe_gzip(path)?;
979    parse_bias_sinex(&bytes)
980}
981
982/// Read and parse a Bias-SINEX product, retaining non-fatal diagnostics.
983/// Files ending in `.gz` are decompressed.
984pub fn load_bias_sinex_lossy(path: impl AsRef<Path>) -> Result<BiasParsed<BiasSet>> {
985    let bytes = read_maybe_gzip(path)?;
986    parse_bias_sinex_lossy(&bytes)
987}
988
989/// Parse CODE DCB bytes into an offline bias set.
990pub fn parse_code_dcb(bytes: &[u8], options: Option<CodeDcbOptions>) -> Result<BiasSet> {
991    Ok(parse_code_dcb_lossy(bytes, options)?.value)
992}
993
994/// Parse CODE DCB bytes and return non-fatal diagnostics with the bias set.
995pub fn parse_code_dcb_lossy(
996    bytes: &[u8],
997    options: Option<CodeDcbOptions>,
998) -> Result<BiasParsed<BiasSet>> {
999    BiasSet::parse_code_dcb(bytes, options).map_err(Error::Bias)
1000}
1001
1002/// Read and parse a CODE DCB product. Files ending in `.gz` are decompressed.
1003pub fn load_code_dcb(path: impl AsRef<Path>, options: Option<CodeDcbOptions>) -> Result<BiasSet> {
1004    let bytes = read_maybe_gzip(path)?;
1005    parse_code_dcb(&bytes, options)
1006}
1007
1008/// Read and parse a CODE DCB product, retaining non-fatal diagnostics.
1009/// Files ending in `.gz` are decompressed.
1010pub fn load_code_dcb_lossy(
1011    path: impl AsRef<Path>,
1012    options: Option<CodeDcbOptions>,
1013) -> Result<BiasParsed<BiasSet>> {
1014    let bytes = read_maybe_gzip(path)?;
1015    parse_code_dcb_lossy(&bytes, options)
1016}
1017
1018/// Decode Compact RINEX (Hatanaka) OBS text into plain RINEX OBS text.
1019pub fn decode_crinex(text: &str) -> Result<String> {
1020    rinex::decode_crinex(text).map_err(Error::Crinex)
1021}
1022
1023/// Encode plain RINEX OBS text into Compact RINEX (Hatanaka) text.
1024///
1025/// The lower core encoder emits a canonical CRINEX stream, so it is not
1026/// expected to byte-match an arbitrary `RNX2CRX` product. It does guarantee that
1027/// decoding the emitted CRINEX reconstructs the input RINEX observation text.
1028pub fn encode_crinex(text: &str) -> Result<String> {
1029    rinex::encode_crinex(text).map_err(Error::Crinex)
1030}
1031
1032/// Read and decode a Compact RINEX (Hatanaka) OBS file.
1033pub fn load_crinex(path: impl AsRef<Path>) -> Result<String> {
1034    let text = std::fs::read_to_string(path)?;
1035    decode_crinex(&text)
1036}
1037
1038fn read_maybe_gzip(path: impl AsRef<Path>) -> Result<Vec<u8>> {
1039    let path = path.as_ref();
1040    let bytes = std::fs::read(path)?;
1041    if path.extension().and_then(|ext| ext.to_str()) != Some("gz") {
1042        return Ok(bytes);
1043    }
1044    let mut decoder = GzDecoder::new(&bytes[..]);
1045    let mut decoded = Vec::new();
1046    decoder.read_to_end(&mut decoded)?;
1047    Ok(decoded)
1048}
1049
1050/// Run single-point positioning under the public validation/orchestration
1051/// policy.
1052///
1053/// `eph` supplies satellite ECEF/ITRF positions in metres and satellite clocks
1054/// in seconds. `inputs.observations` are pseudoranges in metres, and
1055/// `inputs.t_rx_j2000_s` is the receive epoch in seconds since J2000 in the
1056/// ephemeris time scale. The SPP state uses `[x, y, z, clock]` with position in
1057/// ECEF/ITRF metres and receiver clock bias represented as metres internally;
1058/// the returned [`ReceiverSolution`] also exposes the receiver clock in seconds.
1059/// `with_geodetic` controls whether WGS84 latitude/longitude/height are
1060/// populated, and `policy` carries validation, masking, and correction behavior.
1061///
1062/// Delegates to [`sidereon_core::positioning::solve_with_policy`] and returns
1063/// its [`ReceiverSolution`], mapping any failure to [`Error::Spp`].
1064pub fn solve_spp(
1065    eph: &dyn EphemerisSource,
1066    inputs: &SolveInputs,
1067    with_geodetic: bool,
1068    policy: SolvePolicy,
1069) -> Result<ReceiverSolution> {
1070    sidereon_core::positioning::solve_with_policy(eph, inputs, with_geodetic, policy)
1071        .map_err(Error::Spp)
1072}
1073
1074/// Solve a batch of independent SPP epochs against a shared ephemeris, serially.
1075///
1076/// Each element of `epochs` is one receive instant's [`SolveInputs`]; element
1077/// `i` of the result is [`solve_spp`] applied to `epochs[i]` with the shared
1078/// `eph`, `with_geodetic`, and `policy`. The serial reference the parallel
1079/// [`solve_spp_batch`] is proven bit-identical against.
1080pub fn solve_spp_batch_serial(
1081    eph: &dyn EphemerisSource,
1082    epochs: &[SolveInputs],
1083    with_geodetic: bool,
1084    policy: SolvePolicy,
1085) -> Vec<Result<ReceiverSolution>> {
1086    sidereon_core::positioning::solve_spp_batch_serial(eph, epochs, with_geodetic, policy)
1087        .into_iter()
1088        .map(|r| r.map_err(Error::Spp))
1089        .collect()
1090}
1091
1092/// Solve a batch of independent SPP epochs against a shared ephemeris, fanning
1093/// the per-epoch solves across a rayon thread pool.
1094///
1095/// Each epoch is solved by the same single-epoch kernel as [`solve_spp`] and the
1096/// indexed parallel collect preserves order, so element `i` is byte-for-byte
1097/// identical to element `i` of [`solve_spp_batch_serial`]: epochs share only the
1098/// immutable `eph`/`policy`, with no cross-epoch state. The work is
1099/// embarrassingly parallel, so throughput scales with cores while every value
1100/// stays bit-exact. `eph` must be [`Sync`] to be shared across the pool; the
1101/// language bindings call this inside their GIL/scheduler release so the whole
1102/// fleet of fixes computes with no interpreter lock held.
1103pub fn solve_spp_batch(
1104    eph: &(dyn EphemerisSource + Sync),
1105    epochs: &[SolveInputs],
1106    with_geodetic: bool,
1107    policy: SolvePolicy,
1108) -> Vec<Result<ReceiverSolution>> {
1109    sidereon_core::positioning::solve_spp_batch_parallel(eph, epochs, with_geodetic, policy)
1110        .into_iter()
1111        .map(|r| r.map_err(Error::Spp))
1112        .collect()
1113}
1114
1115/// Solve receiver ECEF velocity and clock drift from one epoch of range-rate or
1116/// Doppler observations.
1117///
1118/// `source` supplies satellite ECEF/ITRF state at `t_rx_j2000_s`, expressed in
1119/// seconds since J2000. `observations` are either range rates in metres per
1120/// second or Doppler values in hertz, depending on `options.observable`; Doppler
1121/// rows use each observation's carrier frequency in hertz. `receiver_ecef_m` is
1122/// the known receiver ECEF/ITRF position in metres. The returned
1123/// [`VelocitySolution`] reports ECEF velocity in metres per second and receiver
1124/// clock drift in seconds per second.
1125///
1126/// Delegates to [`sidereon_core::velocity::solve`], returning its
1127/// [`VelocitySolution`] and mapping any failure to [`Error::Velocity`].
1128pub fn solve_velocity(
1129    source: &dyn ObservableEphemerisSource,
1130    observations: &[VelocityObservation],
1131    receiver_ecef_m: [f64; 3],
1132    t_rx_j2000_s: f64,
1133    options: VelocitySolveOptions,
1134) -> Result<VelocitySolution> {
1135    sidereon_core::velocity::solve(source, observations, receiver_ecef_m, t_rx_j2000_s, options)
1136        .map_err(Error::Velocity)
1137}
1138
1139/// Solve a static multi-epoch float RTK baseline.
1140///
1141/// Prefer this typed-config form for new Rust callers. It delegates to the
1142/// lower-level positional [`solve_rtk_float`] function, preserving the exact
1143/// core solver path and error mapping.
1144///
1145/// ```
1146/// use sidereon::{
1147///     rtk_filter::{FloatSolveOpts, MeasModel, StochasticModel},
1148///     RtkFloatConfig,
1149/// };
1150///
1151/// let epochs = Vec::new();
1152/// let ambiguity_ids = Vec::<String>::new();
1153/// let model = MeasModel {
1154///     code_sigma_m: 0.3,
1155///     phase_sigma_m: 0.003,
1156///     sagnac: true,
1157///     stochastic: StochasticModel::Rtklib,
1158/// };
1159/// let opts = FloatSolveOpts {
1160///     position_tol_m: 1.0e-4,
1161///     ambiguity_tol_m: 1.0e-4,
1162///     max_iterations: 1,
1163/// };
1164/// let config = RtkFloatConfig::new(&epochs, [0.0; 3], &ambiguity_ids, &model, opts);
1165///
1166/// assert!(matches!(
1167///     sidereon::solve_rtk_float_with(config),
1168///     Err(sidereon::Error::RtkFloat(_))
1169/// ));
1170/// ```
1171pub fn solve_rtk_float_with(config: RtkFloatConfig<'_>) -> Result<FloatBaselineSolution> {
1172    solve_rtk_float(
1173        config.epochs,
1174        config.base_ecef_m,
1175        config.ambiguity_ids,
1176        config.initial_baseline_m,
1177        config.model,
1178        config.options,
1179        config.receiver_antenna_corrections,
1180    )
1181}
1182
1183/// Lower-level positional form for a static multi-epoch float RTK baseline.
1184///
1185/// New Rust callers should prefer [`solve_rtk_float_with`] with
1186/// [`RtkFloatConfig`] so units and frame semantics are attached to named fields.
1187/// `base` is the known base receiver ECEF/ITRF position in metres.
1188/// `initial_baseline_m` is the rover-minus-base ECEF/ITRF baseline seed in
1189/// metres. `epochs` contain normalized double-difference code and carrier-phase
1190/// rows in metres; `ambiguity_ids` names the float ambiguity state columns.
1191/// Receiver antenna corrections are applied only when `Some(_)`; `None` is the
1192/// explicit no-correction case.
1193///
1194/// This function is kept for existing bindings and delegates to
1195/// [`sidereon_core::rtk_filter::solve_float_baseline`], returning its
1196/// [`FloatBaselineSolution`] and mapping any failure to [`Error::RtkFloat`].
1197pub fn solve_rtk_float(
1198    epochs: &[Epoch],
1199    base: [f64; 3],
1200    ambiguity_ids: &[String],
1201    initial_baseline_m: [f64; 3],
1202    model: &MeasModel,
1203    opts: FloatSolveOpts,
1204    receiver_antenna_corrections: Option<&ReceiverAntennaCorrections>,
1205) -> Result<FloatBaselineSolution> {
1206    sidereon_core::rtk_filter::solve_float_baseline(
1207        epochs,
1208        base,
1209        ambiguity_ids,
1210        initial_baseline_m,
1211        model,
1212        opts,
1213        receiver_antenna_corrections,
1214    )
1215    .map_err(Error::RtkFloat)
1216}
1217
1218/// Solve a static fixed RTK baseline with residual validation/FDE.
1219///
1220/// Prefer this typed-config form for new Rust callers. It delegates to the
1221/// lower-level positional [`solve_rtk_fixed`] function, preserving the exact
1222/// core solver path and error mapping.
1223pub fn solve_rtk_fixed_with(config: RtkFixedConfig<'_>) -> Result<ValidatedFixedBaselineSolution> {
1224    solve_rtk_fixed(
1225        config.epochs,
1226        config.base_ecef_m,
1227        config.initial_ambiguities,
1228        config.initial_baseline_m,
1229        config.model,
1230        config.options,
1231        config.receiver_antenna_corrections,
1232    )
1233}
1234
1235/// Lower-level positional form for a static fixed RTK baseline with residual
1236/// validation/FDE.
1237///
1238/// New Rust callers should prefer [`solve_rtk_fixed_with`] with
1239/// [`RtkFixedConfig`] so units and frame semantics are attached to named fields.
1240/// `base` is the known base receiver ECEF/ITRF position in metres.
1241/// `initial_baseline_m` is the rover-minus-base ECEF/ITRF baseline seed in
1242/// metres. `initial_ambiguities` supplies ambiguity ids, satellite mapping,
1243/// wavelengths, and offsets for the integer search; wavelengths and offsets are
1244/// in metres and fixed ambiguity decisions are reported in carrier cycles and
1245/// metres. Receiver antenna corrections are applied only when `Some(_)`;
1246/// `None` is the explicit no-correction case.
1247///
1248/// This function is kept for existing bindings and delegates to
1249/// [`sidereon_core::rtk_filter::solve_fixed_baseline_validated`], returning its
1250/// [`ValidatedFixedBaselineSolution`] and mapping any failure to
1251/// [`Error::RtkFixed`].
1252pub fn solve_rtk_fixed(
1253    epochs: &[Epoch],
1254    base: [f64; 3],
1255    initial_ambiguities: AmbiguitySet,
1256    initial_baseline_m: [f64; 3],
1257    model: &MeasModel,
1258    opts: ValidatedFixedSolveOpts,
1259    receiver_antenna_corrections: Option<&ReceiverAntennaCorrections>,
1260) -> Result<ValidatedFixedBaselineSolution> {
1261    sidereon_core::rtk_filter::solve_fixed_baseline_validated(
1262        epochs,
1263        base,
1264        initial_ambiguities,
1265        initial_baseline_m,
1266        model,
1267        opts,
1268        receiver_antenna_corrections,
1269    )
1270    .map_err(Error::RtkFixed)
1271}
1272
1273/// Solve a static multi-epoch float PPP arc.
1274///
1275/// Prefer this typed-config form for new Rust callers. It delegates to the
1276/// lower-level positional [`solve_ppp_float`] function, preserving the exact core
1277/// solver path and error mapping.
1278pub fn solve_ppp_float_with(config: PppFloatConfig<'_>) -> Result<FloatSolution> {
1279    solve_ppp_float(
1280        config.source,
1281        config.epochs,
1282        config.initial_state,
1283        config.solve,
1284    )
1285}
1286
1287/// Lower-level positional form for a static multi-epoch float PPP arc.
1288///
1289/// New Rust callers should prefer [`solve_ppp_float_with`] with
1290/// [`PppFloatConfig`] so units and frame semantics are attached to named fields.
1291/// `source` supplies satellite ECEF/ITRF positions in metres and clocks in
1292/// seconds. `epochs` contain ionosphere-free code and carrier phase in metres.
1293/// `initial_state.position_m` is ECEF/ITRF metres; receiver clocks, carrier
1294/// ambiguities, and zenith tropospheric delay are represented in metres.
1295///
1296/// This function is kept for existing bindings and delegates to
1297/// [`sidereon_core::precise_positioning::solve_float_epochs`], returning its
1298/// [`FloatSolution`] and mapping any failure to [`Error::PppFloat`].
1299pub fn solve_ppp_float(
1300    source: &dyn ObservableEphemerisSource,
1301    epochs: &[FloatEpoch],
1302    initial_state: FloatState,
1303    config: FloatSolveConfig,
1304) -> Result<FloatSolution> {
1305    sidereon_core::precise_positioning::solve_float_epochs(source, epochs, initial_state, config)
1306        .map_err(Error::PppFloat)
1307}
1308
1309/// Search integer ambiguities from a float PPP solution and re-solve with them
1310/// held fixed.
1311///
1312/// Prefer this typed-config form for new Rust callers. It delegates to the
1313/// lower-level positional [`solve_ppp_fixed`] function, preserving the exact core
1314/// solver path and error mapping.
1315pub fn solve_ppp_fixed_with(config: PppFixedConfig<'_>) -> Result<FixedSolution> {
1316    solve_ppp_fixed(
1317        config.source,
1318        config.epochs,
1319        config.float_solution,
1320        config.solve,
1321    )
1322}
1323
1324/// Lower-level positional form for static integer-fixed PPP.
1325///
1326/// New Rust callers should prefer [`solve_ppp_fixed_with`] with
1327/// [`PppFixedConfig`] so units and frame semantics are attached to named fields.
1328/// `source` and `epochs` must describe the same static ECEF/ITRF arc used to
1329/// produce `float_solution`. Ambiguity wavelengths and offsets in `config` are
1330/// metres; integer decisions in the returned solution are reported in carrier
1331/// cycles and converted metres.
1332///
1333/// This function is kept for existing bindings and delegates to
1334/// [`sidereon_core::precise_positioning::solve_fixed_from_float`], returning its
1335/// [`FixedSolution`] and mapping any failure to [`Error::PppFixed`].
1336pub fn solve_ppp_fixed(
1337    source: &dyn ObservableEphemerisSource,
1338    epochs: &[FloatEpoch],
1339    float_solution: FloatSolution,
1340    config: FixedSolveConfig,
1341) -> Result<FixedSolution> {
1342    sidereon_core::precise_positioning::solve_fixed_from_float(
1343        source,
1344        epochs,
1345        float_solution,
1346        config,
1347    )
1348    .map_err(Error::PppFixed)
1349}
1350
1351#[cfg(all(test, sidereon_repo_tests))]
1352mod tests {
1353    use super::*;
1354    use sidereon_core::positioning::{Corrections, KlobucharCoeffs, Observation, SurfaceMet};
1355    use std::collections::BTreeMap;
1356    use std::path::PathBuf;
1357
1358    // A tiny real SP3 product: five GPS satellites at coincident positions over
1359    // two epochs. It parses cleanly but is geometrically degenerate, so any SPP
1360    // solve against it fails. Reused from the core parity fixtures.
1361    const DEGENERATE_SP3: &[u8] =
1362        include_bytes!("../../sidereon-core/tests/fixtures/sp3/degenerate_coincident_5sat.sp3");
1363    const ANTEX_TEXT: &str =
1364        include_str!("../../sidereon-core/tests/fixtures/antex/igs20_wettzell_trim.atx");
1365    const RINEX_NAV_TEXT: &str =
1366        include_str!("../../sidereon-core/tests/fixtures/nav/ESBC00DNK_R_20201770000_01D_MN.rnx");
1367    const RINEX_OBS_TEXT: &str = include_str!(
1368        "../../sidereon-core/tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx"
1369    );
1370    const RINEX_CLOCK_TEXT: &str =
1371        include_str!("../../sidereon-core/tests/fixtures/clk/synthetic_rinex_clock.clk");
1372    const BIAS_BYTES: &[u8] = include_bytes!("../../sidereon-core/tests/fixtures/bias/CODE.BIA");
1373    const DCB_BYTES: &[u8] =
1374        include_bytes!("../../sidereon-core/tests/fixtures/bias/P1C1_RINEX.DCB");
1375    const CRINEX_TEXT: &str = include_str!(
1376        "../../sidereon-core/tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.crx"
1377    );
1378    const STATIONS_TLE: &str =
1379        include_str!("../../sidereon-core/tests/fixtures/celestrak/stations.tle");
1380    const REAL_SSRA02IGS0_1060_FRAME_HEX: &str =
1381        include_str!("../../sidereon-core/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex");
1382
1383    fn fixture_path(parts: &[&str]) -> PathBuf {
1384        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1385        path.push("../sidereon-core/tests/fixtures");
1386        for part in parts {
1387            path.push(part);
1388        }
1389        path
1390    }
1391
1392    fn station_tle(name: &str) -> tca::TcaTle<'static> {
1393        let mut lines = STATIONS_TLE.lines();
1394        while let Some(object_name) = lines.next() {
1395            let Some(line1) = lines.next() else {
1396                break;
1397            };
1398            let Some(line2) = lines.next() else {
1399                break;
1400            };
1401            if object_name.trim() == name {
1402                return tca::TcaTle::new(line1, line2);
1403            }
1404        }
1405        panic!("missing station TLE {name}");
1406    }
1407
1408    fn norm3(v: [f64; 3]) -> f64 {
1409        (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
1410    }
1411
1412    fn hex_bytes(hex: &str) -> Vec<u8> {
1413        let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1414        assert_eq!(compact.len() % 2, 0);
1415        compact
1416            .as_bytes()
1417            .chunks_exact(2)
1418            .map(|chunk| {
1419                let hi = (chunk[0] as char).to_digit(16).unwrap();
1420                let lo = (chunk[1] as char).to_digit(16).unwrap();
1421                ((hi << 4) | lo) as u8
1422            })
1423            .collect()
1424    }
1425
1426    fn rtk_model() -> MeasModel {
1427        MeasModel {
1428            code_sigma_m: 0.3,
1429            phase_sigma_m: 0.003,
1430            sagnac: true,
1431            stochastic: rtk_filter::StochasticModel::Rtklib,
1432        }
1433    }
1434
1435    fn rtk_float_options() -> FloatSolveOpts {
1436        FloatSolveOpts {
1437            position_tol_m: 1.0e-4,
1438            ambiguity_tol_m: 1.0e-4,
1439            max_iterations: 1,
1440        }
1441    }
1442
1443    fn rtk_fixed_options() -> rtk_filter::ValidatedFixedSolveOpts {
1444        rtk_filter::ValidatedFixedSolveOpts {
1445            float: rtk_float_options(),
1446            fixed: rtk_filter::FixedSolveOpts {
1447                position_tol_m: 1.0e-4,
1448                ambiguity_tol_m: 1.0e-4,
1449                max_iterations: 1,
1450                ratio_threshold: 3.0,
1451                partial_ambiguity_resolution: false,
1452                partial_min_ambiguities: 4,
1453            },
1454            residual: rtk_filter::ResidualValidationOpts {
1455                threshold_sigma: None,
1456                max_exclusions: 0,
1457            },
1458        }
1459    }
1460
1461    fn ppp_float_solve_config() -> FloatSolveConfig {
1462        FloatSolveConfig {
1463            weights: precise_positioning::MeasurementWeights {
1464                code: 1.0,
1465                phase: 100.0,
1466                elevation_weighting: false,
1467            },
1468            tropo: precise_positioning::TroposphereOptions::disabled(),
1469            corrections: precise_positioning::RangeCorrections::disabled(),
1470            opts: precise_positioning::FloatSolveOptions {
1471                max_iterations: 1,
1472                position_tolerance_m: 1.0e-4,
1473                clock_tolerance_m: 1.0e-4,
1474                ambiguity_tolerance_m: 1.0e-4,
1475                ztd_tolerance_m: 1.0e-4,
1476            },
1477            elevation_cutoff_deg: None,
1478            residual_screen: false,
1479            estimate_residual_ionosphere: false,
1480        }
1481    }
1482
1483    fn ppp_fixed_solve_config() -> FixedSolveConfig {
1484        let float = ppp_float_solve_config();
1485        FixedSolveConfig {
1486            weights: float.weights,
1487            tropo: float.tropo,
1488            corrections: float.corrections,
1489            opts: float.opts,
1490            elevation_cutoff_deg: None,
1491            ambiguity: precise_positioning::FixedAmbiguityOptions {
1492                wavelengths_m: BTreeMap::new(),
1493                offsets_m: BTreeMap::new(),
1494                ratio_threshold: 3.0,
1495            },
1496            estimate_residual_ionosphere: false,
1497        }
1498    }
1499
1500    fn empty_ppp_state() -> FloatState {
1501        FloatState {
1502            position_m: [0.0; 3],
1503            clocks_m: Vec::new(),
1504            ambiguities_m: BTreeMap::new(),
1505            ztd_m: 0.0,
1506            tropo_gradient_north_m: 0.0,
1507            tropo_gradient_east_m: 0.0,
1508            residual_ionosphere_m: BTreeMap::new(),
1509        }
1510    }
1511
1512    fn empty_ppp_float_solution() -> FloatSolution {
1513        FloatSolution {
1514            position_m: [0.0; 3],
1515            position_covariance: sidereon_core::dop::PositionCovariance {
1516                ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1517                enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1518            },
1519            formal_position_covariance: sidereon_core::dop::PositionCovariance {
1520                ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1521                enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1522            },
1523            posterior_variance_factor: 1.0,
1524            position_covariance_scale_factor: 1.0,
1525            temporal_position_covariance: sidereon_core::dop::PositionCovariance {
1526                ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1527                enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1528            },
1529            temporal_position_covariance_scale_factor: 1.0,
1530            temporal_correlation: sidereon_core::precise_positioning::TemporalCorrelationSummary {
1531                lag1_autocorrelation: 0.0,
1532                decorrelation_time_epochs: 0.0,
1533                decorrelation_time_s: None,
1534                nominal_sample_count: 0,
1535                effective_sample_count: 0.0,
1536                variance_inflation_factor: 1.0,
1537                arcs_used: 0,
1538            },
1539            epoch_clocks_m: Vec::new(),
1540            ambiguities_m: BTreeMap::new(),
1541            residual_ionosphere_m: BTreeMap::new(),
1542            ztd_residual_m: None,
1543            tropo_gradient_north_m: None,
1544            tropo_gradient_east_m: None,
1545            tropo_gradient_covariance_m2: None,
1546            formal_tropo_gradient_covariance_m2: None,
1547            residuals_m: Vec::new(),
1548            used_sats: Vec::new(),
1549            iterations: 0,
1550            converged: false,
1551            status: precise_positioning::FloatStatus::MaxIterations,
1552            code_rms_m: 0.0,
1553            phase_rms_m: 0.0,
1554            weighted_rms_m: 0.0,
1555        }
1556    }
1557
1558    #[test]
1559    fn load_sp3_parses_a_precise_product() {
1560        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
1561        assert_eq!(sp3.epoch_count(), 2);
1562        assert_eq!(sp3.satellites().len(), 5);
1563    }
1564
1565    #[test]
1566    fn load_sp3_surfaces_parse_errors() {
1567        let err = load_sp3(b"not an sp3 file").unwrap_err();
1568        assert!(matches!(err, Error::Sp3(_)));
1569        assert!(err.to_string().contains("SP3 parse failed"));
1570        // The core parse message is preserved as the error source.
1571        assert!(std::error::Error::source(&err).is_some());
1572    }
1573
1574    #[test]
1575    fn product_ingestion_wrappers_parse_fixture_products() {
1576        let antex = parse_antex(ANTEX_TEXT).expect("parse ANTEX fixture");
1577        assert!(!antex.antennas.is_empty());
1578        let loaded_antex =
1579            load_antex(fixture_path(&["antex", "igs20_wettzell_trim.atx"])).expect("load ANTEX");
1580        assert_eq!(loaded_antex.antennas.len(), antex.antennas.len());
1581
1582        let nav = parse_rinex_nav(RINEX_NAV_TEXT).expect("parse RINEX NAV fixture");
1583        assert!(!nav.records().is_empty() || !nav.glonass_records().is_empty());
1584        let loaded_nav =
1585            load_rinex_nav(fixture_path(&["nav", "ESBC00DNK_R_20201770000_01D_MN.rnx"]))
1586                .expect("load RINEX NAV");
1587        assert_eq!(loaded_nav.records().len(), nav.records().len());
1588        assert_eq!(
1589            loaded_nav.glonass_records().len(),
1590            nav.glonass_records().len()
1591        );
1592
1593        let obs = parse_rinex_obs(RINEX_OBS_TEXT).expect("parse RINEX OBS fixture");
1594        assert!(!obs.epochs().is_empty());
1595        let loaded_obs = load_rinex_obs(fixture_path(&[
1596            "obs",
1597            "ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
1598        ]))
1599        .expect("load RINEX OBS");
1600        assert_eq!(loaded_obs.epochs().len(), obs.epochs().len());
1601
1602        let clock = parse_rinex_clock(RINEX_CLOCK_TEXT).expect("parse RINEX clock fixture");
1603        assert!(!clock.series_rows().is_empty());
1604        let loaded_clock = load_rinex_clock(fixture_path(&["clk", "synthetic_rinex_clock.clk"]))
1605            .expect("load RINEX clock");
1606        assert_eq!(loaded_clock.series_rows().len(), clock.series_rows().len());
1607        let lossy_clock = parse_rinex_clock_lossy("AS malformed\n");
1608        assert!(lossy_clock.series_rows().is_empty());
1609        let loaded_lossy_clock =
1610            load_rinex_clock_lossy(fixture_path(&["clk", "synthetic_rinex_clock.clk"]))
1611                .expect("load lossy RINEX clock");
1612        assert_eq!(
1613            loaded_lossy_clock.series_rows().len(),
1614            clock.series_rows().len()
1615        );
1616
1617        let bias = parse_bias_sinex(BIAS_BYTES).expect("parse Bias-SINEX fixture");
1618        assert_eq!(bias.records().len(), 351);
1619        let loaded_bias = load_bias_sinex(fixture_path(&[
1620            "bias",
1621            "COD0OPSFIN_20261330000_01D_01D_OSB.BIA.gz",
1622        ]))
1623        .expect("load gzip Bias-SINEX");
1624        assert!(!loaded_bias.records().is_empty());
1625        let lossy_bias = parse_bias_sinex_lossy(BIAS_BYTES).expect("lossy Bias-SINEX parse");
1626        assert_eq!(lossy_bias.value.records().len(), bias.records().len());
1627
1628        let dcb = parse_code_dcb(DCB_BYTES, None).expect("parse CODE DCB fixture");
1629        assert_eq!(dcb.records().len(), 496);
1630        let loaded_dcb =
1631            load_code_dcb(fixture_path(&["bias", "P1C1_RINEX.DCB"]), None).expect("load CODE DCB");
1632        assert_eq!(loaded_dcb.records().len(), dcb.records().len());
1633        let lossy_dcb = load_code_dcb_lossy(fixture_path(&["bias", "P1C1_RINEX.DCB"]), None)
1634            .expect("load lossy CODE DCB");
1635        assert_eq!(lossy_dcb.value.records().len(), dcb.records().len());
1636
1637        let decoded = decode_crinex(CRINEX_TEXT).expect("decode CRINEX fixture");
1638        assert!(decoded.contains("RINEX VERSION / TYPE"));
1639        let encoded = encode_crinex(&decoded).expect("encode decoded RINEX fixture");
1640        let round_tripped = decode_crinex(&encoded).expect("decode encoded CRINEX fixture");
1641        assert_eq!(round_tripped, decoded);
1642        let loaded_decoded = load_crinex(fixture_path(&[
1643            "obs",
1644            "ESBC00DNK_R_20201770000_01D_30S_MO_trim.crx",
1645        ]))
1646        .expect("load CRINEX");
1647        assert_eq!(loaded_decoded, decoded);
1648    }
1649
1650    #[test]
1651    fn ssr_store_from_rtcm_ingests_real_combined_orbit_clock_frame() {
1652        let week = sidereon_core::astro::time::model::GnssWeekTow::new(
1653            sidereon_core::astro::time::model::TimeScale::Gpst,
1654            2425,
1655            344_970.0,
1656        )
1657        .expect("valid week");
1658        let store = ssr_store_from_rtcm(&hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX), week)
1659            .expect("ingest real SSR frame");
1660        let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).expect("valid satellite");
1661        let orbit = store.orbit(sat).expect("G30 orbit correction");
1662        let clock = store.clock(sat).expect("G30 clock correction");
1663        assert_eq!(orbit.iode, 90);
1664        assert!((orbit.radial_m + 0.0807).abs() < 1.0e-12);
1665        assert!((orbit.along_m + 0.2484).abs() < 1.0e-12);
1666        assert!((orbit.cross_m - 0.1396).abs() < 1.0e-12);
1667        assert!((clock.c0_m - 0.0166).abs() < 1.0e-12);
1668    }
1669
1670    #[test]
1671    fn product_ingestion_wrappers_map_errors() {
1672        let err = match parse_rinex_nav("not a RINEX NAV file") {
1673            Ok(_) => panic!("invalid RINEX NAV unexpectedly parsed"),
1674            Err(err) => err,
1675        };
1676        assert!(matches!(err, Error::RinexNav(_)));
1677        assert!(std::error::Error::source(&err).is_some());
1678
1679        let err = match parse_rinex_nav(
1680            "     4.00           NAVIGATION DATA     M                   RINEX VERSION / TYPE\n\
1681             XXX                                                         END OF HEADER\n\
1682             > EPH G01 LNAV\n",
1683        ) {
1684            Ok(_) => panic!("empty v4 EPH frame unexpectedly parsed"),
1685            Err(err) => err,
1686        };
1687        assert!(matches!(err, Error::RinexNav(_)));
1688
1689        let err = parse_rinex_obs("not a RINEX OBS file").unwrap_err();
1690        assert!(matches!(err, Error::RinexObs(_)));
1691        assert!(std::error::Error::source(&err).is_some());
1692
1693        let err = parse_rinex_clock("AS malformed").unwrap_err();
1694        assert!(matches!(err, Error::RinexClock(_)));
1695        assert!(std::error::Error::source(&err).is_some());
1696
1697        let err = parse_code_dcb(b"not a DCB file", None).unwrap_err();
1698        assert!(matches!(err, Error::Bias(_)));
1699        assert!(std::error::Error::source(&err).is_some());
1700
1701        let err = decode_crinex("not a CRINEX file\n").unwrap_err();
1702        assert!(matches!(err, Error::Crinex(_)));
1703        let rendered = err.to_string();
1704        assert!(rendered.starts_with("CRINEX decode failed:"), "{rendered}");
1705        assert!(!rendered.contains("SP3 parse failed"), "{rendered}");
1706        assert!(std::error::Error::source(&err).is_some());
1707
1708        let missing = fixture_path(&["missing.nope"]);
1709        let err = match load_rinex_nav(missing) {
1710            Ok(_) => panic!("missing RINEX NAV path unexpectedly loaded"),
1711            Err(err) => err,
1712        };
1713        assert!(matches!(err, Error::Io(_)));
1714        assert!(std::error::Error::source(&err).is_some());
1715    }
1716
1717    #[test]
1718    fn astro_umbrella_reexports_broader_astrodynamics_surface() {
1719        let budget = astro::rf::LinkBudget {
1720            eirp_dbw: 0.0,
1721            fspl_db: 165.0,
1722            receiver_gt_dbk: -12.0,
1723            other_losses_db: 3.0,
1724            required_cn0_dbhz: 35.0,
1725        };
1726        assert_eq!(
1727            astro::rf::link_margin(&budget)
1728                .expect("valid RF link budget")
1729                .to_bits(),
1730            13.599999999999994_f64.to_bits()
1731        );
1732
1733        let ts =
1734            astro::time::TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0).expect("valid UTC instant");
1735        assert!((ts.jd_tt - 2451545.0).abs() < 1.0e-3);
1736
1737        let sun = [149_597_870.7, 0.0, 0.0];
1738        assert_eq!(
1739            astro::events::eclipse::status([-7000.0, 0.0, 0.0], sun)
1740                .expect("valid eclipse geometry"),
1741            astro::events::eclipse::EclipseStatus::Umbra
1742        );
1743        assert!(
1744            (astro::angles::beta_angle([1.0, 0.0, 0.0], sun).expect("valid beta geometry") - 90.0)
1745                .abs()
1746                <= 1.0e-9
1747        );
1748        let sep = astro::angles::angular_separation_coords(
1749            (101.287155333, -16.716115861),
1750            (114.825493028, 5.224993306),
1751        )
1752        .expect("valid angular separation");
1753        assert!((sep - 25.7013646403623).abs() <= 1.0e-9);
1754        let pa = astro::angles::position_angle(
1755            (101.287155333, -16.716115861),
1756            (114.825493028, 5.224993306),
1757        )
1758        .expect("valid position angle");
1759        let pa_diff = {
1760            let diff = (pa - 32.51673660099302).abs();
1761            diff.min(360.0 - diff)
1762        };
1763        assert!(pa_diff <= 1.0e-9);
1764        assert!(astro::covariance::symmetric(&[
1765            [1.0, 0.1, 0.2],
1766            [0.1, 2.0, 0.3],
1767            [0.2, 0.3, 3.0]
1768        ]));
1769
1770        assert!(core::mem::size_of::<astro::bodies::SunMoon>() > 0);
1771        assert!(core::mem::size_of::<astro::cdm::CdmKvn>() > 0);
1772        assert!(core::mem::size_of::<astro::conjunction::ConjunctionState>() > 0);
1773        assert!(core::mem::size_of::<astro::frames::transforms::TemeStateKm>() > 0);
1774        assert!(core::mem::size_of::<astro::omm::Omm>() > 0);
1775        assert!(core::mem::size_of::<astro::tdm::Tdm>() > 0);
1776    }
1777
1778    #[test]
1779    fn ground_site_sun_moon_helpers_reachable_through_facade() {
1780        let station = GeodeticStationKm {
1781            latitude_deg: 51.4769,
1782            longitude_deg: 0.0,
1783            altitude_km: 0.046,
1784        };
1785        // Solar upper transit at Greenwich on 2024-06-20 (Skyfield de421):
1786        // az 180.0 deg, alt 61.96 deg.
1787        let noon = UtcInstant::from_utc(2024, 6, 20, 12, 1, 42, 0).expect("valid UTC");
1788        let sun = sun_az_el(&station, noon).expect("sun geometry");
1789        assert!((sun.elevation_deg - 61.96).abs() < 0.5);
1790
1791        // Full moon of 2024-04-23 23:49 UTC: nearly fully lit.
1792        let full = UtcInstant::from_utc(2024, 4, 23, 23, 49, 0, 0).expect("valid UTC");
1793        let illum = moon_illumination(&station, full).expect("moon illumination");
1794        assert!(illum.illuminated_fraction > 0.95);
1795        let moon = moon_az_el(&station, full).expect("moon geometry");
1796        assert!((350_000.0..410_000.0).contains(&moon.range_km));
1797
1798        let start = UtcInstant::from_utc(2024, 4, 23, 0, 0, 0, 0).expect("valid UTC");
1799        let end = UtcInstant::from_utc(2024, 4, 24, 0, 0, 0, 0).expect("valid UTC");
1800        assert_eq!(
1801            find_moon_elevation_crossings(&station, start, end, MoonElevationOptions::default())
1802                .expect("moon crossings")
1803                .len(),
1804            2
1805        );
1806        assert_eq!(
1807            find_moon_transits(&station, start, end, 300.0, 1.0)
1808                .expect("moon transits")
1809                .len(),
1810            2
1811        );
1812
1813        // The shared Earth-fixed topocentric primitive is exposed too.
1814        let (_az, el, _range) =
1815            itrs_to_topocentric([0.0, 0.0, 7000.0], &station).expect("topocentric");
1816        assert!(el.is_finite());
1817
1818        let kernel = astro::Spk::from_bytes(include_bytes!(
1819            "../../sidereon-core/tests/fixtures/bodies/observe_de.bsp"
1820        ))
1821        .expect("fixture SPK");
1822        let observe_time = UtcInstant::from_utc(2024, 1, 1, 0, 0, 0, 0).expect("valid UTC");
1823        let mars = observe_spk_body(&station, observe_time, &kernel, 4).expect("Mars observation");
1824        assert!(mars.apparent.right_ascension_deg.is_finite());
1825        assert!(!mars.reduced);
1826        let target = Target::Spk {
1827            kernel: &kernel,
1828            naif_id: 4,
1829        };
1830        let same = observe(&station, observe_time, target, ObserveOptions::default())
1831            .expect("generic observation");
1832        assert_eq!(
1833            same.apparent.right_ascension_deg.to_bits(),
1834            mars.apparent.right_ascension_deg.to_bits()
1835        );
1836        let tod =
1837            gcrs_to_true_of_date_matrix(&observe_time.time_scales()).expect("true-of-date matrix");
1838        assert!(tod[0][0].is_finite());
1839        let gcrs_state = astro::frames::transforms::TemeStateKm {
1840            position_km: [7000.0, 100.0, -50.0],
1841            velocity_km_s: [0.0, 7.5, 0.1],
1842        };
1843        let (teme_pos, _) = gcrs_to_teme_compute(&gcrs_state, &observe_time.time_scales(), false)
1844            .expect("TEME inverse transform");
1845        assert!(teme_pos.0.is_finite());
1846    }
1847
1848    #[test]
1849    fn root_geodetic_look_angle_and_doppler_helpers_reachable_through_facade() {
1850        let station = GroundStation {
1851            latitude_deg: 51.5,
1852            longitude_deg: -0.1,
1853            altitude_m: 11.0,
1854        };
1855        let datetime = UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).expect("valid UTC");
1856        let tle = station_tle("ISS (ZARYA)");
1857        let elements = tle::parse(tle.line1, tle.line2)
1858            .expect("ISS TLE parses")
1859            .elements
1860            .to_element_set()
1861            .expect("ISS TLE converts to SGP4 elements");
1862        let look = look_angle(&elements, station, datetime).expect("ISS look angle");
1863        assert!(look.azimuth_deg.is_finite());
1864        assert!((-90.0..=90.0).contains(&look.elevation_deg));
1865        assert!(look.range_km > 100.0);
1866
1867        let satellite = sgp4::Satellite::from_tle(tle.line1, tle.line2).expect("ISS TLE parses");
1868        let track = ground_track(&satellite, &[datetime]).expect("ISS ground track");
1869        assert_eq!(track.len(), 1);
1870        assert!(track[0].lat_rad.is_finite());
1871        assert!(track[0].lon_rad.is_finite());
1872
1873        let ecef = geodetic_to_itrs(51.5, -0.1, 0.011).expect("geodetic to ITRS");
1874        let geodetic = itrs_to_geodetic_compute(ecef.0, ecef.1, ecef.2).expect("ITRS to geodetic");
1875        assert!((geodetic.0 - 51.5).abs() < 1.0e-6);
1876        assert!((geodetic.1 + 0.1).abs() < 1.0e-6);
1877        let projected = geodetic_from_ecef_proj(ecef.0 * 1000.0, ecef.1 * 1000.0, ecef.2 * 1000.0)
1878            .expect("projected geodetic");
1879        assert!((projected[0] + 0.1).abs() < 1.0e-6);
1880        assert!((projected[1] - 51.5).abs() < 1.0e-6);
1881
1882        let ts = datetime.time_scales();
1883        let shifted = doppler_shift(
1884            [3700.2112112039954, 2015.9122181206055, 5309.513078070448],
1885            [-3.398428894395407, 6.869656830559572, -0.239850181126689],
1886            40.0,
1887            -74.0,
1888            0.0,
1889            &ts,
1890            437.0e6,
1891        )
1892        .expect("Doppler shift");
1893        assert!(shifted.range_rate_km_s.is_finite());
1894        assert!(shifted.doppler_hz.is_finite());
1895    }
1896
1897    #[test]
1898    fn tca_shortcut_screens_two_real_tles_over_one_day() {
1899        let primary_tle = station_tle("ISS (ZARYA)");
1900        let secondary_tle = station_tle("CSS (TIANHE)");
1901        let primary = sgp4::Satellite::from_tle(primary_tle.line1, primary_tle.line2)
1902            .expect("station TLE parses");
1903        let window = tca::TcaWindow::from_start_and_duration_seconds(
1904            primary.epoch_jd(),
1905            sidereon_core::constants::SECONDS_PER_DAY,
1906        )
1907        .expect("valid one-day window");
1908        let options = tca::TcaFinderOptions {
1909            coarse_step_seconds: 120.0,
1910            time_tolerance_seconds: 1.0e-2,
1911        };
1912
1913        let candidates =
1914            tca::find_tca_candidates_between_tles(primary_tle, secondary_tle, window, options)
1915                .expect("real TLE TCA search succeeds");
1916        assert!(!candidates.is_empty());
1917
1918        let best = candidates
1919            .iter()
1920            .min_by(|a, b| a.miss_distance_km.total_cmp(&b.miss_distance_km))
1921            .expect("candidate set is nonempty");
1922        assert!(best.tca_seconds_since_window_start > 0.0);
1923        assert!(best.tca_seconds_since_window_start < sidereon_core::constants::SECONDS_PER_DAY);
1924        assert!(best.miss_distance_km.is_finite());
1925        assert!(best.miss_distance_km > 0.0);
1926        assert!((norm3(best.relative_position_km) - best.miss_distance_km).abs() < 1.0e-9);
1927        assert!(norm3(best.relative_velocity_km_s) > 0.0);
1928
1929        let secondaries = [secondary_tle];
1930        let threshold_km = best.miss_distance_km + 1.0;
1931        let serial = tca::screen_tca_candidates_from_tle_catalog_serial(
1932            primary_tle,
1933            &secondaries,
1934            window,
1935            threshold_km,
1936            options,
1937        )
1938        .expect("serial real TLE screening succeeds");
1939        let parallel = tca::screen_tca_candidates_from_tle_catalog_parallel(
1940            primary_tle,
1941            &secondaries,
1942            window,
1943            threshold_km,
1944            options,
1945        )
1946        .expect("parallel real TLE screening succeeds");
1947
1948        assert_eq!(serial, parallel);
1949        assert!(!serial.is_empty());
1950        assert!(serial.iter().all(|hit| hit.secondary_index == 0));
1951        assert!(serial
1952            .iter()
1953            .all(|hit| hit.candidate.miss_distance_km <= threshold_km));
1954
1955        let pc_options = tca::TcaPcOptions::with_default_covariance(
1956            0.020,
1957            astro::conjunction::PcMethod::Alfano2005,
1958        );
1959        let conjunctions = tca::find_tca_conjunctions_between_tles(
1960            primary_tle,
1961            secondary_tle,
1962            window,
1963            options,
1964            pc_options,
1965        )
1966        .expect("real TLE TCA Pc search succeeds");
1967        assert_eq!(conjunctions.len(), candidates.len());
1968        assert!(conjunctions.iter().all(|conjunction| {
1969            conjunction.collision_probability.pc.is_finite()
1970                && (0.0..=1.0).contains(&conjunction.collision_probability.pc)
1971        }));
1972    }
1973
1974    #[test]
1975    fn gnss_utility_modules_are_reexported() {
1976        assert_eq!(
1977            frequencies::frequency_hz(GnssSystem::Gps, frequencies::CarrierBand::L1),
1978            Some(constants::F_L1_HZ)
1979        );
1980        assert_eq!(GnssSystem::Gps.as_str(), "GPS");
1981        assert_eq!(GnssSystem::Gps.to_string(), "GPS");
1982        assert_eq!(frequencies::CarrierBand::L1.as_str(), "l1");
1983        assert_eq!(frequencies::CarrierBand::L1.to_string(), "l1");
1984        assert!(geometry::visible_at_elevation_mask(5.0, 5.0));
1985        assert!(combinations::gamma(constants::F_L1_HZ, constants::F_L2_HZ)
1986            .expect("GPS L1/L2 gamma")
1987            .is_finite());
1988        assert_eq!(
1989            carrier_phase::geometry_free(100.0, 60.0)
1990                .expect("finite geometry-free combination")
1991                .to_bits(),
1992            40.0_f64.to_bits()
1993        );
1994        assert!(quality::pseudorange_variance(
1995            30.0,
1996            quality::PseudorangeVarianceOptions::default()
1997        )
1998        .expect("positive elevation variance")
1999        .is_finite());
2000        assert_eq!(
2001            signal::ca_code(1).expect("GPS PRN 1").len(),
2002            signal::CA_CODE_LENGTH
2003        );
2004        assert_eq!(
2005            velocity::doppler_to_range_rate(-1.0, constants::F_L1_HZ)
2006                .expect("valid Doppler conversion")
2007                .to_bits(),
2008            (constants::C_M_S / constants::F_L1_HZ).to_bits()
2009        );
2010        assert_eq!(navigation::lnav::PREAMBLE, 0b1000_1011);
2011        assert_eq!(dgnss::CodeObservation::new("G01", 1.0).satellite_id, "G01");
2012        assert_eq!(
2013            sbas::sat_to_sbas_prn(sbas::sbas_prn_to_sat(120).expect("valid augmentation PRN")),
2014            Some(120)
2015        );
2016        assert!(core::mem::size_of::<geometry::VisibilityOptions>() > 0);
2017        assert!(core::mem::size_of::<broadcast_comparison::EpochInputs>() > 0);
2018    }
2019
2020    #[test]
2021    fn solve_spp_delegates_to_the_core_solver_and_maps_errors() {
2022        // Two observations against a four-parameter solve is under-determined,
2023        // so the real core solver returns an error; the wrapper maps it into
2024        // `Error::Spp` rather than leaking the technique-specific type.
2025        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2026        let sat = |prn| GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id");
2027        let inputs = SolveInputs {
2028            observations: vec![
2029                Observation {
2030                    satellite_id: sat(1),
2031                    pseudorange_m: 2.1e7,
2032                },
2033                Observation {
2034                    satellite_id: sat(2),
2035                    pseudorange_m: 2.1e7,
2036                },
2037            ],
2038            t_rx_j2000_s: 646_315_200.0,
2039            t_rx_second_of_day_s: 0.0,
2040            day_of_year: 176.0,
2041            initial_guess: [0.0, 0.0, 0.0, 0.0],
2042            corrections: Corrections::NONE,
2043            klobuchar: KlobucharCoeffs {
2044                alpha: [0.0; 4],
2045                beta: [0.0; 4],
2046            },
2047            beidou_klobuchar: None,
2048            galileo_nequick: None,
2049            sbas_iono: None,
2050            glonass_channels: std::collections::BTreeMap::new(),
2051            met: SurfaceMet {
2052                pressure_hpa: 1013.25,
2053                temperature_k: 288.15,
2054                relative_humidity: 0.5,
2055            },
2056            robust: None,
2057        };
2058
2059        let result = solve_spp(&sp3, &inputs, false, SolvePolicy::default());
2060        assert!(matches!(result, Err(Error::Spp(_))), "got {result:?}");
2061        if let Err(err) = result {
2062            assert!(err.to_string().contains("SPP solve failed"));
2063            assert!(std::error::Error::source(&err).is_some());
2064        }
2065    }
2066
2067    #[test]
2068    fn spp_robust_fde_driver_is_reexported_from_facade_root() {
2069        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2070        let sat = |prn| GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id");
2071        let inputs = SolveInputs {
2072            observations: vec![
2073                Observation {
2074                    satellite_id: sat(1),
2075                    pseudorange_m: 2.1e7,
2076                },
2077                Observation {
2078                    satellite_id: sat(2),
2079                    pseudorange_m: 2.1e7,
2080                },
2081            ],
2082            t_rx_j2000_s: 646_315_200.0,
2083            t_rx_second_of_day_s: 0.0,
2084            day_of_year: 176.0,
2085            initial_guess: [0.0, 0.0, 0.0, 0.0],
2086            corrections: Corrections::NONE,
2087            klobuchar: KlobucharCoeffs {
2088                alpha: [0.0; 4],
2089                beta: [0.0; 4],
2090            },
2091            beidou_klobuchar: None,
2092            galileo_nequick: None,
2093            sbas_iono: None,
2094            glonass_channels: std::collections::BTreeMap::new(),
2095            met: SurfaceMet {
2096                pressure_hpa: 1013.25,
2097                temperature_k: 288.15,
2098                relative_humidity: 0.5,
2099            },
2100            robust: None,
2101        };
2102        let options = quality::FdeSppOptions {
2103            fde: quality::FdeOptions {
2104                raim: quality::RaimOptions::default(),
2105                max_iterations: 0,
2106            },
2107            validation: quality::SolutionValidationOptions::default(),
2108        };
2109
2110        let result = spp_robust_fde_driver(
2111            &sp3,
2112            &inputs,
2113            false,
2114            positioning::RobustConfig::default(),
2115            &options,
2116        );
2117
2118        assert!(
2119            matches!(
2120                result,
2121                Err(quality::FdeError::Solve(quality::FdeSppError::Spp(_)))
2122            ),
2123            "got {result:?}"
2124        );
2125    }
2126
2127    #[test]
2128    fn solve_velocity_delegates_to_core_solver_and_maps_errors() {
2129        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2130        let result = solve_velocity(
2131            &sp3,
2132            &[],
2133            [0.0; 3],
2134            646_315_200.0,
2135            VelocitySolveOptions::default(),
2136        );
2137
2138        assert!(
2139            matches!(
2140                result,
2141                Err(Error::Velocity(velocity::VelocityError::NoObservations))
2142            ),
2143            "got {result:?}"
2144        );
2145        if let Err(err) = result {
2146            assert!(err.to_string().contains("velocity solve failed"));
2147            assert!(std::error::Error::source(&err).is_some());
2148        }
2149    }
2150
2151    #[test]
2152    fn solve_rtk_float_positional_wrapper_maps_errors() {
2153        let epochs = Vec::new();
2154        let ambiguity_ids = Vec::<String>::new();
2155        let model = rtk_model();
2156
2157        let err = solve_rtk_float(
2158            &epochs,
2159            [0.0; 3],
2160            &ambiguity_ids,
2161            [0.0; 3],
2162            &model,
2163            rtk_float_options(),
2164            None,
2165        )
2166        .unwrap_err();
2167
2168        assert!(matches!(err, Error::RtkFloat(_)));
2169        assert!(err.to_string().contains("RTK float"));
2170        assert!(std::error::Error::source(&err).is_some());
2171    }
2172
2173    #[test]
2174    fn solve_rtk_fixed_positional_wrapper_maps_errors() {
2175        let epochs = Vec::new();
2176        let ambiguity_ids = Vec::<String>::new();
2177        let ambiguity_satellites = BTreeMap::new();
2178        let wavelengths_m = BTreeMap::new();
2179        let offsets_m = BTreeMap::new();
2180        let float_only_systems = Vec::new();
2181        let model = rtk_model();
2182        let ambiguity_set = AmbiguitySet {
2183            ids: &ambiguity_ids,
2184            satellites: &ambiguity_satellites,
2185            scale: rtk_filter::AmbiguityScale {
2186                wavelengths_m: &wavelengths_m,
2187                offsets_m: &offsets_m,
2188            },
2189            float_only_systems: &float_only_systems,
2190        };
2191
2192        let err = solve_rtk_fixed(
2193            &epochs,
2194            [0.0; 3],
2195            ambiguity_set,
2196            [0.0; 3],
2197            &model,
2198            rtk_fixed_options(),
2199            None,
2200        )
2201        .unwrap_err();
2202
2203        assert!(matches!(err, Error::RtkFixed(_)));
2204        assert!(err.to_string().contains("fixed RTK"));
2205        assert!(std::error::Error::source(&err).is_some());
2206    }
2207
2208    #[test]
2209    fn solve_ppp_float_positional_wrapper_maps_errors_with_fixture_source() {
2210        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2211        let epochs = Vec::new();
2212
2213        let err = solve_ppp_float(&sp3, &epochs, empty_ppp_state(), ppp_float_solve_config())
2214            .unwrap_err();
2215
2216        assert!(matches!(err, Error::PppFloat(_)));
2217        assert!(err.to_string().contains("PPP float"));
2218        assert!(std::error::Error::source(&err).is_some());
2219    }
2220
2221    #[test]
2222    fn solve_ppp_fixed_positional_wrapper_maps_errors_with_fixture_source() {
2223        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2224        let epochs = Vec::new();
2225
2226        let err = solve_ppp_fixed(
2227            &sp3,
2228            &epochs,
2229            empty_ppp_float_solution(),
2230            ppp_fixed_solve_config(),
2231        )
2232        .unwrap_err();
2233
2234        assert!(matches!(err, Error::PppFixed(_)));
2235        assert!(err.to_string().contains("PPP"));
2236        assert!(std::error::Error::source(&err).is_some());
2237    }
2238
2239    #[test]
2240    fn solve_rtk_float_with_delegates_to_positional_solver() {
2241        let epochs = Vec::new();
2242        let ambiguity_ids = Vec::new();
2243        let model = rtk_model();
2244        let config = RtkFloatConfig {
2245            epochs: &epochs,
2246            base_ecef_m: [0.0; 3],
2247            ambiguity_ids: &ambiguity_ids,
2248            initial_baseline_m: [0.0; 3],
2249            model: &model,
2250            options: rtk_float_options(),
2251            receiver_antenna_corrections: None,
2252        };
2253
2254        let typed = solve_rtk_float_with(config.clone()).unwrap_err();
2255        let positional = solve_rtk_float(
2256            config.epochs,
2257            config.base_ecef_m,
2258            config.ambiguity_ids,
2259            config.initial_baseline_m,
2260            config.model,
2261            config.options,
2262            config.receiver_antenna_corrections,
2263        )
2264        .unwrap_err();
2265
2266        assert!(matches!(typed, Error::RtkFloat(_)));
2267        assert_eq!(typed.to_string(), positional.to_string());
2268    }
2269
2270    #[test]
2271    fn solve_rtk_float_with_rejects_receiver_antenna_zero_base_geometry() {
2272        let base = [0.0; 3];
2273        let baseline = [1.0, 0.0, 0.0];
2274        let rover = [
2275            base[0] + baseline[0],
2276            base[1] + baseline[1],
2277            base[2] + baseline[2],
2278        ];
2279        let g01 = [15_000_000.0, 7_000_000.0, 21_000_000.0];
2280        let g02 = [-12_000_000.0, 18_000_000.0, 19_000_000.0];
2281        let range_m = |sat: [f64; 3], recv: [f64; 3]| {
2282            let dx = sat[0] - recv[0];
2283            let dy = sat[1] - recv[1];
2284            let dz = sat[2] - recv[2];
2285            (dx * dx + dy * dy + dz * dz).sqrt()
2286        };
2287        let mk = |sat: [f64; 3], id: &str| rtk_filter::SatMeas {
2288            sat: id.into(),
2289            sd_ambiguity_id: id.into(),
2290            base_code_m: range_m(sat, base),
2291            base_phase_m: range_m(sat, base),
2292            rover_code_m: range_m(sat, rover),
2293            rover_phase_m: range_m(sat, rover),
2294            base_tx_pos: sat,
2295            rover_tx_pos: sat,
2296            pos: sat,
2297        };
2298        let epochs = vec![rtk_filter::Epoch {
2299            references: vec![mk(g01, "G01")],
2300            nonref: vec![mk(g02, "G02")],
2301            velocity_mps: None,
2302            dt_s: 0.0,
2303        }];
2304        let ambiguity_ids = vec!["G02".to_string()];
2305        let model = rtk_model();
2306        let cal = rtk_filter::ReceiverAntennaCalibration {
2307            pco_neu_m: [0.0, 0.0, 0.0],
2308            noazi_pcv_m: vec![(0.0, 0.0)],
2309            azi_pcv_m: Vec::new(),
2310        };
2311        let corrections = ReceiverAntennaCorrections {
2312            base: cal.clone(),
2313            rover: cal,
2314        };
2315        let config =
2316            RtkFloatConfig::new(&epochs, base, &ambiguity_ids, &model, rtk_float_options())
2317                .with_initial_baseline_m(baseline)
2318                .with_receiver_antenna_corrections(Some(&corrections));
2319
2320        let err = solve_rtk_float_with(config).unwrap_err();
2321
2322        assert!(matches!(
2323            err,
2324            Error::RtkFloat(rtk_filter::FloatSolveError::ReceiverAntenna(
2325                rtk_filter::ReceiverAntennaError::InvalidGeometry
2326            ))
2327        ));
2328    }
2329
2330    #[test]
2331    fn solve_rtk_fixed_with_delegates_to_positional_solver() {
2332        let epochs = Vec::new();
2333        let ambiguity_ids = Vec::new();
2334        let ambiguity_satellites = BTreeMap::new();
2335        let wavelengths_m = BTreeMap::new();
2336        let offsets_m = BTreeMap::new();
2337        let float_only_systems = Vec::new();
2338        let model = rtk_model();
2339        let ambiguity_set = AmbiguitySet {
2340            ids: &ambiguity_ids,
2341            satellites: &ambiguity_satellites,
2342            scale: rtk_filter::AmbiguityScale {
2343                wavelengths_m: &wavelengths_m,
2344                offsets_m: &offsets_m,
2345            },
2346            float_only_systems: &float_only_systems,
2347        };
2348        let config = RtkFixedConfig {
2349            epochs: &epochs,
2350            base_ecef_m: [0.0; 3],
2351            initial_ambiguities: ambiguity_set,
2352            initial_baseline_m: [0.0; 3],
2353            model: &model,
2354            options: rtk_fixed_options(),
2355            receiver_antenna_corrections: None,
2356        };
2357
2358        let typed = solve_rtk_fixed_with(config.clone()).unwrap_err();
2359        let positional = solve_rtk_fixed(
2360            config.epochs,
2361            config.base_ecef_m,
2362            config.initial_ambiguities,
2363            config.initial_baseline_m,
2364            config.model,
2365            config.options,
2366            config.receiver_antenna_corrections,
2367        )
2368        .unwrap_err();
2369
2370        assert!(matches!(typed, Error::RtkFixed(_)));
2371        assert_eq!(typed.to_string(), positional.to_string());
2372    }
2373
2374    #[test]
2375    fn solve_ppp_float_with_delegates_to_positional_solver() {
2376        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2377        let epochs = Vec::new();
2378        let config = PppFloatConfig {
2379            source: &sp3,
2380            epochs: &epochs,
2381            initial_state: empty_ppp_state(),
2382            solve: ppp_float_solve_config(),
2383        };
2384
2385        let typed = solve_ppp_float_with(config.clone()).unwrap_err();
2386        let positional = solve_ppp_float(
2387            config.source,
2388            config.epochs,
2389            config.initial_state,
2390            config.solve,
2391        )
2392        .unwrap_err();
2393
2394        assert!(matches!(typed, Error::PppFloat(_)));
2395        assert_eq!(typed.to_string(), positional.to_string());
2396    }
2397
2398    #[test]
2399    fn solve_ppp_fixed_with_delegates_to_positional_solver() {
2400        let sp3 = load_sp3(DEGENERATE_SP3).expect("the fixture parses");
2401        let epochs = Vec::new();
2402        let config = PppFixedConfig {
2403            source: &sp3,
2404            epochs: &epochs,
2405            float_solution: empty_ppp_float_solution(),
2406            solve: ppp_fixed_solve_config(),
2407        };
2408
2409        let typed = solve_ppp_fixed_with(config.clone()).unwrap_err();
2410        let positional = solve_ppp_fixed(
2411            config.source,
2412            config.epochs,
2413            config.float_solution,
2414            config.solve,
2415        )
2416        .unwrap_err();
2417
2418        assert!(matches!(typed, Error::PppFixed(_)));
2419        assert_eq!(typed.to_string(), positional.to_string());
2420    }
2421}