1use 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}