Skip to main content

sidereon_core/sbas/
store.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::astro::time::model::GnssWeekTow;
4use crate::constants::{F_L1_HZ, GPS_EPOCH_TO_J2000_S, MEAN_EARTH_RADIUS_KM, SECONDS_PER_DAY};
5use crate::error::{Error, Result};
6use crate::frame::Wgs84Geodetic;
7use crate::id::{GnssSatelliteId, GnssSystem};
8use crate::ionex::pierce_point;
9use crate::staleness::StalenessPolicy;
10use crate::tolerances::SBAS_IGP_COORD_EPS_DEG;
11
12use super::message::{
13    SbasGeoNav, SbasIgpMask, SbasIntegrity, SbasIonoDelays, SbasLongTermHalf, SbasLongTermRecord,
14    SbasMessage, SbasMixedCorrections,
15};
16
17const FAST_PRC_SCALE_M: f64 = 0.125;
18const IONO_DELAY_SCALE_M: f64 = 0.125;
19const LONG_POS_SCALE_M: f64 = 0.125;
20const LONG_RATE_SCALE_M_S: f64 = 0.000625;
21const LONG_AF0_SCALE_S: f64 = 1.0 / 2_147_483_648.0;
22const LONG_AF1_SCALE_S_S: f64 = 1.0 / 549_755_813_888.0;
23const GEO_XY_POS_SCALE_M: f64 = 0.08;
24const GEO_Z_POS_SCALE_M: f64 = 0.4;
25const GEO_XY_RATE_SCALE_M_S: f64 = 0.000625;
26const GEO_Z_RATE_SCALE_M_S: f64 = 0.004;
27const GEO_XY_ACCEL_SCALE_M_S2: f64 = 0.0000125;
28const GEO_Z_ACCEL_SCALE_M_S2: f64 = 0.0000625;
29const GEO_AF0_SCALE_S: f64 = 1.0 / 2_147_483_648.0;
30const GEO_AF1_SCALE_S_S: f64 = 1.0 / 1_099_511_627_776.0;
31const GEO_DISABLED_TIMEOUT_S: f64 = 60.0;
32const SBAS_SHELL_HEIGHT_KM: f64 = 350.0;
33
34/// DO-229 UDRE variance table, in square meters, for UDREI values 0 through 13.
35pub const SBAS_UDRE_VARIANCE_M2: [f64; 14] = [
36    0.0520, 0.0924, 0.1444, 0.2830, 0.4678, 0.8315, 1.2992, 1.8709, 2.5465, 3.3260, 5.1968,
37    20.7870, 230.9661, 2078.695,
38];
39
40/// DO-229 GIVE variance table, in square meters, for GIVEI values 0 through 14.
41pub const SBAS_GIVE_VARIANCE_M2: [f64; 15] = [
42    0.0084, 0.0333, 0.0749, 0.1331, 0.2079, 0.2994, 0.4075, 0.5322, 0.6735, 0.8315, 1.1974, 1.8709,
43    3.3260, 20.7870, 187.0826,
44];
45
46/// Return the DO-229 UDRE variance for one UDREI value.
47pub fn udre_variance_m2_for_udrei(udrei: u8) -> Option<f64> {
48    SBAS_UDRE_VARIANCE_M2.get(usize::from(udrei)).copied()
49}
50
51/// Return the DO-229 GIVE variance for one GIVEI value.
52pub fn give_variance_m2_for_givei(givei: u8) -> Option<f64> {
53    SBAS_GIVE_VARIANCE_M2.get(usize::from(givei)).copied()
54}
55
56#[derive(Clone, Debug, PartialEq)]
57pub struct SbasFastCorrection {
58    pub prc_m: f64,
59    pub rrc_m_s: f64,
60    pub udrei: u8,
61    pub t_of_j2000_s: f64,
62    pub iodf: u8,
63}
64
65impl SbasFastCorrection {
66    /// DO-229 UDRE variance, in square meters, for this fast correction.
67    pub fn udre_variance_m2(&self) -> Option<f64> {
68        udre_variance_m2_for_udrei(self.udrei)
69    }
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct SbasLongTermCorrection {
74    pub iode: u8,
75    pub delta_ecef_m: [f64; 3],
76    pub delta_ecef_rate_m_s: [f64; 3],
77    pub delta_af0_s: f64,
78    pub delta_af1_s_s: f64,
79    pub t0_j2000_s: f64,
80}
81
82#[derive(Clone, Debug, PartialEq)]
83pub struct SbasIgp {
84    pub lat_deg: f64,
85    pub lon_deg: f64,
86    pub vertical_delay_m: f64,
87    pub give_variance_m2: Option<f64>,
88}
89
90#[derive(Clone, Debug, PartialEq, Default)]
91pub struct SbasIonoGrid {
92    igps: Vec<SbasIgp>,
93    pub iodi: u8,
94}
95
96impl SbasIonoGrid {
97    /// Construct an ionospheric grid from IGP records and the IODI value.
98    pub fn new(igps: Vec<SbasIgp>, iodi: u8) -> Self {
99        Self { igps, iodi }
100    }
101
102    /// Ionospheric grid points in storage order.
103    pub fn igps(&self) -> &[SbasIgp] {
104        &self.igps
105    }
106
107    /// Interpolate the L1 slant ionospheric delay at a receiver look direction.
108    pub fn slant_delay_m(
109        &self,
110        receiver: Wgs84Geodetic,
111        elevation_rad: f64,
112        azimuth_rad: f64,
113        frequency_hz: f64,
114    ) -> Option<f64> {
115        if self.igps.is_empty() || !frequency_hz.is_finite() || frequency_hz <= 0.0 {
116            return None;
117        }
118        let geom = pierce_point(
119            receiver.lat_rad,
120            receiver.lon_rad,
121            azimuth_rad,
122            elevation_rad,
123            MEAN_EARTH_RADIUS_KM,
124            SBAS_SHELL_HEIGHT_KM,
125        );
126        let vertical_delay_m =
127            self.vertical_delay_at_ipp(geom.phi_ipp_deg, normalize_lon(geom.lambda_ipp_deg))?;
128        let mapping = 1.0 / (1.0 - geom.s * geom.s).sqrt();
129        let frequency_scale = (F_L1_HZ / frequency_hz) * (F_L1_HZ / frequency_hz);
130        Some(vertical_delay_m * mapping * frequency_scale)
131    }
132
133    /// Interpolate the vertical GIVE variance at an ionospheric pierce point.
134    pub fn variance_at_ipp(&self, lat_deg: f64, lon_deg: f64) -> Option<f64> {
135        let variance_m2 = self
136            .delay_variance_at_ipp(lat_deg, lon_deg)?
137            .give_variance_m2?;
138        (variance_m2.is_finite() && variance_m2 >= 0.0).then_some(variance_m2)
139    }
140
141    fn vertical_delay_at_ipp(&self, lat_deg: f64, lon_deg: f64) -> Option<f64> {
142        self.delay_variance_at_ipp(lat_deg, lon_deg)
143            .map(|value| value.vertical_delay_m)
144    }
145
146    fn delay_variance_at_ipp(&self, lat_deg: f64, lon_deg: f64) -> Option<IgpInterpolation> {
147        let mut lats: Vec<f64> = self.igps.iter().map(|p| p.lat_deg).collect();
148        lats.sort_by(f64_total_cmp);
149        lats.dedup_by(|a, b| (*a - *b).abs() < SBAS_IGP_COORD_EPS_DEG);
150        let mut lons: Vec<f64> = self.igps.iter().map(|p| normalize_lon(p.lon_deg)).collect();
151        lons.sort_by(f64_total_cmp);
152        lons.dedup_by(|a, b| (*a - *b).abs() < SBAS_IGP_COORD_EPS_DEG);
153        let (lat0, lat1) = bracket_pair(&lats, lat_deg)?;
154        let (lon0, lon1) = bracket_pair(&lons, lon_deg)?;
155        if (lat1 - lat0).abs() < f64::EPSILON || (lon1 - lon0).abs() < f64::EPSILON {
156            return None;
157        }
158
159        let corners = [
160            self.find_igp(lat0, lon0),
161            self.find_igp(lat0, lon1),
162            self.find_igp(lat1, lon0),
163            self.find_igp(lat1, lon1),
164        ];
165        let active: Vec<SbasIgp> = corners.into_iter().flatten().collect();
166        match active.len() {
167            4 => {
168                let q = (lat_deg - lat0) / (lat1 - lat0);
169                let p = (lon_deg - lon0) / (lon1 - lon0);
170                let e00 = active_point(&active, lat0, lon0)?.vertical_delay_m;
171                let e01 = active_point(&active, lat0, lon1)?.vertical_delay_m;
172                let e10 = active_point(&active, lat1, lon0)?.vertical_delay_m;
173                let e11 = active_point(&active, lat1, lon1)?.vertical_delay_m;
174                let vertical_delay_m = (1.0 - p) * (1.0 - q) * e00
175                    + p * (1.0 - q) * e01
176                    + (1.0 - p) * q * e10
177                    + p * q * e11;
178                let give_variance_m2 = bilinear_give_variance_m2(&active, lat0, lat1, lon0, lon1)
179                    .map(|(v00, v01, v10, v11)| {
180                        (1.0 - p) * (1.0 - q) * v00
181                            + p * (1.0 - q) * v01
182                            + (1.0 - p) * q * v10
183                            + p * q * v11
184                    });
185                Some(IgpInterpolation {
186                    vertical_delay_m,
187                    give_variance_m2,
188                })
189            }
190            3 => Some(IgpInterpolation {
191                vertical_delay_m: plane_interpolate(&active, lat_deg, lon_deg)?,
192                give_variance_m2: plane_interpolate_give_variance(&active, lat_deg, lon_deg),
193            }),
194            _ => None,
195        }
196    }
197
198    fn find_igp(&self, lat_deg: f64, lon_deg: f64) -> Option<SbasIgp> {
199        self.igps
200            .iter()
201            .find(|p| {
202                (p.lat_deg - lat_deg).abs() < SBAS_IGP_COORD_EPS_DEG
203                    && (normalize_lon(p.lon_deg) - lon_deg).abs() < SBAS_IGP_COORD_EPS_DEG
204            })
205            .cloned()
206    }
207}
208
209#[derive(Clone, Debug, PartialEq)]
210pub struct SbasGeoState {
211    pub position_ecef_m: [f64; 3],
212    pub velocity_ecef_m_s: [f64; 3],
213    pub acceleration_ecef_m_s2: [f64; 3],
214    pub clock_offset_s: f64,
215    pub clock_drift_s_s: f64,
216    pub t0_j2000_s: f64,
217}
218
219impl SbasGeoState {
220    pub fn state_at(&self, t_j2000_s: f64) -> ([f64; 3], f64) {
221        let dt = t_j2000_s - self.t0_j2000_s;
222        let dt2 = dt * dt;
223        let position = [
224            self.position_ecef_m[0]
225                + self.velocity_ecef_m_s[0] * dt
226                + 0.5 * self.acceleration_ecef_m_s2[0] * dt2,
227            self.position_ecef_m[1]
228                + self.velocity_ecef_m_s[1] * dt
229                + 0.5 * self.acceleration_ecef_m_s2[1] * dt2,
230            self.position_ecef_m[2]
231                + self.velocity_ecef_m_s[2] * dt
232                + 0.5 * self.acceleration_ecef_m_s2[2] * dt2,
233        ];
234        let clock = self.clock_offset_s + self.clock_drift_s_s * dt;
235        (position, clock)
236    }
237}
238
239#[derive(Debug)]
240pub struct SbasCorrectionStore {
241    partitions: BTreeMap<GnssSatelliteId, GeoPartition>,
242    policy: StalenessPolicy,
243    allow_partial: bool,
244}
245
246impl Default for SbasCorrectionStore {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl SbasCorrectionStore {
253    pub fn new() -> Self {
254        Self {
255            partitions: BTreeMap::new(),
256            policy: StalenessPolicy::seconds(360.0),
257            allow_partial: false,
258        }
259    }
260
261    pub fn ingest(
262        &mut self,
263        message: &SbasMessage,
264        geo: GnssSatelliteId,
265        epoch: GnssWeekTow,
266    ) -> Result<()> {
267        if geo.system != GnssSystem::Sbas {
268            return Err(Error::InvalidInput(
269                "SBAS source GEO must be an SBAS id".to_string(),
270            ));
271        }
272        let epoch_j2000_s = epoch_to_j2000_s(epoch);
273        let partition = self.partitions.entry(geo).or_default();
274        partition.last_update_j2000_s = epoch_j2000_s;
275        match message {
276            SbasMessage::DoNotUse(_) => {
277                partition.disabled_until_j2000_s = Some(epoch_j2000_s + GEO_DISABLED_TIMEOUT_S);
278            }
279            SbasMessage::PrnMask(mask) => {
280                partition.active_iodp = Some(mask.iodp);
281                partition
282                    .masks
283                    .insert(mask.iodp, resolve_prn_mask(&mask.mask));
284            }
285            SbasMessage::FastCorrections(fast) => {
286                ingest_fast(
287                    partition,
288                    fast.message_type,
289                    fast.iodf,
290                    fast.iodp,
291                    &fast.prc,
292                    &fast.udrei,
293                    epoch_j2000_s,
294                );
295            }
296            SbasMessage::Integrity(integrity) => {
297                ingest_integrity(partition, integrity);
298            }
299            SbasMessage::FastDegradation(degradation) => {
300                if Some(degradation.iodp) == partition.active_iodp {
301                    partition.system_latency_s = f64::from(degradation.system_latency_s);
302                }
303            }
304            SbasMessage::GeoNav(geo_nav) => {
305                partition.geo_nav = Some(Timed {
306                    value: geo_state_from_message(geo_nav, epoch),
307                    epoch_j2000_s,
308                });
309            }
310            SbasMessage::IgpMask(mask) => {
311                partition
312                    .igp_masks
313                    .insert((mask.band_number, mask.iodi), mask.clone());
314            }
315            SbasMessage::IonoDelays(delays) => {
316                ingest_iono(partition, delays, epoch_j2000_s);
317            }
318            SbasMessage::MixedCorrections(mixed) => {
319                ingest_mixed(partition, mixed, epoch, epoch_j2000_s);
320            }
321            SbasMessage::LongTermCorrections(long) => {
322                for half in &long.halves {
323                    ingest_long_half(partition, half, epoch, epoch_j2000_s);
324                }
325            }
326            SbasMessage::NetworkTime(_)
327            | SbasMessage::GeoAlmanac(_)
328            | SbasMessage::Unsupported(_) => {}
329        }
330        Ok(())
331    }
332
333    pub fn ready_geos(&self, t_j2000_s: f64) -> Vec<GnssSatelliteId> {
334        let mut geos: Vec<(GnssSatelliteId, f64)> = self
335            .partitions
336            .iter()
337            .filter(|(_, p)| !p.is_disabled(t_j2000_s))
338            .filter(|(_, p)| p.active_iodp.is_some())
339            .filter(|(_, p)| p.iono_grid.is_some() || p.geo_nav.is_some() || !p.fast.is_empty())
340            .map(|(&geo, p)| (geo, p.last_update_j2000_s))
341            .collect();
342        geos.sort_by(|a, b| f64_total_cmp(&b.1, &a.1));
343        geos.into_iter().map(|(geo, _)| geo).collect()
344    }
345
346    pub fn fast(&self, geo: GnssSatelliteId, sat: GnssSatelliteId) -> Option<&SbasFastCorrection> {
347        self.partitions.get(&geo)?.fast.get(&sat).map(|t| &t.value)
348    }
349
350    pub fn long_term(
351        &self,
352        geo: GnssSatelliteId,
353        sat: GnssSatelliteId,
354    ) -> Option<&SbasLongTermCorrection> {
355        self.partitions
356            .get(&geo)?
357            .long_term
358            .get(&sat)
359            .map(|t| &t.value)
360    }
361
362    pub fn iono_grid(&self, geo: GnssSatelliteId) -> Option<&SbasIonoGrid> {
363        let partition = self.partitions.get(&geo)?;
364        if partition.is_disabled(partition.last_update_j2000_s) {
365            return None;
366        }
367        partition.iono_grid.as_ref().map(|t| &t.value)
368    }
369
370    pub fn geo_nav(&self, geo: GnssSatelliteId) -> Option<&SbasGeoState> {
371        self.partitions
372            .get(&geo)?
373            .geo_nav
374            .as_ref()
375            .map(|t| &t.value)
376    }
377
378    pub fn with_policy(mut self, policy: StalenessPolicy) -> Self {
379        self.policy = policy;
380        self
381    }
382
383    pub fn allow_partial(mut self, yes: bool) -> Self {
384        self.allow_partial = yes;
385        self
386    }
387
388    pub(crate) fn fresh_fast(
389        &self,
390        geo: GnssSatelliteId,
391        sat: GnssSatelliteId,
392        t_j2000_s: f64,
393    ) -> Option<&SbasFastCorrection> {
394        let p = self.partitions.get(&geo)?;
395        let timed = p.fast.get(&sat)?;
396        self.fresh(timed.epoch_j2000_s, t_j2000_s)
397            .then_some(&timed.value)
398    }
399
400    pub(crate) fn fresh_long_term(
401        &self,
402        geo: GnssSatelliteId,
403        sat: GnssSatelliteId,
404        t_j2000_s: f64,
405    ) -> Option<&SbasLongTermCorrection> {
406        let p = self.partitions.get(&geo)?;
407        let timed = p.long_term.get(&sat)?;
408        self.fresh(timed.epoch_j2000_s, t_j2000_s)
409            .then_some(&timed.value)
410    }
411
412    pub(crate) fn fresh_iono_grid(
413        &self,
414        geo: GnssSatelliteId,
415        t_j2000_s: f64,
416    ) -> Option<&SbasIonoGrid> {
417        let p = self.partitions.get(&geo)?;
418        let timed = p.iono_grid.as_ref()?;
419        (!p.is_disabled(t_j2000_s) && self.fresh(timed.epoch_j2000_s, t_j2000_s))
420            .then_some(&timed.value)
421    }
422
423    pub(crate) fn fresh_geo_nav(
424        &self,
425        geo: GnssSatelliteId,
426        t_j2000_s: f64,
427    ) -> Option<&SbasGeoState> {
428        let timed = self.partitions.get(&geo)?.geo_nav.as_ref()?;
429        self.fresh(timed.epoch_j2000_s, t_j2000_s)
430            .then_some(&timed.value)
431    }
432
433    pub(crate) fn is_disabled(&self, geo: GnssSatelliteId, t_j2000_s: f64) -> bool {
434        self.partitions
435            .get(&geo)
436            .is_some_and(|p| p.is_disabled(t_j2000_s))
437    }
438
439    pub(crate) fn is_withdrawn(&self, geo: GnssSatelliteId, sat: GnssSatelliteId) -> bool {
440        self.partitions
441            .get(&geo)
442            .is_some_and(|p| p.withdrawn.contains(&sat))
443    }
444
445    pub(crate) fn allow_partial_corrections(&self) -> bool {
446        self.allow_partial
447    }
448
449    #[cfg(test)]
450    pub(crate) fn insert_long_term_for_test(
451        &mut self,
452        geo: GnssSatelliteId,
453        sat: GnssSatelliteId,
454        correction: SbasLongTermCorrection,
455        epoch_j2000_s: f64,
456    ) {
457        self.partitions.entry(geo).or_default().long_term.insert(
458            sat,
459            Timed {
460                value: correction,
461                epoch_j2000_s,
462            },
463        );
464    }
465
466    fn fresh(&self, source_j2000_s: f64, t_j2000_s: f64) -> bool {
467        (t_j2000_s - source_j2000_s).abs() <= self.policy.max_staleness_s
468    }
469}
470
471#[derive(Debug, Default)]
472struct GeoPartition {
473    active_iodp: Option<u8>,
474    masks: BTreeMap<u8, Vec<GnssSatelliteId>>,
475    fast: BTreeMap<GnssSatelliteId, Timed<SbasFastCorrection>>,
476    previous_fast: BTreeMap<GnssSatelliteId, SbasFastCorrection>,
477    long_term: BTreeMap<GnssSatelliteId, Timed<SbasLongTermCorrection>>,
478    igp_masks: BTreeMap<(u8, u8), SbasIgpMask>,
479    iono_grid: Option<Timed<SbasIonoGrid>>,
480    geo_nav: Option<Timed<SbasGeoState>>,
481    withdrawn: BTreeSet<GnssSatelliteId>,
482    system_latency_s: f64,
483    disabled_until_j2000_s: Option<f64>,
484    last_update_j2000_s: f64,
485}
486
487impl GeoPartition {
488    fn is_disabled(&self, t_j2000_s: f64) -> bool {
489        self.disabled_until_j2000_s
490            .is_some_and(|until| t_j2000_s <= until)
491    }
492}
493
494#[derive(Debug)]
495struct Timed<T> {
496    value: T,
497    epoch_j2000_s: f64,
498}
499
500fn ingest_fast(
501    partition: &mut GeoPartition,
502    message_type: u8,
503    iodf: u8,
504    iodp: u8,
505    prc: &[i16],
506    udrei: &[u8],
507    epoch_j2000_s: f64,
508) {
509    if Some(iodp) != partition.active_iodp {
510        return;
511    }
512    let start = match message_type {
513        2..=5 => usize::from(message_type - 2) * 13,
514        _ => 0,
515    };
516    for (i, (&prc_raw, &udrei)) in prc.iter().zip(udrei.iter()).enumerate() {
517        let Some(sat) = monitored_sat(partition, iodp, start + i) else {
518            continue;
519        };
520        if udrei >= 14 {
521            partition.withdrawn.insert(sat);
522            continue;
523        }
524        let t_of_j2000_s = epoch_j2000_s + partition.system_latency_s;
525        let prc_m = f64::from(prc_raw) * FAST_PRC_SCALE_M;
526        let rrc_m_s = partition
527            .previous_fast
528            .get(&sat)
529            .filter(|prev| prev.iodf == iodf)
530            .and_then(|prev| {
531                let dt = t_of_j2000_s - prev.t_of_j2000_s;
532                (dt > 0.0).then_some((prc_m - prev.prc_m) / dt)
533            })
534            .unwrap_or(0.0);
535        let correction = SbasFastCorrection {
536            prc_m,
537            rrc_m_s,
538            udrei,
539            t_of_j2000_s,
540            iodf,
541        };
542        partition.previous_fast.insert(sat, correction.clone());
543        partition.fast.insert(
544            sat,
545            Timed {
546                value: correction,
547                epoch_j2000_s: t_of_j2000_s,
548            },
549        );
550        partition.withdrawn.remove(&sat);
551    }
552}
553
554fn ingest_integrity(partition: &mut GeoPartition, integrity: &SbasIntegrity) {
555    let Some(iodp) = partition.active_iodp else {
556        return;
557    };
558    for (idx, &udrei) in integrity.udrei.iter().enumerate() {
559        let Some(sat) = monitored_sat(partition, iodp, idx) else {
560            continue;
561        };
562        let block = idx / 13;
563        let Some(fast) = partition.fast.get_mut(&sat) else {
564            continue;
565        };
566        if integrity.iodf[block] != fast.value.iodf {
567            continue;
568        }
569        fast.value.udrei = udrei;
570        if udrei >= 14 {
571            partition.withdrawn.insert(sat);
572        }
573    }
574}
575
576fn ingest_mixed(
577    partition: &mut GeoPartition,
578    mixed: &SbasMixedCorrections,
579    epoch: GnssWeekTow,
580    epoch_j2000_s: f64,
581) {
582    ingest_fast(
583        partition,
584        2 + mixed.fast.block_id.min(3),
585        mixed.fast.iodf,
586        mixed.fast.iodp,
587        &mixed.fast.prc,
588        &mixed.fast.udrei,
589        epoch_j2000_s,
590    );
591    ingest_long_half(partition, &mixed.long_term, epoch, epoch_j2000_s);
592}
593
594fn ingest_long_half(
595    partition: &mut GeoPartition,
596    half: &SbasLongTermHalf,
597    epoch: GnssWeekTow,
598    epoch_j2000_s: f64,
599) {
600    if Some(half.iodp) != partition.active_iodp {
601        return;
602    }
603    for record in &half.records {
604        let Some(sat) = monitored_sat(
605            partition,
606            half.iodp,
607            usize::from(record.monitored_index.saturating_sub(1)),
608        ) else {
609            continue;
610        };
611        if sat.system != GnssSystem::Gps {
612            continue;
613        }
614        let t0_j2000_s = record
615            .time_of_day_s
616            .map(|tod| lift_time_of_day(epoch, f64::from(tod) * 16.0))
617            .unwrap_or(epoch_j2000_s);
618        let correction = long_record_to_correction(record, t0_j2000_s);
619        partition.long_term.insert(
620            sat,
621            Timed {
622                value: correction,
623                epoch_j2000_s,
624            },
625        );
626    }
627}
628
629fn ingest_iono(partition: &mut GeoPartition, delays: &SbasIonoDelays, epoch_j2000_s: f64) {
630    let Some(mask) = partition.igp_masks.get(&(delays.band_number, delays.iodi)) else {
631        return;
632    };
633    let active_positions: Vec<usize> = mask
634        .mask
635        .iter()
636        .enumerate()
637        .filter_map(|(idx, active)| active.then_some(idx))
638        .collect();
639    let start = usize::from(delays.block_id) * delays.entries.len();
640    let mut igps = partition
641        .iono_grid
642        .as_ref()
643        .filter(|grid| grid.value.iodi == delays.iodi)
644        .map(|grid| grid.value.igps.clone())
645        .unwrap_or_default();
646    for (slot, entry) in delays.entries.iter().enumerate() {
647        if entry.givei == 15 {
648            continue;
649        }
650        let Some(&position) = active_positions.get(start + slot) else {
651            continue;
652        };
653        let Some((lat_deg, lon_deg)) = igp_location(delays.band_number, position) else {
654            continue;
655        };
656        let point = SbasIgp {
657            lat_deg,
658            lon_deg,
659            vertical_delay_m: f64::from(entry.vertical_delay) * IONO_DELAY_SCALE_M,
660            give_variance_m2: give_variance_m2_for_givei(entry.givei),
661        };
662        if let Some(existing) = igps.iter_mut().find(|p| {
663            (p.lat_deg - lat_deg).abs() < SBAS_IGP_COORD_EPS_DEG
664                && (normalize_lon(p.lon_deg) - normalize_lon(lon_deg)).abs()
665                    < SBAS_IGP_COORD_EPS_DEG
666        }) {
667            *existing = point;
668        } else {
669            igps.push(point);
670        }
671    }
672    partition.iono_grid = Some(Timed {
673        value: SbasIonoGrid::new(igps, delays.iodi),
674        epoch_j2000_s,
675    });
676}
677
678fn long_record_to_correction(
679    record: &SbasLongTermRecord,
680    t0_j2000_s: f64,
681) -> SbasLongTermCorrection {
682    SbasLongTermCorrection {
683        iode: record.iode,
684        delta_ecef_m: [
685            f64::from(record.delta_x) * LONG_POS_SCALE_M,
686            f64::from(record.delta_y) * LONG_POS_SCALE_M,
687            f64::from(record.delta_z) * LONG_POS_SCALE_M,
688        ],
689        delta_ecef_rate_m_s: [
690            f64::from(record.delta_x_rate) * LONG_RATE_SCALE_M_S,
691            f64::from(record.delta_y_rate) * LONG_RATE_SCALE_M_S,
692            f64::from(record.delta_z_rate) * LONG_RATE_SCALE_M_S,
693        ],
694        delta_af0_s: f64::from(record.delta_a_f0) * LONG_AF0_SCALE_S,
695        delta_af1_s_s: f64::from(record.delta_a_f1) * LONG_AF1_SCALE_S_S,
696        t0_j2000_s,
697    }
698}
699
700fn geo_state_from_message(message: &SbasGeoNav, epoch: GnssWeekTow) -> SbasGeoState {
701    SbasGeoState {
702        position_ecef_m: [
703            f64::from(message.x_m) * GEO_XY_POS_SCALE_M,
704            f64::from(message.y_m) * GEO_XY_POS_SCALE_M,
705            f64::from(message.z_m) * GEO_Z_POS_SCALE_M,
706        ],
707        velocity_ecef_m_s: [
708            f64::from(message.x_rate_m_s) * GEO_XY_RATE_SCALE_M_S,
709            f64::from(message.y_rate_m_s) * GEO_XY_RATE_SCALE_M_S,
710            f64::from(message.z_rate_m_s) * GEO_Z_RATE_SCALE_M_S,
711        ],
712        acceleration_ecef_m_s2: [
713            f64::from(message.x_accel_m_s2) * GEO_XY_ACCEL_SCALE_M_S2,
714            f64::from(message.y_accel_m_s2) * GEO_XY_ACCEL_SCALE_M_S2,
715            f64::from(message.z_accel_m_s2) * GEO_Z_ACCEL_SCALE_M_S2,
716        ],
717        clock_offset_s: f64::from(message.a_gf0_s) * GEO_AF0_SCALE_S,
718        clock_drift_s_s: f64::from(message.a_gf1_s_s) * GEO_AF1_SCALE_S_S,
719        t0_j2000_s: lift_time_of_day(epoch, f64::from(message.time_of_day_s) * 16.0),
720    }
721}
722
723fn monitored_sat(
724    partition: &GeoPartition,
725    iodp: u8,
726    zero_based_index: usize,
727) -> Option<GnssSatelliteId> {
728    partition.masks.get(&iodp)?.get(zero_based_index).copied()
729}
730
731fn resolve_prn_mask(mask: &[bool; 210]) -> Vec<GnssSatelliteId> {
732    mask.iter()
733        .enumerate()
734        .filter_map(|(idx, active)| active.then(|| mask_position_to_sat(idx)).flatten())
735        .collect()
736}
737
738fn mask_position_to_sat(position: usize) -> Option<GnssSatelliteId> {
739    match position {
740        0..=31 => GnssSatelliteId::new(GnssSystem::Gps, (position + 1) as u8).ok(),
741        37..=63 => GnssSatelliteId::new(GnssSystem::Glonass, (position - 36) as u8).ok(),
742        119..=157 => sbas_prn_to_sat((position + 1) as u16),
743        _ => None,
744    }
745}
746
747pub fn sbas_prn_to_sat(broadcast_prn: u16) -> Option<GnssSatelliteId> {
748    let slot = broadcast_prn.checked_sub(100)?;
749    if (20..=58).contains(&slot) {
750        GnssSatelliteId::new(GnssSystem::Sbas, slot as u8).ok()
751    } else {
752        None
753    }
754}
755
756pub fn sat_to_sbas_prn(sat: GnssSatelliteId) -> Option<u16> {
757    (sat.system == GnssSystem::Sbas).then_some(u16::from(sat.prn) + 100)
758}
759
760fn epoch_to_j2000_s(epoch: GnssWeekTow) -> f64 {
761    f64::from(epoch.week) * crate::constants::SECONDS_PER_WEEK + epoch.tow_s - GPS_EPOCH_TO_J2000_S
762}
763
764fn lift_time_of_day(epoch: GnssWeekTow, time_of_day_s: f64) -> f64 {
765    let continuous = f64::from(epoch.week) * crate::constants::SECONDS_PER_WEEK + epoch.tow_s;
766    let day_start = (continuous / SECONDS_PER_DAY).floor() * SECONDS_PER_DAY;
767    let candidates = [
768        day_start - SECONDS_PER_DAY + time_of_day_s,
769        day_start + time_of_day_s,
770        day_start + SECONDS_PER_DAY + time_of_day_s,
771    ];
772    let best = candidates
773        .into_iter()
774        .min_by(|a, b| f64_total_cmp(&(a - continuous).abs(), &(b - continuous).abs()))
775        .unwrap_or(day_start + time_of_day_s);
776    best - GPS_EPOCH_TO_J2000_S
777}
778
779#[derive(Clone, Copy)]
780struct IgpBandSegment {
781    fixed_deg: i16,
782    variable_deg: &'static [i16],
783    first_bit: usize,
784    last_bit: usize,
785}
786
787const IGP_X1: [i16; 28] = [
788    -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
789    40, 45, 50, 55, 65, 75, 85,
790];
791const IGP_X2: [i16; 23] = [
792    -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
793    55,
794];
795const IGP_X3: [i16; 27] = [
796    -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
797    40, 45, 50, 55, 65, 75,
798];
799const IGP_X4: [i16; 28] = [
800    -85, -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30,
801    35, 40, 45, 50, 55, 65, 75,
802];
803const IGP_X5: [i16; 72] = [
804    -180, -175, -170, -165, -160, -155, -150, -145, -140, -135, -130, -125, -120, -115, -110, -105,
805    -100, -95, -90, -85, -80, -75, -70, -65, -60, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10,
806    -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 105,
807    110, 115, 120, 125, 130, 135, 140, 145, 150, 155, 160, 165, 170, 175,
808];
809const IGP_X6: [i16; 36] = [
810    -180, -170, -160, -150, -140, -130, -120, -110, -100, -90, -80, -70, -60, -50, -40, -30, -20,
811    -10, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170,
812];
813const IGP_X7: [i16; 12] = [-180, -150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
814const IGP_X8: [i16; 12] = [-170, -140, -110, -80, -50, -20, 10, 40, 70, 100, 130, 160];
815
816const IGP_BANDS_LOW: [[IgpBandSegment; 8]; 9] = [
817    [
818        IgpBandSegment {
819            fixed_deg: -180,
820            variable_deg: &IGP_X1,
821            first_bit: 1,
822            last_bit: 28,
823        },
824        IgpBandSegment {
825            fixed_deg: -175,
826            variable_deg: &IGP_X2,
827            first_bit: 29,
828            last_bit: 51,
829        },
830        IgpBandSegment {
831            fixed_deg: -170,
832            variable_deg: &IGP_X3,
833            first_bit: 52,
834            last_bit: 78,
835        },
836        IgpBandSegment {
837            fixed_deg: -165,
838            variable_deg: &IGP_X2,
839            first_bit: 79,
840            last_bit: 101,
841        },
842        IgpBandSegment {
843            fixed_deg: -160,
844            variable_deg: &IGP_X3,
845            first_bit: 102,
846            last_bit: 128,
847        },
848        IgpBandSegment {
849            fixed_deg: -155,
850            variable_deg: &IGP_X2,
851            first_bit: 129,
852            last_bit: 151,
853        },
854        IgpBandSegment {
855            fixed_deg: -150,
856            variable_deg: &IGP_X3,
857            first_bit: 152,
858            last_bit: 178,
859        },
860        IgpBandSegment {
861            fixed_deg: -145,
862            variable_deg: &IGP_X2,
863            first_bit: 179,
864            last_bit: 201,
865        },
866    ],
867    [
868        IgpBandSegment {
869            fixed_deg: -140,
870            variable_deg: &IGP_X4,
871            first_bit: 1,
872            last_bit: 28,
873        },
874        IgpBandSegment {
875            fixed_deg: -135,
876            variable_deg: &IGP_X2,
877            first_bit: 29,
878            last_bit: 51,
879        },
880        IgpBandSegment {
881            fixed_deg: -130,
882            variable_deg: &IGP_X3,
883            first_bit: 52,
884            last_bit: 78,
885        },
886        IgpBandSegment {
887            fixed_deg: -125,
888            variable_deg: &IGP_X2,
889            first_bit: 79,
890            last_bit: 101,
891        },
892        IgpBandSegment {
893            fixed_deg: -120,
894            variable_deg: &IGP_X3,
895            first_bit: 102,
896            last_bit: 128,
897        },
898        IgpBandSegment {
899            fixed_deg: -115,
900            variable_deg: &IGP_X2,
901            first_bit: 129,
902            last_bit: 151,
903        },
904        IgpBandSegment {
905            fixed_deg: -110,
906            variable_deg: &IGP_X3,
907            first_bit: 152,
908            last_bit: 178,
909        },
910        IgpBandSegment {
911            fixed_deg: -105,
912            variable_deg: &IGP_X2,
913            first_bit: 179,
914            last_bit: 201,
915        },
916    ],
917    [
918        IgpBandSegment {
919            fixed_deg: -100,
920            variable_deg: &IGP_X3,
921            first_bit: 1,
922            last_bit: 27,
923        },
924        IgpBandSegment {
925            fixed_deg: -95,
926            variable_deg: &IGP_X2,
927            first_bit: 28,
928            last_bit: 50,
929        },
930        IgpBandSegment {
931            fixed_deg: -90,
932            variable_deg: &IGP_X1,
933            first_bit: 51,
934            last_bit: 78,
935        },
936        IgpBandSegment {
937            fixed_deg: -85,
938            variable_deg: &IGP_X2,
939            first_bit: 79,
940            last_bit: 101,
941        },
942        IgpBandSegment {
943            fixed_deg: -80,
944            variable_deg: &IGP_X3,
945            first_bit: 102,
946            last_bit: 128,
947        },
948        IgpBandSegment {
949            fixed_deg: -75,
950            variable_deg: &IGP_X2,
951            first_bit: 129,
952            last_bit: 151,
953        },
954        IgpBandSegment {
955            fixed_deg: -70,
956            variable_deg: &IGP_X3,
957            first_bit: 152,
958            last_bit: 178,
959        },
960        IgpBandSegment {
961            fixed_deg: -65,
962            variable_deg: &IGP_X2,
963            first_bit: 179,
964            last_bit: 201,
965        },
966    ],
967    [
968        IgpBandSegment {
969            fixed_deg: -60,
970            variable_deg: &IGP_X3,
971            first_bit: 1,
972            last_bit: 27,
973        },
974        IgpBandSegment {
975            fixed_deg: -55,
976            variable_deg: &IGP_X2,
977            first_bit: 28,
978            last_bit: 50,
979        },
980        IgpBandSegment {
981            fixed_deg: -50,
982            variable_deg: &IGP_X4,
983            first_bit: 51,
984            last_bit: 78,
985        },
986        IgpBandSegment {
987            fixed_deg: -45,
988            variable_deg: &IGP_X2,
989            first_bit: 79,
990            last_bit: 101,
991        },
992        IgpBandSegment {
993            fixed_deg: -40,
994            variable_deg: &IGP_X3,
995            first_bit: 102,
996            last_bit: 128,
997        },
998        IgpBandSegment {
999            fixed_deg: -35,
1000            variable_deg: &IGP_X2,
1001            first_bit: 129,
1002            last_bit: 151,
1003        },
1004        IgpBandSegment {
1005            fixed_deg: -30,
1006            variable_deg: &IGP_X3,
1007            first_bit: 152,
1008            last_bit: 178,
1009        },
1010        IgpBandSegment {
1011            fixed_deg: -25,
1012            variable_deg: &IGP_X2,
1013            first_bit: 179,
1014            last_bit: 201,
1015        },
1016    ],
1017    [
1018        IgpBandSegment {
1019            fixed_deg: -20,
1020            variable_deg: &IGP_X3,
1021            first_bit: 1,
1022            last_bit: 27,
1023        },
1024        IgpBandSegment {
1025            fixed_deg: -15,
1026            variable_deg: &IGP_X2,
1027            first_bit: 28,
1028            last_bit: 50,
1029        },
1030        IgpBandSegment {
1031            fixed_deg: -10,
1032            variable_deg: &IGP_X3,
1033            first_bit: 51,
1034            last_bit: 77,
1035        },
1036        IgpBandSegment {
1037            fixed_deg: -5,
1038            variable_deg: &IGP_X2,
1039            first_bit: 78,
1040            last_bit: 100,
1041        },
1042        IgpBandSegment {
1043            fixed_deg: 0,
1044            variable_deg: &IGP_X1,
1045            first_bit: 101,
1046            last_bit: 128,
1047        },
1048        IgpBandSegment {
1049            fixed_deg: 5,
1050            variable_deg: &IGP_X2,
1051            first_bit: 129,
1052            last_bit: 151,
1053        },
1054        IgpBandSegment {
1055            fixed_deg: 10,
1056            variable_deg: &IGP_X3,
1057            first_bit: 152,
1058            last_bit: 178,
1059        },
1060        IgpBandSegment {
1061            fixed_deg: 15,
1062            variable_deg: &IGP_X2,
1063            first_bit: 179,
1064            last_bit: 201,
1065        },
1066    ],
1067    [
1068        IgpBandSegment {
1069            fixed_deg: 20,
1070            variable_deg: &IGP_X3,
1071            first_bit: 1,
1072            last_bit: 27,
1073        },
1074        IgpBandSegment {
1075            fixed_deg: 25,
1076            variable_deg: &IGP_X2,
1077            first_bit: 28,
1078            last_bit: 50,
1079        },
1080        IgpBandSegment {
1081            fixed_deg: 30,
1082            variable_deg: &IGP_X3,
1083            first_bit: 51,
1084            last_bit: 77,
1085        },
1086        IgpBandSegment {
1087            fixed_deg: 35,
1088            variable_deg: &IGP_X2,
1089            first_bit: 78,
1090            last_bit: 100,
1091        },
1092        IgpBandSegment {
1093            fixed_deg: 40,
1094            variable_deg: &IGP_X4,
1095            first_bit: 101,
1096            last_bit: 128,
1097        },
1098        IgpBandSegment {
1099            fixed_deg: 45,
1100            variable_deg: &IGP_X2,
1101            first_bit: 129,
1102            last_bit: 151,
1103        },
1104        IgpBandSegment {
1105            fixed_deg: 50,
1106            variable_deg: &IGP_X3,
1107            first_bit: 152,
1108            last_bit: 178,
1109        },
1110        IgpBandSegment {
1111            fixed_deg: 55,
1112            variable_deg: &IGP_X2,
1113            first_bit: 179,
1114            last_bit: 201,
1115        },
1116    ],
1117    [
1118        IgpBandSegment {
1119            fixed_deg: 60,
1120            variable_deg: &IGP_X3,
1121            first_bit: 1,
1122            last_bit: 27,
1123        },
1124        IgpBandSegment {
1125            fixed_deg: 65,
1126            variable_deg: &IGP_X2,
1127            first_bit: 28,
1128            last_bit: 50,
1129        },
1130        IgpBandSegment {
1131            fixed_deg: 70,
1132            variable_deg: &IGP_X3,
1133            first_bit: 51,
1134            last_bit: 77,
1135        },
1136        IgpBandSegment {
1137            fixed_deg: 75,
1138            variable_deg: &IGP_X2,
1139            first_bit: 78,
1140            last_bit: 100,
1141        },
1142        IgpBandSegment {
1143            fixed_deg: 80,
1144            variable_deg: &IGP_X3,
1145            first_bit: 101,
1146            last_bit: 127,
1147        },
1148        IgpBandSegment {
1149            fixed_deg: 85,
1150            variable_deg: &IGP_X2,
1151            first_bit: 128,
1152            last_bit: 150,
1153        },
1154        IgpBandSegment {
1155            fixed_deg: 90,
1156            variable_deg: &IGP_X1,
1157            first_bit: 151,
1158            last_bit: 178,
1159        },
1160        IgpBandSegment {
1161            fixed_deg: 95,
1162            variable_deg: &IGP_X2,
1163            first_bit: 179,
1164            last_bit: 201,
1165        },
1166    ],
1167    [
1168        IgpBandSegment {
1169            fixed_deg: 100,
1170            variable_deg: &IGP_X3,
1171            first_bit: 1,
1172            last_bit: 27,
1173        },
1174        IgpBandSegment {
1175            fixed_deg: 105,
1176            variable_deg: &IGP_X2,
1177            first_bit: 28,
1178            last_bit: 50,
1179        },
1180        IgpBandSegment {
1181            fixed_deg: 110,
1182            variable_deg: &IGP_X3,
1183            first_bit: 51,
1184            last_bit: 77,
1185        },
1186        IgpBandSegment {
1187            fixed_deg: 115,
1188            variable_deg: &IGP_X2,
1189            first_bit: 78,
1190            last_bit: 100,
1191        },
1192        IgpBandSegment {
1193            fixed_deg: 120,
1194            variable_deg: &IGP_X3,
1195            first_bit: 101,
1196            last_bit: 127,
1197        },
1198        IgpBandSegment {
1199            fixed_deg: 125,
1200            variable_deg: &IGP_X2,
1201            first_bit: 128,
1202            last_bit: 150,
1203        },
1204        IgpBandSegment {
1205            fixed_deg: 130,
1206            variable_deg: &IGP_X4,
1207            first_bit: 151,
1208            last_bit: 178,
1209        },
1210        IgpBandSegment {
1211            fixed_deg: 135,
1212            variable_deg: &IGP_X2,
1213            first_bit: 179,
1214            last_bit: 201,
1215        },
1216    ],
1217    [
1218        IgpBandSegment {
1219            fixed_deg: 140,
1220            variable_deg: &IGP_X3,
1221            first_bit: 1,
1222            last_bit: 27,
1223        },
1224        IgpBandSegment {
1225            fixed_deg: 145,
1226            variable_deg: &IGP_X2,
1227            first_bit: 28,
1228            last_bit: 50,
1229        },
1230        IgpBandSegment {
1231            fixed_deg: 150,
1232            variable_deg: &IGP_X3,
1233            first_bit: 51,
1234            last_bit: 77,
1235        },
1236        IgpBandSegment {
1237            fixed_deg: 155,
1238            variable_deg: &IGP_X2,
1239            first_bit: 78,
1240            last_bit: 100,
1241        },
1242        IgpBandSegment {
1243            fixed_deg: 160,
1244            variable_deg: &IGP_X3,
1245            first_bit: 101,
1246            last_bit: 127,
1247        },
1248        IgpBandSegment {
1249            fixed_deg: 165,
1250            variable_deg: &IGP_X2,
1251            first_bit: 128,
1252            last_bit: 150,
1253        },
1254        IgpBandSegment {
1255            fixed_deg: 170,
1256            variable_deg: &IGP_X3,
1257            first_bit: 151,
1258            last_bit: 177,
1259        },
1260        IgpBandSegment {
1261            fixed_deg: 175,
1262            variable_deg: &IGP_X2,
1263            first_bit: 178,
1264            last_bit: 200,
1265        },
1266    ],
1267];
1268
1269const IGP_BANDS_POLAR: [[IgpBandSegment; 5]; 2] = [
1270    [
1271        IgpBandSegment {
1272            fixed_deg: 60,
1273            variable_deg: &IGP_X5,
1274            first_bit: 1,
1275            last_bit: 72,
1276        },
1277        IgpBandSegment {
1278            fixed_deg: 65,
1279            variable_deg: &IGP_X6,
1280            first_bit: 73,
1281            last_bit: 108,
1282        },
1283        IgpBandSegment {
1284            fixed_deg: 70,
1285            variable_deg: &IGP_X6,
1286            first_bit: 109,
1287            last_bit: 144,
1288        },
1289        IgpBandSegment {
1290            fixed_deg: 75,
1291            variable_deg: &IGP_X6,
1292            first_bit: 145,
1293            last_bit: 180,
1294        },
1295        IgpBandSegment {
1296            fixed_deg: 85,
1297            variable_deg: &IGP_X7,
1298            first_bit: 181,
1299            last_bit: 192,
1300        },
1301    ],
1302    [
1303        IgpBandSegment {
1304            fixed_deg: -60,
1305            variable_deg: &IGP_X5,
1306            first_bit: 1,
1307            last_bit: 72,
1308        },
1309        IgpBandSegment {
1310            fixed_deg: -65,
1311            variable_deg: &IGP_X6,
1312            first_bit: 73,
1313            last_bit: 108,
1314        },
1315        IgpBandSegment {
1316            fixed_deg: -70,
1317            variable_deg: &IGP_X6,
1318            first_bit: 109,
1319            last_bit: 144,
1320        },
1321        IgpBandSegment {
1322            fixed_deg: -75,
1323            variable_deg: &IGP_X6,
1324            first_bit: 145,
1325            last_bit: 180,
1326        },
1327        IgpBandSegment {
1328            fixed_deg: -85,
1329            variable_deg: &IGP_X8,
1330            first_bit: 181,
1331            last_bit: 192,
1332        },
1333    ],
1334];
1335
1336fn igp_location(band_number: u8, position: usize) -> Option<(f64, f64)> {
1337    let one_based = position + 1;
1338    if band_number <= 8 {
1339        let segment = IGP_BANDS_LOW[usize::from(band_number)]
1340            .iter()
1341            .find(|segment| (segment.first_bit..=segment.last_bit).contains(&one_based))?;
1342        let lat = segment.variable_deg[one_based - segment.first_bit];
1343        return Some((f64::from(lat), f64::from(segment.fixed_deg)));
1344    }
1345    if (9..=10).contains(&band_number) {
1346        let segment = IGP_BANDS_POLAR[usize::from(band_number - 9)]
1347            .iter()
1348            .find(|segment| (segment.first_bit..=segment.last_bit).contains(&one_based))?;
1349        let lon = segment.variable_deg[one_based - segment.first_bit];
1350        return Some((f64::from(segment.fixed_deg), f64::from(lon)));
1351    }
1352    None
1353}
1354
1355fn bracket_pair(values: &[f64], value: f64) -> Option<(f64, f64)> {
1356    if values.len() < 2 {
1357        return None;
1358    }
1359    values.windows(2).find_map(|pair| {
1360        let lo = pair[0];
1361        let hi = pair[1];
1362        (value >= lo && value <= hi).then_some((lo, hi))
1363    })
1364}
1365
1366fn active_point(points: &[SbasIgp], lat_deg: f64, lon_deg: f64) -> Option<&SbasIgp> {
1367    points.iter().find(|p| {
1368        (p.lat_deg - lat_deg).abs() < SBAS_IGP_COORD_EPS_DEG
1369            && (normalize_lon(p.lon_deg) - lon_deg).abs() < SBAS_IGP_COORD_EPS_DEG
1370    })
1371}
1372
1373#[derive(Clone, Copy, Debug, PartialEq)]
1374struct IgpInterpolation {
1375    vertical_delay_m: f64,
1376    give_variance_m2: Option<f64>,
1377}
1378
1379fn bilinear_give_variance_m2(
1380    points: &[SbasIgp],
1381    lat0: f64,
1382    lat1: f64,
1383    lon0: f64,
1384    lon1: f64,
1385) -> Option<(f64, f64, f64, f64)> {
1386    let v00 = active_point(points, lat0, lon0)?.give_variance_m2?;
1387    let v01 = active_point(points, lat0, lon1)?.give_variance_m2?;
1388    let v10 = active_point(points, lat1, lon0)?.give_variance_m2?;
1389    let v11 = active_point(points, lat1, lon1)?.give_variance_m2?;
1390    Some((v00, v01, v10, v11))
1391}
1392
1393fn plane_interpolate(points: &[SbasIgp], lat_deg: f64, lon_deg: f64) -> Option<f64> {
1394    plane_interpolate_value(points, lat_deg, lon_deg, |point| {
1395        Some(point.vertical_delay_m)
1396    })
1397}
1398
1399fn plane_interpolate_give_variance(points: &[SbasIgp], lat_deg: f64, lon_deg: f64) -> Option<f64> {
1400    let variance_m2 =
1401        plane_interpolate_value(points, lat_deg, lon_deg, |point| point.give_variance_m2)?;
1402    (variance_m2.is_finite() && variance_m2 >= 0.0).then_some(variance_m2)
1403}
1404
1405fn plane_interpolate_value(
1406    points: &[SbasIgp],
1407    lat_deg: f64,
1408    lon_deg: f64,
1409    value: impl Fn(&SbasIgp) -> Option<f64>,
1410) -> Option<f64> {
1411    let [p0, p1, p2] = points else {
1412        return None;
1413    };
1414    let x0 = p0.lon_deg;
1415    let y0 = p0.lat_deg;
1416    let z0 = value(p0)?;
1417    let x1 = p1.lon_deg;
1418    let y1 = p1.lat_deg;
1419    let z1 = value(p1)?;
1420    let x2 = p2.lon_deg;
1421    let y2 = p2.lat_deg;
1422    let z2 = value(p2)?;
1423    let det = x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1);
1424    if det.abs() < 1.0e-12 {
1425        return None;
1426    }
1427    let a = (z0 * (y1 - y2) + z1 * (y2 - y0) + z2 * (y0 - y1)) / det;
1428    let b = (x0 * (z1 - z2) + x1 * (z2 - z0) + x2 * (z0 - z1)) / det;
1429    let c = (x0 * (y1 * z2 - y2 * z1) + x1 * (y2 * z0 - y0 * z2) + x2 * (y0 * z1 - y1 * z0)) / det;
1430    Some(a * lon_deg + b * lat_deg + c)
1431}
1432
1433fn normalize_lon(mut lon_deg: f64) -> f64 {
1434    while lon_deg < -180.0 {
1435        lon_deg += 360.0;
1436    }
1437    while lon_deg > 180.0 {
1438        lon_deg -= 360.0;
1439    }
1440    lon_deg
1441}
1442
1443fn f64_total_cmp(a: &f64, b: &f64) -> std::cmp::Ordering {
1444    a.total_cmp(b)
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450    use crate::astro::time::model::TimeScale;
1451    use crate::sbas::message::{
1452        SbasFastCorrections, SbasIgpDelay, SbasIgpMask, SbasIonoDelays, SbasPrnMask, SpareBits,
1453    };
1454
1455    fn epoch(tow_s: f64) -> GnssWeekTow {
1456        GnssWeekTow::new(TimeScale::Gpst, 2400, tow_s).expect("valid epoch")
1457    }
1458
1459    fn geo() -> GnssSatelliteId {
1460        sbas_prn_to_sat(120).expect("valid SBAS PRN")
1461    }
1462
1463    fn gps(prn: u8) -> GnssSatelliteId {
1464        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN")
1465    }
1466
1467    fn mask_message() -> SbasMessage {
1468        let mut mask = [false; 210];
1469        mask[0] = true;
1470        mask[1] = true;
1471        SbasMessage::PrnMask(SbasPrnMask {
1472            preamble: 0x53,
1473            iodp: 1,
1474            mask,
1475            reserved: SpareBits::new(),
1476        })
1477    }
1478
1479    #[test]
1480    fn sbas_prn_helpers_map_broadcast_to_slot_form() {
1481        assert_eq!(sbas_prn_to_sat(120), Some(geo()));
1482        assert_eq!(sat_to_sbas_prn(geo()), Some(120));
1483        assert_eq!(sbas_prn_to_sat(119), None);
1484    }
1485
1486    #[test]
1487    fn fast_corrections_scale_and_derive_rrc_for_same_issue() {
1488        let mut store = SbasCorrectionStore::new();
1489        store.ingest(&mask_message(), geo(), epoch(10.0)).unwrap();
1490        let mut prc = [0i16; 13];
1491        let udrei = [0u8; 13];
1492        prc[0] = 8;
1493        let first = SbasMessage::FastCorrections(SbasFastCorrections {
1494            preamble: 0x53,
1495            message_type: 2,
1496            iodf: 1,
1497            iodp: 1,
1498            prc,
1499            udrei,
1500            reserved: SpareBits::new(),
1501        });
1502        store.ingest(&first, geo(), epoch(20.0)).unwrap();
1503        prc[0] = 16;
1504        let second = SbasMessage::FastCorrections(SbasFastCorrections {
1505            preamble: 0x9A,
1506            message_type: 2,
1507            iodf: 1,
1508            iodp: 1,
1509            prc,
1510            udrei,
1511            reserved: SpareBits::new(),
1512        });
1513        store.ingest(&second, geo(), epoch(28.0)).unwrap();
1514        let fast = store.fast(geo(), gps(1)).expect("fast correction");
1515        assert_eq!(fast.prc_m.to_bits(), 2.0_f64.to_bits());
1516        assert!((fast.rrc_m_s - 0.125).abs() < 1.0e-12);
1517    }
1518
1519    #[test]
1520    fn udrei_withdraws_satellite() {
1521        let mut store = SbasCorrectionStore::new();
1522        store.ingest(&mask_message(), geo(), epoch(10.0)).unwrap();
1523        let prc = [0i16; 13];
1524        let mut udrei = [0u8; 13];
1525        udrei[0] = 15;
1526        let msg = SbasMessage::FastCorrections(SbasFastCorrections {
1527            preamble: 0x53,
1528            message_type: 2,
1529            iodf: 1,
1530            iodp: 1,
1531            prc,
1532            udrei,
1533            reserved: SpareBits::new(),
1534        });
1535        store.ingest(&msg, geo(), epoch(20.0)).unwrap();
1536        assert!(store.is_withdrawn(geo(), gps(1)));
1537    }
1538
1539    #[test]
1540    fn igp_givei_15_is_not_added_to_grid() {
1541        let mut store = SbasCorrectionStore::new();
1542        let mut mask = [false; 201];
1543        mask[0] = true;
1544        mask[1] = true;
1545        let igp_mask = SbasMessage::IgpMask(SbasIgpMask {
1546            preamble: 0x53,
1547            band_number: 0,
1548            iodi: 2,
1549            mask,
1550            reserved: SpareBits::new(),
1551        });
1552        store.ingest(&igp_mask, geo(), epoch(0.0)).unwrap();
1553        let mut entries: [SbasIgpDelay; 15] = core::array::from_fn(|_| SbasIgpDelay::default());
1554        entries[0] = SbasIgpDelay {
1555            vertical_delay: 8,
1556            givei: 0,
1557        };
1558        entries[1] = SbasIgpDelay {
1559            vertical_delay: 16,
1560            givei: 15,
1561        };
1562        let delays = SbasMessage::IonoDelays(SbasIonoDelays {
1563            preamble: 0x53,
1564            band_number: 0,
1565            block_id: 0,
1566            iodi: 2,
1567            entries,
1568            reserved: SpareBits::new(),
1569        });
1570        store.ingest(&delays, geo(), epoch(1.0)).unwrap();
1571        assert_eq!(store.iono_grid(geo()).unwrap().igps().len(), 1);
1572    }
1573
1574    #[test]
1575    fn igp_locations_match_do229_fixture() {
1576        #[derive(serde::Deserialize)]
1577        struct Fixture {
1578            band: u8,
1579            position: usize,
1580            lat_deg: Option<f64>,
1581            lon_deg: Option<f64>,
1582        }
1583
1584        let fixtures: Vec<Fixture> =
1585            serde_json::from_str(include_str!("../../tests/fixtures/sbas_igp_locations.json"))
1586                .expect("valid IGP fixture");
1587        for fixture in fixtures {
1588            let got = igp_location(fixture.band, fixture.position);
1589            match (fixture.lat_deg, fixture.lon_deg) {
1590                (Some(lat), Some(lon)) => {
1591                    assert_eq!(got, Some((lat, lon)));
1592                }
1593                (None, None) => {
1594                    assert_eq!(got, None);
1595                }
1596                _ => panic!("fixture must contain both coordinates or neither"),
1597            }
1598        }
1599    }
1600
1601    #[test]
1602    fn iono_grid_interpolates_four_points() {
1603        let grid = SbasIonoGrid::new(
1604            vec![
1605                SbasIgp {
1606                    lat_deg: 0.0,
1607                    lon_deg: 0.0,
1608                    vertical_delay_m: 1.0,
1609                    give_variance_m2: None,
1610                },
1611                SbasIgp {
1612                    lat_deg: 0.0,
1613                    lon_deg: 5.0,
1614                    vertical_delay_m: 2.0,
1615                    give_variance_m2: None,
1616                },
1617                SbasIgp {
1618                    lat_deg: 5.0,
1619                    lon_deg: 0.0,
1620                    vertical_delay_m: 3.0,
1621                    give_variance_m2: None,
1622                },
1623                SbasIgp {
1624                    lat_deg: 5.0,
1625                    lon_deg: 5.0,
1626                    vertical_delay_m: 4.0,
1627                    give_variance_m2: None,
1628                },
1629            ],
1630            0,
1631        );
1632        let vertical = grid.vertical_delay_at_ipp(2.5, 2.5).unwrap();
1633        assert_eq!(vertical.to_bits(), 2.5_f64.to_bits());
1634    }
1635}