Skip to main content

sidereon_core/bias/
mod.rs

1//! Offline GNSS code and phase bias products.
2//!
3//! The parsers are sans-I/O: callers pass bytes, and the returned bias set
4//! carries typed diagnostics for records that were skipped during forgiving
5//! parsing.
6
7use std::cmp::Ordering;
8use std::collections::{BTreeMap, VecDeque};
9use std::fmt::Write as _;
10use std::str::FromStr;
11
12use crate::astro::constants::time::SECONDS_PER_DAY_I64;
13use crate::astro::time::civil::{day_of_year_int, seconds_between_splits, split_julian_date};
14use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
15use crate::astro::time::scales::julian_day_number;
16use crate::constants::{C_M_S, NS_TO_S, SECONDS_PER_DAY};
17use crate::format::columns::{field, fortran_f64, raw_field};
18pub use crate::format::{Diagnostics, Parsed, RecordRef, Skip, SkipReason, Warning, WarningKind};
19pub use crate::validate::FieldError;
20use crate::validate::{self, CivilSecondPolicy};
21use crate::{frequencies, GnssSatelliteId, GnssSystem};
22
23const BIAS_SINEX_MAJOR_VERSION: &str = "1";
24pub const SINEX_BIAS_SLOPE_DENOMINATOR_S: f64 = 1.0;
25const DSB_INCONSISTENCY_TOL_S: f64 = 1.0e-15;
26const RINEX_VERSION_FOR_BIAS_CODES: f64 = 3.04;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BiasKind {
30    Osb,
31    Dsb,
32    Isb,
33}
34
35impl BiasKind {
36    fn label(self) -> &'static str {
37        match self {
38            Self::Osb => "OSB",
39            Self::Dsb => "DSB",
40            Self::Isb => "ISB",
41        }
42    }
43}
44
45impl FromStr for BiasKind {
46    type Err = ();
47
48    fn from_str(value: &str) -> Result<Self, Self::Err> {
49        match value.trim() {
50            "OSB" => Ok(Self::Osb),
51            "DSB" => Ok(Self::Dsb),
52            "ISB" => Ok(Self::Isb),
53            _ => Err(()),
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum BiasTarget {
60    System(GnssSystem),
61    Satellite(GnssSatelliteId),
62    Receiver {
63        system: GnssSystem,
64        station: String,
65    },
66    SatelliteReceiver {
67        sat: GnssSatelliteId,
68        station: String,
69    },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
73pub struct BiasTargetKey {
74    pub system: GnssSystem,
75    pub sat: Option<GnssSatelliteId>,
76    pub station: Option<String>,
77}
78
79impl BiasTargetKey {
80    pub fn system(system: GnssSystem) -> Self {
81        Self {
82            system,
83            sat: None,
84            station: None,
85        }
86    }
87
88    pub fn satellite(sat: GnssSatelliteId) -> Self {
89        Self {
90            system: sat.system,
91            sat: Some(sat),
92            station: None,
93        }
94    }
95
96    pub fn receiver(system: GnssSystem, station: &str) -> Self {
97        Self {
98            system,
99            sat: None,
100            station: Some(normalize_station(station)),
101        }
102    }
103
104    pub fn satellite_receiver(sat: GnssSatelliteId, station: &str) -> Self {
105        Self {
106            system: sat.system,
107            sat: Some(sat),
108            station: Some(normalize_station(station)),
109        }
110    }
111}
112
113impl From<&BiasTarget> for BiasTargetKey {
114    fn from(value: &BiasTarget) -> Self {
115        match value {
116            BiasTarget::System(system) => Self::system(*system),
117            BiasTarget::Satellite(sat) => Self::satellite(*sat),
118            BiasTarget::Receiver { system, station } => Self::receiver(*system, station),
119            BiasTarget::SatelliteReceiver { sat, station } => {
120                Self::satellite_receiver(*sat, station)
121            }
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
127pub struct BiasEpoch {
128    pub year: i32,
129    pub day_of_year: u16,
130    pub second_of_day: u32,
131}
132
133impl BiasEpoch {
134    pub fn new(year: i32, day_of_year: u16, second_of_day: u32) -> Result<Self, BiasError> {
135        if !(1..=days_in_year(year)).contains(&i32::from(day_of_year)) {
136            return Err(BiasError::InvalidEpoch);
137        }
138        if second_of_day > SECONDS_PER_DAY_I64 as u32 {
139            return Err(BiasError::InvalidEpoch);
140        }
141        Ok(Self {
142            year,
143            day_of_year,
144            second_of_day,
145        })
146    }
147
148    pub fn parse_sinex(token: &str) -> Result<Option<Self>, BiasError> {
149        let token = token.trim();
150        if token.is_empty() || token == "0000:000:00000" {
151            return Ok(None);
152        }
153        let mut parts = token.split(':');
154        let year = parse_int::<i32>(parts.next(), "bias epoch year")?;
155        let doy = parse_int::<u16>(parts.next(), "bias epoch day")?;
156        let sod = parse_int::<u32>(parts.next(), "bias epoch second")?;
157        if parts.next().is_some() {
158            return Err(BiasError::InvalidEpoch);
159        }
160        Self::new(year, doy, sod).map(Some)
161    }
162
163    pub fn format_sinex(self) -> String {
164        format!(
165            "{:04}:{:03}:{:05}",
166            self.year, self.day_of_year, self.second_of_day
167        )
168    }
169
170    fn to_split(self) -> Result<JulianDateSplit, BiasError> {
171        let jdn = julian_day_number(self.year, 1, 1) + i64::from(self.day_of_year) - 1;
172        let (year, month, day) = crate::astro::time::civil::civil_from_julian_day_number(jdn);
173        let (jd_whole, fraction) = split_julian_date(
174            year as i32,
175            month as i32,
176            day as i32,
177            0,
178            0,
179            f64::from(self.second_of_day),
180        );
181        JulianDateSplit::new(jd_whole, fraction).map_err(|_| BiasError::InvalidEpoch)
182    }
183
184    fn next_midnight(self) -> Result<Self, BiasError> {
185        let jdn = julian_day_number(self.year, 1, 1) + i64::from(self.day_of_year);
186        let (year, _month, _day) = crate::astro::time::civil::civil_from_julian_day_number(jdn);
187        let doy = day_of_year_int(year as i32, 1, 1);
188        let base = julian_day_number(year as i32, 1, 1);
189        let day = (jdn - base + doy) as u16;
190        Self::new(year as i32, day, 0)
191    }
192
193    fn normalize_end(self) -> Result<Self, BiasError> {
194        if self.second_of_day == 86_399 {
195            self.next_midnight()
196        } else {
197            Ok(self)
198        }
199    }
200}
201
202#[derive(Debug, Clone, PartialEq)]
203pub struct BiasRecord {
204    pub kind: BiasKind,
205    pub target: BiasTarget,
206    pub svn: Option<String>,
207    pub obs1: String,
208    pub obs2: Option<String>,
209    pub valid_from: Option<BiasEpoch>,
210    pub valid_until: Option<BiasEpoch>,
211    pub raw_epochs: (String, String),
212    pub value: f64,
213    pub sigma: Option<f64>,
214    pub slope: Option<f64>,
215    pub slope_sigma: Option<f64>,
216    pub is_phase: bool,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220pub enum BiasMode {
221    Absolute,
222    Relative,
223    #[default]
224    Unspecified,
225}
226
227#[derive(Debug, Clone, PartialEq, Default)]
228pub struct ClockReferenceObservables {
229    pub per_system: BTreeMap<GnssSystem, (String, String)>,
230}
231
232#[derive(Debug, Clone, PartialEq, Default)]
233pub struct BiasSetHeader {
234    pub agency: Option<String>,
235    pub file_reference: Vec<(String, String)>,
236    pub description: BTreeMap<String, String>,
237    pub declared_bias_count: Option<usize>,
238    pub dcb_meta: Option<CodeDcbOptions>,
239}
240
241#[derive(Debug, Clone, PartialEq)]
242pub struct BiasSet {
243    records: Vec<BiasRecord>,
244    index: BTreeMap<(BiasTargetKey, String), Vec<usize>>,
245    pub mode: BiasMode,
246    pub time_scale: TimeScale,
247    pub clock_reference: ClockReferenceObservables,
248    pub header: BiasSetHeader,
249    diagnostics: Diagnostics,
250    skipped_records: usize,
251}
252
253#[derive(Debug, Clone, PartialEq)]
254pub struct CodeDcbOptions {
255    pub pair: (String, String),
256    pub year: i32,
257    pub month: u8,
258    pub time_scale: TimeScale,
259    pub receiver_system: Option<GnssSystem>,
260}
261
262#[derive(Debug, Clone, PartialEq, thiserror::Error)]
263pub enum BiasError {
264    #[error("invalid bias input {field}: {reason}")]
265    InvalidInput {
266        field: &'static str,
267        reason: &'static str,
268    },
269    #[error("invalid bias epoch")]
270    InvalidEpoch,
271    #[error("unknown RINEX observable code {code}")]
272    UnknownObservable { code: String },
273    #[error("unsupported Bias-SINEX version {version}")]
274    UnsupportedVersion { version: String },
275    #[error("missing DCB pair or product month")]
276    MissingDcbMetadata,
277    #[error("missing satellite clock reference observables")]
278    MissingClockReference,
279    #[error("bias writer missing required metadata {field}")]
280    MissingWriterMetadata { field: &'static str },
281    #[error("input is not UTF-8")]
282    Utf8,
283}
284
285impl BiasSet {
286    pub fn parse_bias_sinex(input: &[u8]) -> Result<Parsed<BiasSet>, BiasError> {
287        let text = String::from_utf8_lossy(input);
288        parse_bias_sinex_text(text.as_ref())
289    }
290
291    pub fn parse_code_dcb(
292        input: &[u8],
293        options: Option<CodeDcbOptions>,
294    ) -> Result<Parsed<BiasSet>, BiasError> {
295        let text = String::from_utf8_lossy(input);
296        parse_code_dcb_text(text.as_ref(), options)
297    }
298
299    pub fn diagnostics(&self) -> &Diagnostics {
300        &self.diagnostics
301    }
302
303    pub fn skipped_records(&self) -> usize {
304        self.skipped_records
305    }
306
307    pub fn code_osb_seconds(&self, sat: GnssSatelliteId, obs: &str, epoch: Instant) -> Option<f64> {
308        self.osb_for_target_chain(sat, obs, epoch, false)
309    }
310
311    pub fn phase_osb_cycles(&self, sat: GnssSatelliteId, obs: &str, epoch: Instant) -> Option<f64> {
312        self.osb_for_target_chain(sat, obs, epoch, true)
313    }
314
315    pub fn code_dsb_seconds(
316        &self,
317        sat: GnssSatelliteId,
318        obs1: &str,
319        obs2: &str,
320        epoch: Instant,
321    ) -> Option<f64> {
322        self.dsb_for_keys(
323            [
324                BiasTargetKey::satellite(sat),
325                BiasTargetKey::system(sat.system),
326            ],
327            obs1,
328            obs2,
329            epoch,
330        )
331    }
332
333    pub fn receiver_code_osb_seconds(
334        &self,
335        system: GnssSystem,
336        station: &str,
337        obs: &str,
338        epoch: Instant,
339    ) -> Option<f64> {
340        self.select_record(
341            &BiasTargetKey::receiver(system, station),
342            obs,
343            epoch,
344            |record| record.kind == BiasKind::Osb && !record.is_phase,
345        )
346        .and_then(|record| self.record_value_at(record, epoch))
347    }
348
349    pub fn receiver_code_dsb_seconds(
350        &self,
351        system: GnssSystem,
352        station: &str,
353        obs1: &str,
354        obs2: &str,
355        epoch: Instant,
356    ) -> Option<f64> {
357        self.dsb_for_key(&BiasTargetKey::receiver(system, station), obs1, obs2, epoch)
358    }
359
360    pub fn sat_receiver_code_osb_seconds(
361        &self,
362        sat: GnssSatelliteId,
363        station: &str,
364        obs: &str,
365        epoch: Instant,
366    ) -> Option<f64> {
367        self.select_record(
368            &BiasTargetKey::satellite_receiver(sat, station),
369            obs,
370            epoch,
371            |record| record.kind == BiasKind::Osb && !record.is_phase,
372        )
373        .and_then(|record| self.record_value_at(record, epoch))
374    }
375
376    pub fn sat_receiver_code_dsb_seconds(
377        &self,
378        sat: GnssSatelliteId,
379        station: &str,
380        obs1: &str,
381        obs2: &str,
382        epoch: Instant,
383    ) -> Option<f64> {
384        self.dsb_for_key(
385            &BiasTargetKey::satellite_receiver(sat, station),
386            obs1,
387            obs2,
388            epoch,
389        )
390    }
391
392    pub fn records(&self) -> &[BiasRecord] {
393        &self.records
394    }
395
396    pub fn code_bias_model_m(
397        &self,
398        sat: GnssSatelliteId,
399        used_observables: (&str, &str),
400        used_frequencies_hz: (f64, f64),
401        glonass_channel: Option<i8>,
402        clock_reference: (&str, &str),
403        epoch: Instant,
404    ) -> Option<f64> {
405        if used_observables == clock_reference {
406            return Some(0.0);
407        }
408        let used_if = self.if_bias_seconds(sat, used_observables, used_frequencies_hz, epoch);
409        let ref_freq1 = rinex_frequency(sat, clock_reference.0, glonass_channel)?;
410        let ref_freq2 = rinex_frequency(sat, clock_reference.1, glonass_channel)?;
411        let ref_if = self.if_bias_seconds(sat, clock_reference, (ref_freq1, ref_freq2), epoch);
412        match (used_if, ref_if) {
413            (Some(used), Some(reference)) => Some((used - reference) * C_M_S),
414            _ => self
415                .relative_code_bias_seconds(
416                    sat,
417                    used_observables,
418                    used_frequencies_hz,
419                    clock_reference,
420                    epoch,
421                )
422                .map(|seconds| seconds * C_M_S),
423        }
424    }
425
426    fn new(
427        records: Vec<BiasRecord>,
428        mode: BiasMode,
429        time_scale: TimeScale,
430        clock_reference: ClockReferenceObservables,
431        header: BiasSetHeader,
432        mut diagnostics: Diagnostics,
433    ) -> Self {
434        let mut set = Self {
435            records,
436            index: BTreeMap::new(),
437            mode,
438            time_scale,
439            clock_reference,
440            header,
441            diagnostics: Diagnostics::new(),
442            skipped_records: 0,
443        };
444        set.rebuild_index(&mut diagnostics);
445        set.skipped_records = diagnostics.skips.len();
446        set.diagnostics = diagnostics;
447        set
448    }
449
450    fn rebuild_index(&mut self, diagnostics: &mut Diagnostics) {
451        let mut index: BTreeMap<(BiasTargetKey, String), Vec<usize>> = BTreeMap::new();
452        for (record_index, record) in self.records.iter().enumerate() {
453            let key = BiasTargetKey::from(&record.target);
454            index
455                .entry((key, obs_key(record)))
456                .or_default()
457                .push(record_index);
458        }
459        for indices in index.values_mut() {
460            indices.sort_by(|a, b| compare_record_start(&self.records[*a], &self.records[*b]));
461            for pair in indices.windows(2) {
462                let lhs = &self.records[pair[0]];
463                let rhs = &self.records[pair[1]];
464                if intervals_overlap(lhs, rhs) {
465                    diagnostics.push_warning(Warning {
466                        at: RecordRef::at_record(pair[1]),
467                        kind: WarningKind::Overlap,
468                    });
469                }
470            }
471        }
472        self.index = index;
473    }
474
475    fn osb_for_target_chain(
476        &self,
477        sat: GnssSatelliteId,
478        obs: &str,
479        epoch: Instant,
480        phase: bool,
481    ) -> Option<f64> {
482        for key in [
483            BiasTargetKey::satellite(sat),
484            BiasTargetKey::system(sat.system),
485        ] {
486            if let Some(value) = self
487                .select_record(&key, obs, epoch, |record| {
488                    record.kind == BiasKind::Osb && record.is_phase == phase
489                })
490                .and_then(|record| self.record_value_at(record, epoch))
491            {
492                return Some(value);
493            }
494        }
495        None
496    }
497
498    fn if_bias_seconds(
499        &self,
500        sat: GnssSatelliteId,
501        observables: (&str, &str),
502        frequencies_hz: (f64, f64),
503        epoch: Instant,
504    ) -> Option<f64> {
505        let obs1 = self.code_osb_seconds(sat, observables.0, epoch)?;
506        let obs2 = self.code_osb_seconds(sat, observables.1, epoch)?;
507        let (alpha, beta) = ionosphere_free_coefficients(frequencies_hz.0, frequencies_hz.1)?;
508        Some(alpha * obs1 + beta * obs2)
509    }
510
511    fn relative_code_bias_seconds(
512        &self,
513        sat: GnssSatelliteId,
514        used_observables: (&str, &str),
515        used_frequencies_hz: (f64, f64),
516        clock_reference: (&str, &str),
517        epoch: Instant,
518    ) -> Option<f64> {
519        let d1 = if used_observables.0 == clock_reference.0 {
520            0.0
521        } else {
522            self.code_dsb_seconds(sat, used_observables.0, clock_reference.0, epoch)?
523        };
524        let d2 = if used_observables.1 == clock_reference.1 {
525            0.0
526        } else {
527            self.code_dsb_seconds(sat, used_observables.1, clock_reference.1, epoch)?
528        };
529        let (alpha, beta) =
530            ionosphere_free_coefficients(used_frequencies_hz.0, used_frequencies_hz.1)?;
531        Some(alpha * d1 + beta * d2)
532    }
533
534    fn dsb_for_keys<const N: usize>(
535        &self,
536        keys: [BiasTargetKey; N],
537        obs1: &str,
538        obs2: &str,
539        epoch: Instant,
540    ) -> Option<f64> {
541        for key in keys {
542            if let Some(value) = self.dsb_for_key(&key, obs1, obs2, epoch) {
543                return Some(value);
544            }
545        }
546        None
547    }
548
549    fn dsb_for_key(
550        &self,
551        target_key: &BiasTargetKey,
552        obs1: &str,
553        obs2: &str,
554        epoch: Instant,
555    ) -> Option<f64> {
556        if obs1 == obs2 {
557            return Some(0.0);
558        }
559        let graph = self.dsb_graph(target_key, epoch);
560        resolve_dsb_path(&graph, obs1, obs2)
561    }
562
563    fn dsb_graph(
564        &self,
565        target_key: &BiasTargetKey,
566        epoch: Instant,
567    ) -> BTreeMap<String, Vec<(String, f64)>> {
568        let mut graph: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new();
569        for record in &self.records {
570            if BiasTargetKey::from(&record.target) != *target_key
571                || record.kind != BiasKind::Dsb
572                || record.is_phase
573                || !self.record_covers_epoch(record, epoch)
574            {
575                continue;
576            }
577            let Some(value) = self.record_value_at(record, epoch) else {
578                continue;
579            };
580            let Some(obs2) = record.obs2.as_ref() else {
581                continue;
582            };
583            graph
584                .entry(record.obs1.clone())
585                .or_default()
586                .push((obs2.clone(), value));
587            graph
588                .entry(obs2.clone())
589                .or_default()
590                .push((record.obs1.clone(), -value));
591        }
592        for edges in graph.values_mut() {
593            edges.sort_by(|a, b| a.0.cmp(&b.0));
594        }
595        graph
596    }
597
598    fn select_record(
599        &self,
600        target_key: &BiasTargetKey,
601        obs_key: &str,
602        epoch: Instant,
603        predicate: impl Fn(&BiasRecord) -> bool,
604    ) -> Option<&BiasRecord> {
605        let mut selected = None;
606        let indices = self.index.get(&(target_key.clone(), obs_key.to_string()))?;
607        for &index in indices {
608            let record = &self.records[index];
609            if predicate(record) && self.record_covers_epoch(record, epoch) {
610                selected = Some(record);
611            }
612        }
613        selected
614    }
615
616    fn record_covers_epoch(&self, record: &BiasRecord, epoch: Instant) -> bool {
617        if epoch.scale != self.time_scale {
618            return false;
619        }
620        let Some(query) = instant_split(epoch) else {
621            return false;
622        };
623        if let Some(from) = record.valid_from {
624            let Ok(from) = from.to_split() else {
625                return false;
626            };
627            if seconds_between_splits(query.jd_whole, query.fraction, from.jd_whole, from.fraction)
628                < 0.0
629            {
630                return false;
631            }
632        }
633        if let Some(until) = record.valid_until {
634            let Ok(until) = until.to_split() else {
635                return false;
636            };
637            if seconds_between_splits(
638                query.jd_whole,
639                query.fraction,
640                until.jd_whole,
641                until.fraction,
642            ) >= 0.0
643            {
644                return false;
645            }
646        }
647        true
648    }
649
650    fn record_value_at(&self, record: &BiasRecord, epoch: Instant) -> Option<f64> {
651        if !self.record_covers_epoch(record, epoch) {
652            return None;
653        }
654        let Some(slope) = record.slope else {
655            return Some(record.value);
656        };
657        let from = record.valid_from?.to_split().ok()?;
658        let query = instant_split(epoch)?;
659        let dt_s =
660            seconds_between_splits(query.jd_whole, query.fraction, from.jd_whole, from.fraction);
661        Some(record.value + slope * dt_s)
662    }
663}
664
665pub fn write_bias_sinex(set: &BiasSet) -> Result<String, BiasError> {
666    let agency = set
667        .header
668        .agency
669        .as_deref()
670        .ok_or(BiasError::MissingWriterMetadata { field: "agency" })?;
671    if set.mode == BiasMode::Unspecified {
672        return Err(BiasError::MissingWriterMetadata { field: "mode" });
673    }
674    if set.clock_reference.per_system.is_empty() {
675        return Err(BiasError::MissingWriterMetadata {
676            field: "clock_reference",
677        });
678    }
679
680    let mut out = String::new();
681    writeln!(&mut out, "%=BIA 1.00 {agency}").expect("write string");
682    writeln!(&mut out, "+FILE/REFERENCE").expect("write string");
683    if set.header.file_reference.is_empty() {
684        writeln!(&mut out, " DESCRIPTION GENERATED").expect("write string");
685    } else {
686        for (key, value) in &set.header.file_reference {
687            writeln!(&mut out, " {key} {value}").expect("write string");
688        }
689    }
690    writeln!(&mut out, "-FILE/REFERENCE").expect("write string");
691    writeln!(&mut out, "+BIAS/DESCRIPTION").expect("write string");
692    writeln!(
693        &mut out,
694        " BIAS_MODE {}",
695        match set.mode {
696            BiasMode::Absolute => "ABSOLUTE",
697            BiasMode::Relative => "RELATIVE",
698            BiasMode::Unspecified => "UNSPECIFIED",
699        }
700    )
701    .expect("write string");
702    writeln!(
703        &mut out,
704        " TIME_SYSTEM {}",
705        time_scale_sinex_label(set.time_scale)
706    )
707    .expect("write string");
708    for (system, (obs1, obs2)) in &set.clock_reference.per_system {
709        writeln!(
710            &mut out,
711            " SATELLITE_CLOCK_REFERENCE_OBSERVABLES {} {obs1} {obs2}",
712            system.letter()
713        )
714        .expect("write string");
715    }
716    for (key, value) in &set.header.description {
717        if matches!(
718            key.as_str(),
719            "BIAS_MODE" | "TIME_SYSTEM" | "SATELLITE_CLOCK_REFERENCE_OBSERVABLES"
720        ) {
721            continue;
722        }
723        writeln!(&mut out, " {key} {value}").expect("write string");
724    }
725    writeln!(&mut out, "-BIAS/DESCRIPTION").expect("write string");
726    writeln!(&mut out, "+BIAS/SOLUTION {}", set.records.len()).expect("write string");
727    writeln!(
728        &mut out,
729        "*BIAS SVN_ PRN STATION__ OBS1 OBS2 BIAS_START____ BIAS_END______ UNIT __ESTIMATED_VALUE____ _STD_DEV___"
730    )
731    .expect("write string");
732    for record in &set.records {
733        writeln!(&mut out, "{}", format_sinex_solution_record(record)).expect("write string");
734    }
735    writeln!(&mut out, "-BIAS/SOLUTION").expect("write string");
736    Ok(out)
737}
738
739pub fn write_code_dcb(set: &BiasSet) -> Result<String, BiasError> {
740    let meta = set
741        .header
742        .dcb_meta
743        .as_ref()
744        .ok_or(BiasError::MissingWriterMetadata { field: "dcb_meta" })?;
745    let mut out = String::new();
746    writeln!(
747        &mut out,
748        "# DCB {}-{} {:04}-{:02} {}",
749        meta.pair.0,
750        meta.pair.1,
751        meta.year,
752        meta.month,
753        time_scale_sinex_label(meta.time_scale)
754    )
755    .expect("write string");
756    writeln!(&mut out, " PRN / STATION NAME        VALUE (ns)  RMS (ns)").expect("write string");
757    writeln!(
758        &mut out,
759        "***   ****************      *********.***  *****.***"
760    )
761    .expect("write string");
762    for record in &set.records {
763        if record.kind != BiasKind::Dsb || record.is_phase {
764            return Err(BiasError::InvalidInput {
765                field: "bias set",
766                reason: "CODE DCB writer requires code DSB records",
767            });
768        }
769        let value_ns = record.value / NS_TO_S;
770        let sigma_ns = record.sigma.unwrap_or(0.0) / NS_TO_S;
771        match &record.target {
772            BiasTarget::Satellite(sat) => {
773                writeln!(
774                    &mut out,
775                    " {sat:<3}                       {value_ns:10.3} {sigma_ns:10.3}"
776                )
777                .expect("write string");
778            }
779            BiasTarget::Receiver { station, .. } => {
780                writeln!(
781                    &mut out,
782                    "      {station:<16}       {value_ns:10.3} {sigma_ns:10.3}"
783                )
784                .expect("write string");
785            }
786            _ => {
787                return Err(BiasError::InvalidInput {
788                    field: "bias target",
789                    reason: "CODE DCB writer supports satellite and receiver records",
790                });
791            }
792        }
793    }
794    Ok(out)
795}
796
797pub fn ionosphere_free_coefficients(f1_hz: f64, f2_hz: f64) -> Option<(f64, f64)> {
798    validate::finite_positive(f1_hz, "f1_hz").ok()?;
799    validate::finite_positive(f2_hz, "f2_hz").ok()?;
800    let f1_2 = f1_hz * f1_hz;
801    let f2_2 = f2_hz * f2_hz;
802    let denom = f1_2 - f2_2;
803    if denom == 0.0 {
804        return None;
805    }
806    let alpha = f1_2 / denom;
807    let beta = -f2_2 / denom;
808    Some((alpha, beta))
809}
810
811fn parse_bias_sinex_text(text: &str) -> Result<Parsed<BiasSet>, BiasError> {
812    let mut diagnostics = Diagnostics::new();
813    let mut header = BiasSetHeader::default();
814    let mut mode = BiasMode::Unspecified;
815    let mut time_scale = TimeScale::Gpst;
816    let mut clock_reference = ClockReferenceObservables::default();
817    let mut records = Vec::new();
818    let mut current_block: Option<String> = None;
819    let mut saw_file_reference = false;
820    let mut solution_count_line = None;
821
822    let first = text.lines().next().ok_or(BiasError::InvalidInput {
823        field: "input",
824        reason: "empty",
825    })?;
826    parse_bias_sinex_header(first, &mut header)?;
827
828    for (line_index, line) in text.lines().enumerate() {
829        let line_number = line_index + 1;
830        let trimmed = line.trim_end();
831        if line_number == 1 || trimmed.is_empty() {
832            continue;
833        }
834        if let Some(block) = trimmed.strip_prefix('+') {
835            let mut parts = block.split_whitespace();
836            let name = parts.next().unwrap_or("").to_string();
837            if name == "BIAS/SOLUTION" {
838                solution_count_line = Some(line_number);
839                if let Some(count) = parts.next() {
840                    if let Ok(count) = count.parse::<usize>() {
841                        header.declared_bias_count = Some(count);
842                    }
843                }
844            } else if !matches!(name.as_str(), "FILE/REFERENCE" | "BIAS/DESCRIPTION") {
845                diagnostics.push_skip(Skip {
846                    at: RecordRef::at_line(line_number),
847                    reason: SkipReason::UnknownBlock(name.clone()),
848                });
849            }
850            if name == "FILE/REFERENCE" {
851                saw_file_reference = true;
852            }
853            current_block = Some(name);
854            continue;
855        }
856        if trimmed.starts_with('-') {
857            current_block = None;
858            continue;
859        }
860        if trimmed.starts_with('*') {
861            continue;
862        }
863
864        match current_block.as_deref() {
865            Some("FILE/REFERENCE") => parse_file_reference_line(trimmed, &mut header),
866            Some("BIAS/DESCRIPTION") => parse_description_line(
867                trimmed,
868                &mut header,
869                &mut mode,
870                &mut time_scale,
871                &mut clock_reference,
872                &mut diagnostics,
873                line_number,
874            ),
875            Some("BIAS/SOLUTION") => match parse_solution_line(trimmed) {
876                Ok(record) => {
877                    if mode == BiasMode::Absolute && record.kind != BiasKind::Osb {
878                        diagnostics.push_warning(Warning {
879                            at: RecordRef::at_line(line_number),
880                            kind: WarningKind::Mismatch,
881                        });
882                    }
883                    if mode == BiasMode::Relative && record.kind == BiasKind::Osb {
884                        diagnostics.push_warning(Warning {
885                            at: RecordRef::at_line(line_number),
886                            kind: WarningKind::Mismatch,
887                        });
888                    }
889                    records.push(record);
890                }
891                Err(reason) => diagnostics.push_skip(Skip {
892                    at: RecordRef::at_line(line_number),
893                    reason,
894                }),
895            },
896            Some(_) | None => {}
897        }
898    }
899
900    if !saw_file_reference {
901        diagnostics.push_warning(Warning {
902            at: RecordRef::at_line(1),
903            kind: WarningKind::MissingMetadata,
904        });
905    }
906    if let Some(declared) = header.declared_bias_count {
907        if declared != records.len() {
908            diagnostics.push_warning(Warning {
909                at: RecordRef::at_line(solution_count_line.unwrap_or(1)),
910                kind: WarningKind::Mismatch,
911            });
912        }
913    }
914
915    let set = BiasSet::new(
916        records,
917        mode,
918        time_scale,
919        clock_reference,
920        header,
921        diagnostics,
922    );
923    let diagnostics = set.diagnostics.clone();
924    Ok(Parsed::new(set, diagnostics))
925}
926
927fn parse_code_dcb_text(
928    text: &str,
929    options: Option<CodeDcbOptions>,
930) -> Result<Parsed<BiasSet>, BiasError> {
931    let title_meta = parse_dcb_title_metadata(text);
932    let options = match (options, title_meta) {
933        (Some(options), Some(title)) => {
934            if options.pair != title.pair
935                || options.year != title.year
936                || options.month != title.month
937                || options.time_scale != title.time_scale
938            {
939                return Err(BiasError::InvalidInput {
940                    field: "CodeDcbOptions",
941                    reason: "does not match title metadata",
942                });
943            }
944            options
945        }
946        (Some(options), None) => options,
947        (None, Some(title)) => title,
948        (None, None) => return Err(BiasError::MissingDcbMetadata),
949    };
950    validate_dcb_options(&options)?;
951
952    let mut diagnostics = Diagnostics::new();
953    let (valid_from, valid_until) = dcb_month_interval(options.year, options.month)?;
954    let raw_epochs = (
955        valid_from.format_sinex(),
956        valid_until
957            .map(BiasEpoch::format_sinex)
958            .unwrap_or_else(|| "0000:000:00000".to_string()),
959    );
960    let mut records = Vec::new();
961    for (line_index, line) in text.lines().enumerate() {
962        let line_number = line_index + 1;
963        let trimmed = line.trim();
964        if trimmed.is_empty()
965            || trimmed.starts_with('#')
966            || trimmed.starts_with('*')
967            || trimmed.starts_with("PRN")
968            || trimmed.contains("VALUE")
969            || !looks_like_dcb_row(line, &options)
970        {
971            continue;
972        }
973        let row = match parse_dcb_row(line, &options) {
974            Ok(Some(row)) => row,
975            Ok(None) => continue,
976            Err(reason) => {
977                diagnostics.push_skip(Skip {
978                    at: RecordRef::at_line(line_number),
979                    reason,
980                });
981                continue;
982            }
983        };
984        let Some((obs1, obs2)) = map_legacy_dcb_pair(row.system, &options.pair.0, &options.pair.1)
985        else {
986            diagnostics.push_skip(Skip {
987                at: RecordRef::at_line(line_number),
988                reason: SkipReason::UnsupportedRecordType("DCB_PAIR"),
989            });
990            continue;
991        };
992        records.push(BiasRecord {
993            kind: BiasKind::Dsb,
994            target: row.target,
995            svn: None,
996            obs1,
997            obs2: Some(obs2),
998            valid_from: Some(valid_from),
999            valid_until,
1000            raw_epochs: raw_epochs.clone(),
1001            value: row.value_ns * NS_TO_S,
1002            sigma: row.sigma_ns.map(|sigma| sigma * NS_TO_S),
1003            slope: None,
1004            slope_sigma: None,
1005            is_phase: false,
1006        });
1007    }
1008
1009    let mut header = BiasSetHeader {
1010        dcb_meta: Some(options.clone()),
1011        ..BiasSetHeader::default()
1012    };
1013    header.description.insert(
1014        "DCB_PAIR".to_string(),
1015        format!("{} {}", options.pair.0, options.pair.1),
1016    );
1017    let set = BiasSet::new(
1018        records,
1019        BiasMode::Relative,
1020        options.time_scale,
1021        ClockReferenceObservables::default(),
1022        header,
1023        diagnostics,
1024    );
1025    let diagnostics = set.diagnostics.clone();
1026    Ok(Parsed::new(set, diagnostics))
1027}
1028
1029struct DcbRow {
1030    target: BiasTarget,
1031    system: GnssSystem,
1032    value_ns: f64,
1033    sigma_ns: Option<f64>,
1034}
1035
1036fn parse_dcb_row(line: &str, options: &CodeDcbOptions) -> Result<Option<DcbRow>, SkipReason> {
1037    let Some(value_token) = field(line, 24, 38) else {
1038        return Ok(None);
1039    };
1040    let value_ns = fortran_f64(line, 24, 38, "dcb value").ok_or_else(|| {
1041        SkipReason::MalformedField(FieldError::FloatParse {
1042            field: "dcb value",
1043            value: value_token.to_string(),
1044        })
1045    })?;
1046    let sigma_ns = fortran_f64(line, 38, 50, "dcb sigma");
1047
1048    if let Some(sat_token) = field(line, 0, 4).filter(|token| looks_like_satellite_token(token)) {
1049        let sat = sat_token
1050            .parse::<GnssSatelliteId>()
1051            .map_err(|_| SkipReason::UnrepresentableSatellite)?;
1052        return Ok(Some(DcbRow {
1053            target: BiasTarget::Satellite(sat),
1054            system: sat.system,
1055            value_ns,
1056            sigma_ns,
1057        }));
1058    }
1059
1060    if let Some(system) =
1061        field(line, 0, 1).and_then(|token| token.chars().next().and_then(GnssSystem::from_letter))
1062    {
1063        let station = field(line, 6, 10).or_else(|| field(line, 6, 22)).ok_or(
1064            SkipReason::InconsistentRecord("receiver DCB record lacks station"),
1065        )?;
1066        return Ok(Some(DcbRow {
1067            target: BiasTarget::Receiver {
1068                system,
1069                station: normalize_station(station),
1070            },
1071            system,
1072            value_ns,
1073            sigma_ns,
1074        }));
1075    }
1076
1077    if let Some(system) = options.receiver_system {
1078        let station = field(line, 6, 22).or_else(|| field(line, 0, 22)).ok_or(
1079            SkipReason::InconsistentRecord("receiver DCB record lacks station"),
1080        )?;
1081        return Ok(Some(DcbRow {
1082            target: BiasTarget::Receiver {
1083                system,
1084                station: normalize_station(station),
1085            },
1086            system,
1087            value_ns,
1088            sigma_ns,
1089        }));
1090    }
1091
1092    Err(SkipReason::InconsistentRecord(
1093        "receiver DCB record lacks system",
1094    ))
1095}
1096
1097fn looks_like_dcb_row(line: &str, options: &CodeDcbOptions) -> bool {
1098    if field(line, 0, 4).is_some_and(looks_like_satellite_token) {
1099        return true;
1100    }
1101    if field(line, 0, 1)
1102        .and_then(|token| token.chars().next().and_then(GnssSystem::from_letter))
1103        .is_some()
1104        && raw_field(line, 1, 6).trim().is_empty()
1105    {
1106        return true;
1107    }
1108    options.receiver_system.is_some()
1109        && field(line, 24, 38).is_some_and(|token| validate::strict_f64(token, "dcb value").is_ok())
1110}
1111
1112fn parse_bias_sinex_header(line: &str, header: &mut BiasSetHeader) -> Result<(), BiasError> {
1113    let mut tokens = line.split_whitespace();
1114    let Some(marker) = tokens.next() else {
1115        return Err(BiasError::InvalidInput {
1116            field: "header",
1117            reason: "missing marker",
1118        });
1119    };
1120    if marker != "%=BIA" {
1121        return Err(BiasError::InvalidInput {
1122            field: "header",
1123            reason: "missing %=BIA marker",
1124        });
1125    }
1126    let version = tokens.next().ok_or(BiasError::InvalidInput {
1127        field: "version",
1128        reason: "missing",
1129    })?;
1130    if !version.starts_with(BIAS_SINEX_MAJOR_VERSION) {
1131        return Err(BiasError::UnsupportedVersion {
1132            version: version.to_string(),
1133        });
1134    }
1135    header.agency = tokens.next().map(str::to_string);
1136    Ok(())
1137}
1138
1139fn parse_file_reference_line(line: &str, header: &mut BiasSetHeader) {
1140    let mut parts = line.split_whitespace();
1141    if let Some(key) = parts.next() {
1142        let value = parts.collect::<Vec<_>>().join(" ");
1143        header.file_reference.push((key.to_string(), value));
1144    }
1145}
1146
1147fn parse_description_line(
1148    line: &str,
1149    header: &mut BiasSetHeader,
1150    mode: &mut BiasMode,
1151    time_scale: &mut TimeScale,
1152    clock_reference: &mut ClockReferenceObservables,
1153    diagnostics: &mut Diagnostics,
1154    line_number: usize,
1155) {
1156    let mut parts = line.split_whitespace();
1157    let Some(key) = parts.next() else {
1158        return;
1159    };
1160    match key {
1161        "BIAS_MODE" => {
1162            let value = parts.next().unwrap_or("");
1163            *mode = match value {
1164                "ABSOLUTE" => BiasMode::Absolute,
1165                "RELATIVE" => BiasMode::Relative,
1166                _ => BiasMode::Unspecified,
1167            };
1168            header
1169                .description
1170                .insert(key.to_string(), value.to_string());
1171        }
1172        "TIME_SYSTEM" => {
1173            let value = parts.next().unwrap_or("");
1174            match parse_sinex_time_scale(value) {
1175                Some(scale) => {
1176                    *time_scale = scale;
1177                    header
1178                        .description
1179                        .insert(key.to_string(), value.to_string());
1180                }
1181                None => diagnostics.push_skip(Skip {
1182                    at: RecordRef::at_line(line_number),
1183                    reason: SkipReason::MalformedField(FieldError::FloatParse {
1184                        field: "time system",
1185                        value: value.to_string(),
1186                    }),
1187                }),
1188            }
1189        }
1190        "SATELLITE_CLOCK_REFERENCE_OBSERVABLES" => {
1191            let system_token = parts.next().unwrap_or("");
1192            let obs1 = parts.next().unwrap_or("");
1193            let obs2 = parts.next().unwrap_or("");
1194            if let Some(system) = system_token
1195                .chars()
1196                .next()
1197                .and_then(GnssSystem::from_letter)
1198            {
1199                clock_reference
1200                    .per_system
1201                    .insert(system, (obs1.to_string(), obs2.to_string()));
1202                header.description.insert(
1203                    format!("{key}_{}", system.letter()),
1204                    format!("{obs1} {obs2}"),
1205                );
1206            } else {
1207                diagnostics.push_skip(Skip {
1208                    at: RecordRef::at_line(line_number),
1209                    reason: SkipReason::MalformedField(FieldError::IntParse {
1210                        field: "system",
1211                        value: system_token.to_string(),
1212                    }),
1213                });
1214            }
1215        }
1216        _ => {
1217            let value = parts.collect::<Vec<_>>().join(" ");
1218            header.description.insert(key.to_string(), value);
1219        }
1220    }
1221}
1222
1223fn parse_solution_line(line: &str) -> Result<BiasRecord, SkipReason> {
1224    if line.len() < 91 {
1225        return Err(SkipReason::Truncated);
1226    }
1227    let kind = field(line, 1, 5)
1228        .ok_or(SkipReason::Truncated)?
1229        .parse::<BiasKind>()
1230        .map_err(|_| SkipReason::UnsupportedRecordType("BIAS"))?;
1231    let svn = field(line, 6, 10).map(str::to_string);
1232    let prn = field(line, 11, 14);
1233    let station = field(line, 15, 24).map(normalize_station);
1234    let obs1 = field(line, 25, 29)
1235        .ok_or(SkipReason::Truncated)?
1236        .to_string();
1237    let obs2 = field(line, 30, 34).map(str::to_string);
1238    let raw_start = raw_field(line, 35, 49).trim().to_string();
1239    let raw_end = raw_field(line, 50, 64).trim().to_string();
1240    let unit = field(line, 65, 69).ok_or(SkipReason::Truncated)?;
1241    let value = fortran_f64(line, 70, 91, "bias value").ok_or_else(|| {
1242        SkipReason::MalformedField(FieldError::FloatParse {
1243            field: "bias value",
1244            value: raw_field(line, 70, 91).trim().to_string(),
1245        })
1246    })?;
1247    let sigma = fortran_f64(line, 92, 103, "bias sigma");
1248    let slope = fortran_f64(line, 104, 125, "bias slope");
1249    let slope_sigma = fortran_f64(line, 126, 137, "bias slope sigma");
1250    let is_phase = match unit {
1251        "ns" => false,
1252        "cyc" => true,
1253        other => return Err(SkipReason::UnsupportedUnit(other.to_string())),
1254    };
1255    if kind == BiasKind::Osb && obs2.is_some() {
1256        return Err(SkipReason::InconsistentRecord("OSB must not carry OBS2"));
1257    }
1258    if kind != BiasKind::Osb && obs2.is_none() {
1259        return Err(SkipReason::InconsistentRecord("DSB or ISB requires OBS2"));
1260    }
1261    let valid_from = BiasEpoch::parse_sinex(&raw_start).map_err(|_| {
1262        SkipReason::MalformedField(FieldError::IntParse {
1263            field: "bias start",
1264            value: raw_start.clone(),
1265        })
1266    })?;
1267    let valid_until = BiasEpoch::parse_sinex(&raw_end)
1268        .map_err(|_| {
1269            SkipReason::MalformedField(FieldError::IntParse {
1270                field: "bias end",
1271                value: raw_end.clone(),
1272            })
1273        })?
1274        .map(|epoch| epoch.normalize_end())
1275        .transpose()
1276        .map_err(|_| {
1277            SkipReason::MalformedField(FieldError::IntParse {
1278                field: "bias end",
1279                value: raw_end.clone(),
1280            })
1281        })?;
1282    let target = parse_bias_target(svn.as_deref(), prn, station.as_deref())?;
1283    let value = if is_phase { value } else { value * NS_TO_S };
1284    let sigma = sigma.map(|sigma| if is_phase { sigma } else { sigma * NS_TO_S });
1285    let slope = slope.map(|slope| {
1286        if is_phase {
1287            slope / SINEX_BIAS_SLOPE_DENOMINATOR_S
1288        } else {
1289            slope * NS_TO_S / SINEX_BIAS_SLOPE_DENOMINATOR_S
1290        }
1291    });
1292    let slope_sigma = slope_sigma.map(|slope_sigma| {
1293        if is_phase {
1294            slope_sigma / SINEX_BIAS_SLOPE_DENOMINATOR_S
1295        } else {
1296            slope_sigma * NS_TO_S / SINEX_BIAS_SLOPE_DENOMINATOR_S
1297        }
1298    });
1299    Ok(BiasRecord {
1300        kind,
1301        target,
1302        svn,
1303        obs1,
1304        obs2,
1305        valid_from,
1306        valid_until,
1307        raw_epochs: (raw_start, raw_end),
1308        value,
1309        sigma,
1310        slope,
1311        slope_sigma,
1312        is_phase,
1313    })
1314}
1315
1316fn parse_bias_target(
1317    _svn: Option<&str>,
1318    prn: Option<&str>,
1319    station: Option<&str>,
1320) -> Result<BiasTarget, SkipReason> {
1321    match (prn, station) {
1322        (Some(prn), None) if prn.len() == 1 => {
1323            let system = prn.chars().next().and_then(GnssSystem::from_letter).ok_or(
1324                SkipReason::MalformedField(FieldError::IntParse {
1325                    field: "system",
1326                    value: prn.to_string(),
1327                }),
1328            )?;
1329            Ok(BiasTarget::System(system))
1330        }
1331        (Some(prn), Some(station)) if prn.len() == 1 => {
1332            let system = prn.chars().next().and_then(GnssSystem::from_letter).ok_or(
1333                SkipReason::MalformedField(FieldError::IntParse {
1334                    field: "system",
1335                    value: prn.to_string(),
1336                }),
1337            )?;
1338            Ok(BiasTarget::Receiver {
1339                system,
1340                station: normalize_station(station),
1341            })
1342        }
1343        (Some(prn), None) => prn
1344            .parse::<GnssSatelliteId>()
1345            .map(BiasTarget::Satellite)
1346            .map_err(|_| SkipReason::UnrepresentableSatellite),
1347        (Some(prn), Some(station)) => prn
1348            .parse::<GnssSatelliteId>()
1349            .map(|sat| BiasTarget::SatelliteReceiver {
1350                sat,
1351                station: normalize_station(station),
1352            })
1353            .map_err(|_| SkipReason::UnrepresentableSatellite),
1354        (None, Some(_)) | (None, None) => Err(SkipReason::InconsistentRecord("missing PRN")),
1355    }
1356}
1357
1358fn parse_dcb_title_metadata(text: &str) -> Option<CodeDcbOptions> {
1359    for line in text.lines().take(12) {
1360        let mut pair = None;
1361        let mut year = None;
1362        let mut month = None;
1363        let mut scale = None;
1364        let tokens = line
1365            .split_whitespace()
1366            .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
1367            .collect::<Vec<_>>();
1368        for (index, token) in tokens.iter().enumerate() {
1369            if let Some((a, b)) = token.split_once('-') {
1370                if is_legacy_dcb_label(a) && is_legacy_dcb_label(b) {
1371                    pair = Some((a.to_string(), b.to_string()));
1372                }
1373            }
1374            if let Some((y, m)) = token.split_once('-') {
1375                if let (Ok(y), Ok(m)) = (y.parse::<i32>(), m.parse::<u8>()) {
1376                    year = Some(y);
1377                    month = Some(m);
1378                }
1379            }
1380            if token.eq_ignore_ascii_case("YEAR") {
1381                year = tokens
1382                    .get(index + 1)
1383                    .and_then(|value| value.parse::<i32>().ok());
1384            }
1385            if token.eq_ignore_ascii_case("MONTH") {
1386                month = tokens
1387                    .get(index + 1)
1388                    .and_then(|value| value.parse::<u8>().ok());
1389            }
1390            scale = scale.or_else(|| parse_sinex_time_scale(token));
1391        }
1392        if let (Some(pair), Some(year), Some(month)) = (pair, year, month) {
1393            return Some(CodeDcbOptions {
1394                pair,
1395                year,
1396                month,
1397                time_scale: scale.unwrap_or(TimeScale::Gpst),
1398                receiver_system: None,
1399            });
1400        }
1401    }
1402    None
1403}
1404
1405fn validate_dcb_options(options: &CodeDcbOptions) -> Result<(), BiasError> {
1406    if !is_legacy_dcb_label(&options.pair.0) || !is_legacy_dcb_label(&options.pair.1) {
1407        return Err(BiasError::InvalidInput {
1408            field: "pair",
1409            reason: "unknown legacy DCB label",
1410        });
1411    }
1412    if !(1..=12).contains(&options.month) {
1413        return Err(BiasError::InvalidInput {
1414            field: "month",
1415            reason: "out of range",
1416        });
1417    }
1418    Ok(())
1419}
1420
1421fn dcb_month_interval(year: i32, month: u8) -> Result<(BiasEpoch, Option<BiasEpoch>), BiasError> {
1422    let start = month_epoch(year, month)?;
1423    let (next_year, next_month) = if month == 12 {
1424        (year + 1, 1)
1425    } else {
1426        (year, month + 1)
1427    };
1428    Ok((start, Some(month_epoch(next_year, next_month)?)))
1429}
1430
1431fn month_epoch(year: i32, month: u8) -> Result<BiasEpoch, BiasError> {
1432    let doy = day_of_year_int(year, i32::from(month), 1);
1433    BiasEpoch::new(year, doy as u16, 0)
1434}
1435
1436fn map_legacy_dcb_pair(system: GnssSystem, left: &str, right: &str) -> Option<(String, String)> {
1437    let left = map_legacy_dcb_label(system, left)?;
1438    let right = map_legacy_dcb_label(system, right)?;
1439    Some((left.to_string(), right.to_string()))
1440}
1441
1442fn map_legacy_dcb_label(system: GnssSystem, label: &str) -> Option<&'static str> {
1443    match (system, label) {
1444        (GnssSystem::Gps, "P1") => Some("C1W"),
1445        (GnssSystem::Gps, "P2") => Some("C2W"),
1446        (GnssSystem::Gps, "C1") => Some("C1C"),
1447        (GnssSystem::Gps, "C2") => Some("C2C"),
1448        (GnssSystem::Glonass, "P1") => Some("C1P"),
1449        (GnssSystem::Glonass, "P2") => Some("C2P"),
1450        (GnssSystem::Glonass, "C1") => Some("C1C"),
1451        (GnssSystem::Glonass, "C2") => Some("C2C"),
1452        (GnssSystem::Galileo, "P1") => Some("C1X"),
1453        (GnssSystem::Galileo, "P2") => Some("C5X"),
1454        (GnssSystem::Galileo, "C1") => Some("C1C"),
1455        (GnssSystem::Galileo, "C2") => Some("C5Q"),
1456        (GnssSystem::BeiDou, "P1") => Some("C2I"),
1457        (GnssSystem::BeiDou, "P2") => Some("C6I"),
1458        (GnssSystem::BeiDou, "C1") => Some("C2I"),
1459        (GnssSystem::BeiDou, "C2") => Some("C7I"),
1460        _ => None,
1461    }
1462}
1463
1464fn is_legacy_dcb_label(label: &str) -> bool {
1465    matches!(label, "P1" | "P2" | "C1" | "C2")
1466}
1467
1468fn format_sinex_solution_record(record: &BiasRecord) -> String {
1469    let (svn, prn, station) = target_fields(record);
1470    let obs2 = record.obs2.as_deref().unwrap_or("");
1471    let (unit, value, sigma, slope, slope_sigma) = if record.is_phase {
1472        (
1473            "cyc",
1474            record.value,
1475            record.sigma,
1476            record.slope.map(|s| s * SINEX_BIAS_SLOPE_DENOMINATOR_S),
1477            record
1478                .slope_sigma
1479                .map(|s| s * SINEX_BIAS_SLOPE_DENOMINATOR_S),
1480        )
1481    } else {
1482        (
1483            "ns",
1484            record.value / NS_TO_S,
1485            record.sigma.map(|s| s / NS_TO_S),
1486            record
1487                .slope
1488                .map(|s| s / NS_TO_S * SINEX_BIAS_SLOPE_DENOMINATOR_S),
1489            record
1490                .slope_sigma
1491                .map(|s| s / NS_TO_S * SINEX_BIAS_SLOPE_DENOMINATOR_S),
1492        )
1493    };
1494    let start = if record.raw_epochs.0.is_empty() {
1495        record
1496            .valid_from
1497            .map(BiasEpoch::format_sinex)
1498            .unwrap_or_else(|| "0000:000:00000".to_string())
1499    } else {
1500        record.raw_epochs.0.clone()
1501    };
1502    let end = if record.raw_epochs.1.is_empty() {
1503        record
1504            .valid_until
1505            .map(BiasEpoch::format_sinex)
1506            .unwrap_or_else(|| "0000:000:00000".to_string())
1507    } else {
1508        record.raw_epochs.1.clone()
1509    };
1510    let mut line = format!(
1511        " {:<4} {:<4} {:<3} {:<9} {:<4} {:<4} {:<14} {:<14} {:<4} {:>21.12E}",
1512        record.kind.label(),
1513        svn,
1514        prn,
1515        station,
1516        record.obs1,
1517        obs2,
1518        start,
1519        end,
1520        unit,
1521        value
1522    );
1523    if let Some(sigma) = sigma {
1524        write!(&mut line, " {sigma:>11.5E}").expect("write string");
1525    } else {
1526        line.push_str(&" ".repeat(12));
1527    }
1528    if let Some(slope) = slope {
1529        write!(&mut line, " {slope:>21.12E}").expect("write string");
1530        if let Some(slope_sigma) = slope_sigma {
1531            write!(&mut line, " {slope_sigma:>11.5E}").expect("write string");
1532        }
1533    }
1534    line
1535}
1536
1537fn target_fields(record: &BiasRecord) -> (String, String, String) {
1538    match &record.target {
1539        BiasTarget::System(system) => (
1540            record.svn.clone().unwrap_or_default(),
1541            system.letter().to_string(),
1542            String::new(),
1543        ),
1544        BiasTarget::Satellite(sat) => (
1545            record.svn.clone().unwrap_or_default(),
1546            sat.to_string(),
1547            String::new(),
1548        ),
1549        BiasTarget::Receiver { system, station } => (
1550            record.svn.clone().unwrap_or_default(),
1551            system.letter().to_string(),
1552            station.clone(),
1553        ),
1554        BiasTarget::SatelliteReceiver { sat, station } => (
1555            record.svn.clone().unwrap_or_default(),
1556            sat.to_string(),
1557            station.clone(),
1558        ),
1559    }
1560}
1561
1562fn resolve_dsb_path(
1563    graph: &BTreeMap<String, Vec<(String, f64)>>,
1564    start: &str,
1565    end: &str,
1566) -> Option<f64> {
1567    let mut queue = VecDeque::new();
1568    let mut best_depth = None;
1569    let mut candidates = Vec::new();
1570    queue.push_back((vec![start.to_string()], 0.0));
1571
1572    while let Some((path, value)) = queue.pop_front() {
1573        let depth = path.len() - 1;
1574        if best_depth.is_some_and(|best| depth > best) {
1575            continue;
1576        }
1577        let node = path.last().expect("path contains start");
1578        if node == end {
1579            best_depth = Some(depth);
1580            candidates.push((path, value));
1581            continue;
1582        }
1583        for (next, edge_value) in graph.get(node).into_iter().flatten() {
1584            if path.iter().any(|seen| seen == next) {
1585                continue;
1586            }
1587            let mut next_path = path.clone();
1588            next_path.push(next.clone());
1589            queue.push_back((next_path, value + edge_value));
1590        }
1591    }
1592
1593    candidates.sort_by(|a, b| a.0.cmp(&b.0));
1594    let (_, first) = candidates.first()?;
1595    if candidates
1596        .iter()
1597        .any(|(_, value)| (value - first).abs() > DSB_INCONSISTENCY_TOL_S)
1598    {
1599        return None;
1600    }
1601    Some(*first)
1602}
1603
1604fn compare_record_start(a: &BiasRecord, b: &BiasRecord) -> Ordering {
1605    a.valid_from
1606        .cmp(&b.valid_from)
1607        .then_with(|| a.raw_epochs.0.cmp(&b.raw_epochs.0))
1608}
1609
1610fn intervals_overlap(a: &BiasRecord, b: &BiasRecord) -> bool {
1611    let a_start = a.valid_from;
1612    let b_start = b.valid_from;
1613    let a_end = a.valid_until;
1614    let b_end = b.valid_until;
1615    let a_before_b_end = b_end.is_none_or(|end| a_start.is_none_or(|start| start < end));
1616    let b_before_a_end = a_end.is_none_or(|end| b_start.is_none_or(|start| start < end));
1617    a_before_b_end && b_before_a_end
1618}
1619
1620fn obs_key(record: &BiasRecord) -> String {
1621    match record.kind {
1622        BiasKind::Osb => record.obs1.clone(),
1623        BiasKind::Dsb | BiasKind::Isb => {
1624            format!("{}-{}", record.obs1, record.obs2.as_deref().unwrap_or(""))
1625        }
1626    }
1627}
1628
1629fn normalize_station(station: &str) -> String {
1630    let upper = station.trim().to_ascii_uppercase();
1631    if upper.len() > 4 && upper.as_bytes()[..4].iter().all(u8::is_ascii_alphanumeric) {
1632        upper[..4].to_string()
1633    } else {
1634        upper
1635    }
1636}
1637
1638fn instant_split(epoch: Instant) -> Option<JulianDateSplit> {
1639    match epoch.repr {
1640        InstantRepr::JulianDate(split) => Some(split),
1641        InstantRepr::Nanos(nanos) => {
1642            let seconds = nanos as f64 * NS_TO_S;
1643            let days = seconds.div_euclid(SECONDS_PER_DAY);
1644            let rem = seconds.rem_euclid(SECONDS_PER_DAY);
1645            JulianDateSplit::new(crate::constants::J2000_JD + days, rem / SECONDS_PER_DAY).ok()
1646        }
1647    }
1648}
1649
1650pub fn bias_epoch_instant(epoch: BiasEpoch, scale: TimeScale) -> Result<Instant, BiasError> {
1651    Ok(Instant::from_julian_date(scale, epoch.to_split()?))
1652}
1653
1654pub fn civil_datetime_instant(
1655    epoch: crate::ppp_corrections::CivilDateTime,
1656    scale: TimeScale,
1657) -> Result<Instant, BiasError> {
1658    let second_policy = match scale {
1659        TimeScale::Utc | TimeScale::Glonasst => CivilSecondPolicy::UtcLike,
1660        _ => CivilSecondPolicy::Continuous,
1661    };
1662    validate::civil_datetime_with_second_policy(
1663        i64::from(epoch.year),
1664        i64::from(epoch.month),
1665        i64::from(epoch.day),
1666        i64::from(epoch.hour),
1667        i64::from(epoch.minute),
1668        epoch.second,
1669        second_policy,
1670    )
1671    .map_err(|_| BiasError::InvalidEpoch)?;
1672    let (jd_whole, fraction) = split_julian_date(
1673        epoch.year,
1674        i32::from(epoch.month),
1675        i32::from(epoch.day),
1676        i32::from(epoch.hour),
1677        i32::from(epoch.minute),
1678        epoch.second,
1679    );
1680    Ok(Instant::from_julian_date(
1681        scale,
1682        JulianDateSplit::new(jd_whole, fraction).map_err(|_| BiasError::InvalidEpoch)?,
1683    ))
1684}
1685
1686fn parse_int<T>(token: Option<&str>, field: &'static str) -> Result<T, BiasError>
1687where
1688    T: FromStr,
1689{
1690    let token = token.ok_or(BiasError::InvalidInput {
1691        field,
1692        reason: "missing",
1693    })?;
1694    token.parse::<T>().map_err(|_| BiasError::InvalidInput {
1695        field,
1696        reason: "invalid integer",
1697    })
1698}
1699
1700fn parse_sinex_time_scale(label: &str) -> Option<TimeScale> {
1701    match label.trim() {
1702        "G" | "GPS" | "GPST" => Some(TimeScale::Gpst),
1703        "R" | "GLO" | "UTC" => Some(TimeScale::Utc),
1704        "E" | "GAL" | "GST" => Some(TimeScale::Gst),
1705        "C" | "BDT" => Some(TimeScale::Bdt),
1706        "J" | "QZS" | "QZSST" => Some(TimeScale::Qzsst),
1707        "TAI" => Some(TimeScale::Tai),
1708        _ => None,
1709    }
1710}
1711
1712fn time_scale_sinex_label(scale: TimeScale) -> &'static str {
1713    match scale {
1714        TimeScale::Gpst => "G",
1715        TimeScale::Utc | TimeScale::Glonasst => "R",
1716        TimeScale::Gst => "E",
1717        TimeScale::Bdt => "C",
1718        TimeScale::Qzsst => "J",
1719        TimeScale::Tai => "TAI",
1720        TimeScale::Tt => "TT",
1721        TimeScale::Tdb => "TDB",
1722    }
1723}
1724
1725fn days_in_year(year: i32) -> i32 {
1726    if is_leap_year(year) {
1727        366
1728    } else {
1729        365
1730    }
1731}
1732
1733fn is_leap_year(year: i32) -> bool {
1734    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1735}
1736
1737fn looks_like_satellite_token(token: &str) -> bool {
1738    let mut chars = token.chars();
1739    let Some(system) = chars.next() else {
1740        return false;
1741    };
1742    let Some(first_digit) = chars.next() else {
1743        return false;
1744    };
1745    GnssSystem::from_letter(system).is_some()
1746        && first_digit.is_ascii_digit()
1747        && chars.all(|c| c.is_ascii_digit())
1748}
1749
1750fn rinex_frequency(sat: GnssSatelliteId, obs: &str, glonass_channel: Option<i8>) -> Option<f64> {
1751    frequencies::rinex_observation_frequency_hz(
1752        sat.system,
1753        obs,
1754        RINEX_VERSION_FOR_BIAS_CODES,
1755        glonass_channel,
1756    )
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762    use crate::constants::{F_L1_HZ, F_L2_HZ};
1763
1764    fn sat() -> GnssSatelliteId {
1765        GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap()
1766    }
1767
1768    fn epoch(year: i32, doy: u16, sod: u32) -> Instant {
1769        bias_epoch_instant(BiasEpoch::new(year, doy, sod).unwrap(), TimeScale::Gpst).unwrap()
1770    }
1771
1772    #[test]
1773    fn bias_epoch_normalizes_inclusive_end_of_day() {
1774        let end = BiasEpoch::new(2020, 366, 86_399)
1775            .unwrap()
1776            .normalize_end()
1777            .unwrap();
1778        assert_eq!(end, BiasEpoch::new(2021, 1, 0).unwrap());
1779    }
1780
1781    #[test]
1782    fn dsb_path_resolution_uses_fewest_lexicographic_path() {
1783        let mut graph = BTreeMap::new();
1784        graph.insert(
1785            "C1C".to_string(),
1786            vec![("C1W".to_string(), 1.0), ("C1P".to_string(), 2.0)],
1787        );
1788        graph.insert("C1P".to_string(), vec![("C1W".to_string(), 3.0)]);
1789        assert_eq!(resolve_dsb_path(&graph, "C1C", "C1W"), Some(1.0));
1790    }
1791
1792    #[test]
1793    fn dcb_tgd_bridge_matches_pinned_operation_order() {
1794        let dcb_s = 4.2e-9_f64;
1795        let gamma = (F_L1_HZ / F_L2_HZ) * (F_L1_HZ / F_L2_HZ);
1796        let tgd_s = dcb_s / (1.0 - gamma);
1797        assert_eq!(tgd_s.to_bits(), 0xbe3be217807ad49e);
1798    }
1799
1800    #[test]
1801    fn ionosphere_free_bias_model_matches_closed_form_bits() {
1802        let (alpha, beta) = ionosphere_free_coefficients(F_L1_HZ, F_L2_HZ).unwrap();
1803        let used = alpha * 1.25e-9 + beta * -0.5e-9;
1804        let reference = alpha * 0.25e-9 + beta * -0.5e-9;
1805        let model = (used - reference) * C_M_S;
1806        assert_eq!(model.to_bits(), 0x3fe86c0d69376a57);
1807    }
1808
1809    #[test]
1810    fn code_query_ignores_phase_record() {
1811        let record = BiasRecord {
1812            kind: BiasKind::Osb,
1813            target: BiasTarget::Satellite(sat()),
1814            svn: None,
1815            obs1: "L1C".to_string(),
1816            obs2: None,
1817            valid_from: Some(BiasEpoch::new(2020, 1, 0).unwrap()),
1818            valid_until: Some(BiasEpoch::new(2020, 2, 0).unwrap()),
1819            raw_epochs: ("2020:001:00000".to_string(), "2020:002:00000".to_string()),
1820            value: -0.25,
1821            sigma: None,
1822            slope: None,
1823            slope_sigma: None,
1824            is_phase: true,
1825        };
1826        let set = BiasSet::new(
1827            vec![record],
1828            BiasMode::Absolute,
1829            TimeScale::Gpst,
1830            ClockReferenceObservables::default(),
1831            BiasSetHeader::default(),
1832            Diagnostics::new(),
1833        );
1834        assert_eq!(set.code_osb_seconds(sat(), "L1C", epoch(2020, 1, 0)), None);
1835        assert_eq!(
1836            set.phase_osb_cycles(sat(), "L1C", epoch(2020, 1, 0)),
1837            Some(-0.25)
1838        );
1839    }
1840}