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
34pub 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
40pub 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
46pub fn udre_variance_m2_for_udrei(udrei: u8) -> Option<f64> {
48 SBAS_UDRE_VARIANCE_M2.get(usize::from(udrei)).copied()
49}
50
51pub 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 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 pub fn new(igps: Vec<SbasIgp>, iodi: u8) -> Self {
99 Self { igps, iodi }
100 }
101
102 pub fn igps(&self) -> &[SbasIgp] {
104 &self.igps
105 }
106
107 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 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}