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