use std::cmp::Ordering;
use std::collections::{BTreeMap, VecDeque};
use std::fmt::Write as _;
use std::str::FromStr;
use crate::astro::constants::time::SECONDS_PER_DAY_I64;
use crate::astro::time::civil::{day_of_year_int, seconds_between_splits, split_julian_date};
use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
use crate::astro::time::scales::julian_day_number;
use crate::constants::{C_M_S, NS_TO_S, SECONDS_PER_DAY};
use crate::format::columns::{field, fortran_f64, raw_field};
pub use crate::format::{Diagnostics, Parsed, RecordRef, Skip, SkipReason, Warning, WarningKind};
pub use crate::validate::FieldError;
use crate::validate::{self, CivilSecondPolicy};
use crate::{frequencies, GnssSatelliteId, GnssSystem};
const BIAS_SINEX_MAJOR_VERSION: &str = "1";
pub const SINEX_BIAS_SLOPE_DENOMINATOR_S: f64 = 1.0;
const DSB_INCONSISTENCY_TOL_S: f64 = 1.0e-15;
const RINEX_VERSION_FOR_BIAS_CODES: f64 = 3.04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BiasKind {
Osb,
Dsb,
Isb,
}
impl BiasKind {
fn label(self) -> &'static str {
match self {
Self::Osb => "OSB",
Self::Dsb => "DSB",
Self::Isb => "ISB",
}
}
}
impl FromStr for BiasKind {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"OSB" => Ok(Self::Osb),
"DSB" => Ok(Self::Dsb),
"ISB" => Ok(Self::Isb),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BiasTarget {
System(GnssSystem),
Satellite(GnssSatelliteId),
Receiver {
system: GnssSystem,
station: String,
},
SatelliteReceiver {
sat: GnssSatelliteId,
station: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BiasTargetKey {
pub system: GnssSystem,
pub sat: Option<GnssSatelliteId>,
pub station: Option<String>,
}
impl BiasTargetKey {
pub fn system(system: GnssSystem) -> Self {
Self {
system,
sat: None,
station: None,
}
}
pub fn satellite(sat: GnssSatelliteId) -> Self {
Self {
system: sat.system,
sat: Some(sat),
station: None,
}
}
pub fn receiver(system: GnssSystem, station: &str) -> Self {
Self {
system,
sat: None,
station: Some(normalize_station(station)),
}
}
pub fn satellite_receiver(sat: GnssSatelliteId, station: &str) -> Self {
Self {
system: sat.system,
sat: Some(sat),
station: Some(normalize_station(station)),
}
}
}
impl From<&BiasTarget> for BiasTargetKey {
fn from(value: &BiasTarget) -> Self {
match value {
BiasTarget::System(system) => Self::system(*system),
BiasTarget::Satellite(sat) => Self::satellite(*sat),
BiasTarget::Receiver { system, station } => Self::receiver(*system, station),
BiasTarget::SatelliteReceiver { sat, station } => {
Self::satellite_receiver(*sat, station)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BiasEpoch {
pub year: i32,
pub day_of_year: u16,
pub second_of_day: u32,
}
impl BiasEpoch {
pub fn new(year: i32, day_of_year: u16, second_of_day: u32) -> Result<Self, BiasError> {
if !(1..=days_in_year(year)).contains(&i32::from(day_of_year)) {
return Err(BiasError::InvalidEpoch);
}
if second_of_day > SECONDS_PER_DAY_I64 as u32 {
return Err(BiasError::InvalidEpoch);
}
Ok(Self {
year,
day_of_year,
second_of_day,
})
}
pub fn parse_sinex(token: &str) -> Result<Option<Self>, BiasError> {
let token = token.trim();
if token.is_empty() || token == "0000:000:00000" {
return Ok(None);
}
let mut parts = token.split(':');
let year = parse_int::<i32>(parts.next(), "bias epoch year")?;
let doy = parse_int::<u16>(parts.next(), "bias epoch day")?;
let sod = parse_int::<u32>(parts.next(), "bias epoch second")?;
if parts.next().is_some() {
return Err(BiasError::InvalidEpoch);
}
Self::new(year, doy, sod).map(Some)
}
pub fn format_sinex(self) -> String {
format!(
"{:04}:{:03}:{:05}",
self.year, self.day_of_year, self.second_of_day
)
}
fn to_split(self) -> Result<JulianDateSplit, BiasError> {
let jdn = julian_day_number(self.year, 1, 1) + i64::from(self.day_of_year) - 1;
let (year, month, day) = crate::astro::time::civil::civil_from_julian_day_number(jdn);
let (jd_whole, fraction) = split_julian_date(
year as i32,
month as i32,
day as i32,
0,
0,
f64::from(self.second_of_day),
);
JulianDateSplit::new(jd_whole, fraction).map_err(|_| BiasError::InvalidEpoch)
}
fn next_midnight(self) -> Result<Self, BiasError> {
let jdn = julian_day_number(self.year, 1, 1) + i64::from(self.day_of_year);
let (year, _month, _day) = crate::astro::time::civil::civil_from_julian_day_number(jdn);
let doy = day_of_year_int(year as i32, 1, 1);
let base = julian_day_number(year as i32, 1, 1);
let day = (jdn - base + doy) as u16;
Self::new(year as i32, day, 0)
}
fn normalize_end(self) -> Result<Self, BiasError> {
if self.second_of_day == 86_399 {
self.next_midnight()
} else {
Ok(self)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BiasRecord {
pub kind: BiasKind,
pub target: BiasTarget,
pub svn: Option<String>,
pub obs1: String,
pub obs2: Option<String>,
pub valid_from: Option<BiasEpoch>,
pub valid_until: Option<BiasEpoch>,
pub raw_epochs: (String, String),
pub value: f64,
pub sigma: Option<f64>,
pub slope: Option<f64>,
pub slope_sigma: Option<f64>,
pub is_phase: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BiasMode {
Absolute,
Relative,
#[default]
Unspecified,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ClockReferenceObservables {
pub per_system: BTreeMap<GnssSystem, (String, String)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct BiasSetHeader {
pub agency: Option<String>,
pub file_reference: Vec<(String, String)>,
pub description: BTreeMap<String, String>,
pub declared_bias_count: Option<usize>,
pub dcb_meta: Option<CodeDcbOptions>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BiasSet {
records: Vec<BiasRecord>,
index: BTreeMap<(BiasTargetKey, String), Vec<usize>>,
pub mode: BiasMode,
pub time_scale: TimeScale,
pub clock_reference: ClockReferenceObservables,
pub header: BiasSetHeader,
diagnostics: Diagnostics,
skipped_records: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodeDcbOptions {
pub pair: (String, String),
pub year: i32,
pub month: u8,
pub time_scale: TimeScale,
pub receiver_system: Option<GnssSystem>,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum BiasError {
#[error("invalid bias input {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
#[error("invalid bias epoch")]
InvalidEpoch,
#[error("unknown RINEX observable code {code}")]
UnknownObservable { code: String },
#[error("unsupported Bias-SINEX version {version}")]
UnsupportedVersion { version: String },
#[error("missing DCB pair or product month")]
MissingDcbMetadata,
#[error("missing satellite clock reference observables")]
MissingClockReference,
#[error("bias writer missing required metadata {field}")]
MissingWriterMetadata { field: &'static str },
#[error("input is not UTF-8")]
Utf8,
}
impl BiasSet {
pub fn parse_bias_sinex(input: &[u8]) -> Result<Parsed<BiasSet>, BiasError> {
let text = String::from_utf8_lossy(input);
parse_bias_sinex_text(text.as_ref())
}
pub fn parse_code_dcb(
input: &[u8],
options: Option<CodeDcbOptions>,
) -> Result<Parsed<BiasSet>, BiasError> {
let text = String::from_utf8_lossy(input);
parse_code_dcb_text(text.as_ref(), options)
}
pub fn diagnostics(&self) -> &Diagnostics {
&self.diagnostics
}
pub fn skipped_records(&self) -> usize {
self.skipped_records
}
pub fn code_osb_seconds(&self, sat: GnssSatelliteId, obs: &str, epoch: Instant) -> Option<f64> {
self.osb_for_target_chain(sat, obs, epoch, false)
}
pub fn phase_osb_cycles(&self, sat: GnssSatelliteId, obs: &str, epoch: Instant) -> Option<f64> {
self.osb_for_target_chain(sat, obs, epoch, true)
}
pub fn code_dsb_seconds(
&self,
sat: GnssSatelliteId,
obs1: &str,
obs2: &str,
epoch: Instant,
) -> Option<f64> {
self.dsb_for_keys(
[
BiasTargetKey::satellite(sat),
BiasTargetKey::system(sat.system),
],
obs1,
obs2,
epoch,
)
}
pub fn receiver_code_osb_seconds(
&self,
system: GnssSystem,
station: &str,
obs: &str,
epoch: Instant,
) -> Option<f64> {
self.select_record(
&BiasTargetKey::receiver(system, station),
obs,
epoch,
|record| record.kind == BiasKind::Osb && !record.is_phase,
)
.and_then(|record| self.record_value_at(record, epoch))
}
pub fn receiver_code_dsb_seconds(
&self,
system: GnssSystem,
station: &str,
obs1: &str,
obs2: &str,
epoch: Instant,
) -> Option<f64> {
self.dsb_for_key(&BiasTargetKey::receiver(system, station), obs1, obs2, epoch)
}
pub fn sat_receiver_code_osb_seconds(
&self,
sat: GnssSatelliteId,
station: &str,
obs: &str,
epoch: Instant,
) -> Option<f64> {
self.select_record(
&BiasTargetKey::satellite_receiver(sat, station),
obs,
epoch,
|record| record.kind == BiasKind::Osb && !record.is_phase,
)
.and_then(|record| self.record_value_at(record, epoch))
}
pub fn sat_receiver_code_dsb_seconds(
&self,
sat: GnssSatelliteId,
station: &str,
obs1: &str,
obs2: &str,
epoch: Instant,
) -> Option<f64> {
self.dsb_for_key(
&BiasTargetKey::satellite_receiver(sat, station),
obs1,
obs2,
epoch,
)
}
pub fn records(&self) -> &[BiasRecord] {
&self.records
}
pub fn code_bias_model_m(
&self,
sat: GnssSatelliteId,
used_observables: (&str, &str),
used_frequencies_hz: (f64, f64),
glonass_channel: Option<i8>,
clock_reference: (&str, &str),
epoch: Instant,
) -> Option<f64> {
if used_observables == clock_reference {
return Some(0.0);
}
let used_if = self.if_bias_seconds(sat, used_observables, used_frequencies_hz, epoch);
let ref_freq1 = rinex_frequency(sat, clock_reference.0, glonass_channel)?;
let ref_freq2 = rinex_frequency(sat, clock_reference.1, glonass_channel)?;
let ref_if = self.if_bias_seconds(sat, clock_reference, (ref_freq1, ref_freq2), epoch);
match (used_if, ref_if) {
(Some(used), Some(reference)) => Some((used - reference) * C_M_S),
_ => self
.relative_code_bias_seconds(
sat,
used_observables,
used_frequencies_hz,
clock_reference,
epoch,
)
.map(|seconds| seconds * C_M_S),
}
}
fn new(
records: Vec<BiasRecord>,
mode: BiasMode,
time_scale: TimeScale,
clock_reference: ClockReferenceObservables,
header: BiasSetHeader,
mut diagnostics: Diagnostics,
) -> Self {
let mut set = Self {
records,
index: BTreeMap::new(),
mode,
time_scale,
clock_reference,
header,
diagnostics: Diagnostics::new(),
skipped_records: 0,
};
set.rebuild_index(&mut diagnostics);
set.skipped_records = diagnostics.skips.len();
set.diagnostics = diagnostics;
set
}
fn rebuild_index(&mut self, diagnostics: &mut Diagnostics) {
let mut index: BTreeMap<(BiasTargetKey, String), Vec<usize>> = BTreeMap::new();
for (record_index, record) in self.records.iter().enumerate() {
let key = BiasTargetKey::from(&record.target);
index
.entry((key, obs_key(record)))
.or_default()
.push(record_index);
}
for indices in index.values_mut() {
indices.sort_by(|a, b| compare_record_start(&self.records[*a], &self.records[*b]));
for pair in indices.windows(2) {
let lhs = &self.records[pair[0]];
let rhs = &self.records[pair[1]];
if intervals_overlap(lhs, rhs) {
diagnostics.push_warning(Warning {
at: RecordRef::at_record(pair[1]),
kind: WarningKind::Overlap,
});
}
}
}
self.index = index;
}
fn osb_for_target_chain(
&self,
sat: GnssSatelliteId,
obs: &str,
epoch: Instant,
phase: bool,
) -> Option<f64> {
for key in [
BiasTargetKey::satellite(sat),
BiasTargetKey::system(sat.system),
] {
if let Some(value) = self
.select_record(&key, obs, epoch, |record| {
record.kind == BiasKind::Osb && record.is_phase == phase
})
.and_then(|record| self.record_value_at(record, epoch))
{
return Some(value);
}
}
None
}
fn if_bias_seconds(
&self,
sat: GnssSatelliteId,
observables: (&str, &str),
frequencies_hz: (f64, f64),
epoch: Instant,
) -> Option<f64> {
let obs1 = self.code_osb_seconds(sat, observables.0, epoch)?;
let obs2 = self.code_osb_seconds(sat, observables.1, epoch)?;
let (alpha, beta) = ionosphere_free_coefficients(frequencies_hz.0, frequencies_hz.1)?;
Some(alpha * obs1 + beta * obs2)
}
fn relative_code_bias_seconds(
&self,
sat: GnssSatelliteId,
used_observables: (&str, &str),
used_frequencies_hz: (f64, f64),
clock_reference: (&str, &str),
epoch: Instant,
) -> Option<f64> {
let d1 = if used_observables.0 == clock_reference.0 {
0.0
} else {
self.code_dsb_seconds(sat, used_observables.0, clock_reference.0, epoch)?
};
let d2 = if used_observables.1 == clock_reference.1 {
0.0
} else {
self.code_dsb_seconds(sat, used_observables.1, clock_reference.1, epoch)?
};
let (alpha, beta) =
ionosphere_free_coefficients(used_frequencies_hz.0, used_frequencies_hz.1)?;
Some(alpha * d1 + beta * d2)
}
fn dsb_for_keys<const N: usize>(
&self,
keys: [BiasTargetKey; N],
obs1: &str,
obs2: &str,
epoch: Instant,
) -> Option<f64> {
for key in keys {
if let Some(value) = self.dsb_for_key(&key, obs1, obs2, epoch) {
return Some(value);
}
}
None
}
fn dsb_for_key(
&self,
target_key: &BiasTargetKey,
obs1: &str,
obs2: &str,
epoch: Instant,
) -> Option<f64> {
if obs1 == obs2 {
return Some(0.0);
}
let graph = self.dsb_graph(target_key, epoch);
resolve_dsb_path(&graph, obs1, obs2)
}
fn dsb_graph(
&self,
target_key: &BiasTargetKey,
epoch: Instant,
) -> BTreeMap<String, Vec<(String, f64)>> {
let mut graph: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new();
for record in &self.records {
if BiasTargetKey::from(&record.target) != *target_key
|| record.kind != BiasKind::Dsb
|| record.is_phase
|| !self.record_covers_epoch(record, epoch)
{
continue;
}
let Some(value) = self.record_value_at(record, epoch) else {
continue;
};
let Some(obs2) = record.obs2.as_ref() else {
continue;
};
graph
.entry(record.obs1.clone())
.or_default()
.push((obs2.clone(), value));
graph
.entry(obs2.clone())
.or_default()
.push((record.obs1.clone(), -value));
}
for edges in graph.values_mut() {
edges.sort_by(|a, b| a.0.cmp(&b.0));
}
graph
}
fn select_record(
&self,
target_key: &BiasTargetKey,
obs_key: &str,
epoch: Instant,
predicate: impl Fn(&BiasRecord) -> bool,
) -> Option<&BiasRecord> {
let mut selected = None;
let indices = self.index.get(&(target_key.clone(), obs_key.to_string()))?;
for &index in indices {
let record = &self.records[index];
if predicate(record) && self.record_covers_epoch(record, epoch) {
selected = Some(record);
}
}
selected
}
fn record_covers_epoch(&self, record: &BiasRecord, epoch: Instant) -> bool {
if epoch.scale != self.time_scale {
return false;
}
let Some(query) = instant_split(epoch) else {
return false;
};
if let Some(from) = record.valid_from {
let Ok(from) = from.to_split() else {
return false;
};
if seconds_between_splits(query.jd_whole, query.fraction, from.jd_whole, from.fraction)
< 0.0
{
return false;
}
}
if let Some(until) = record.valid_until {
let Ok(until) = until.to_split() else {
return false;
};
if seconds_between_splits(
query.jd_whole,
query.fraction,
until.jd_whole,
until.fraction,
) >= 0.0
{
return false;
}
}
true
}
fn record_value_at(&self, record: &BiasRecord, epoch: Instant) -> Option<f64> {
if !self.record_covers_epoch(record, epoch) {
return None;
}
let Some(slope) = record.slope else {
return Some(record.value);
};
let from = record.valid_from?.to_split().ok()?;
let query = instant_split(epoch)?;
let dt_s =
seconds_between_splits(query.jd_whole, query.fraction, from.jd_whole, from.fraction);
Some(record.value + slope * dt_s)
}
}
pub fn write_bias_sinex(set: &BiasSet) -> Result<String, BiasError> {
let agency = set
.header
.agency
.as_deref()
.ok_or(BiasError::MissingWriterMetadata { field: "agency" })?;
if set.mode == BiasMode::Unspecified {
return Err(BiasError::MissingWriterMetadata { field: "mode" });
}
if set.clock_reference.per_system.is_empty() {
return Err(BiasError::MissingWriterMetadata {
field: "clock_reference",
});
}
let mut out = String::new();
writeln!(&mut out, "%=BIA 1.00 {agency}").expect("write string");
writeln!(&mut out, "+FILE/REFERENCE").expect("write string");
if set.header.file_reference.is_empty() {
writeln!(&mut out, " DESCRIPTION GENERATED").expect("write string");
} else {
for (key, value) in &set.header.file_reference {
writeln!(&mut out, " {key} {value}").expect("write string");
}
}
writeln!(&mut out, "-FILE/REFERENCE").expect("write string");
writeln!(&mut out, "+BIAS/DESCRIPTION").expect("write string");
writeln!(
&mut out,
" BIAS_MODE {}",
match set.mode {
BiasMode::Absolute => "ABSOLUTE",
BiasMode::Relative => "RELATIVE",
BiasMode::Unspecified => "UNSPECIFIED",
}
)
.expect("write string");
writeln!(
&mut out,
" TIME_SYSTEM {}",
time_scale_sinex_label(set.time_scale)
)
.expect("write string");
for (system, (obs1, obs2)) in &set.clock_reference.per_system {
writeln!(
&mut out,
" SATELLITE_CLOCK_REFERENCE_OBSERVABLES {} {obs1} {obs2}",
system.letter()
)
.expect("write string");
}
for (key, value) in &set.header.description {
if matches!(
key.as_str(),
"BIAS_MODE" | "TIME_SYSTEM" | "SATELLITE_CLOCK_REFERENCE_OBSERVABLES"
) {
continue;
}
writeln!(&mut out, " {key} {value}").expect("write string");
}
writeln!(&mut out, "-BIAS/DESCRIPTION").expect("write string");
writeln!(&mut out, "+BIAS/SOLUTION {}", set.records.len()).expect("write string");
writeln!(
&mut out,
"*BIAS SVN_ PRN STATION__ OBS1 OBS2 BIAS_START____ BIAS_END______ UNIT __ESTIMATED_VALUE____ _STD_DEV___"
)
.expect("write string");
for record in &set.records {
writeln!(&mut out, "{}", format_sinex_solution_record(record)).expect("write string");
}
writeln!(&mut out, "-BIAS/SOLUTION").expect("write string");
Ok(out)
}
pub fn write_code_dcb(set: &BiasSet) -> Result<String, BiasError> {
let meta = set
.header
.dcb_meta
.as_ref()
.ok_or(BiasError::MissingWriterMetadata { field: "dcb_meta" })?;
let mut out = String::new();
writeln!(
&mut out,
"# DCB {}-{} {:04}-{:02} {}",
meta.pair.0,
meta.pair.1,
meta.year,
meta.month,
time_scale_sinex_label(meta.time_scale)
)
.expect("write string");
writeln!(&mut out, " PRN / STATION NAME VALUE (ns) RMS (ns)").expect("write string");
writeln!(
&mut out,
"*** **************** *********.*** *****.***"
)
.expect("write string");
for record in &set.records {
if record.kind != BiasKind::Dsb || record.is_phase {
return Err(BiasError::InvalidInput {
field: "bias set",
reason: "CODE DCB writer requires code DSB records",
});
}
let value_ns = record.value / NS_TO_S;
let sigma_ns = record.sigma.unwrap_or(0.0) / NS_TO_S;
match &record.target {
BiasTarget::Satellite(sat) => {
writeln!(
&mut out,
" {sat:<3} {value_ns:10.3} {sigma_ns:10.3}"
)
.expect("write string");
}
BiasTarget::Receiver { station, .. } => {
writeln!(
&mut out,
" {station:<16} {value_ns:10.3} {sigma_ns:10.3}"
)
.expect("write string");
}
_ => {
return Err(BiasError::InvalidInput {
field: "bias target",
reason: "CODE DCB writer supports satellite and receiver records",
});
}
}
}
Ok(out)
}
pub fn ionosphere_free_coefficients(f1_hz: f64, f2_hz: f64) -> Option<(f64, f64)> {
validate::finite_positive(f1_hz, "f1_hz").ok()?;
validate::finite_positive(f2_hz, "f2_hz").ok()?;
let f1_2 = f1_hz * f1_hz;
let f2_2 = f2_hz * f2_hz;
let denom = f1_2 - f2_2;
if denom == 0.0 {
return None;
}
let alpha = f1_2 / denom;
let beta = -f2_2 / denom;
Some((alpha, beta))
}
fn parse_bias_sinex_text(text: &str) -> Result<Parsed<BiasSet>, BiasError> {
let mut diagnostics = Diagnostics::new();
let mut header = BiasSetHeader::default();
let mut mode = BiasMode::Unspecified;
let mut time_scale = TimeScale::Gpst;
let mut clock_reference = ClockReferenceObservables::default();
let mut records = Vec::new();
let mut current_block: Option<String> = None;
let mut saw_file_reference = false;
let mut solution_count_line = None;
let first = text.lines().next().ok_or(BiasError::InvalidInput {
field: "input",
reason: "empty",
})?;
parse_bias_sinex_header(first, &mut header)?;
for (line_index, line) in text.lines().enumerate() {
let line_number = line_index + 1;
let trimmed = line.trim_end();
if line_number == 1 || trimmed.is_empty() {
continue;
}
if let Some(block) = trimmed.strip_prefix('+') {
let mut parts = block.split_whitespace();
let name = parts.next().unwrap_or("").to_string();
if name == "BIAS/SOLUTION" {
solution_count_line = Some(line_number);
if let Some(count) = parts.next() {
if let Ok(count) = count.parse::<usize>() {
header.declared_bias_count = Some(count);
}
}
} else if !matches!(name.as_str(), "FILE/REFERENCE" | "BIAS/DESCRIPTION") {
diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason: SkipReason::UnknownBlock(name.clone()),
});
}
if name == "FILE/REFERENCE" {
saw_file_reference = true;
}
current_block = Some(name);
continue;
}
if trimmed.starts_with('-') {
current_block = None;
continue;
}
if trimmed.starts_with('*') {
continue;
}
match current_block.as_deref() {
Some("FILE/REFERENCE") => parse_file_reference_line(trimmed, &mut header),
Some("BIAS/DESCRIPTION") => parse_description_line(
trimmed,
&mut header,
&mut mode,
&mut time_scale,
&mut clock_reference,
&mut diagnostics,
line_number,
),
Some("BIAS/SOLUTION") => match parse_solution_line(trimmed) {
Ok(record) => {
if mode == BiasMode::Absolute && record.kind != BiasKind::Osb {
diagnostics.push_warning(Warning {
at: RecordRef::at_line(line_number),
kind: WarningKind::Mismatch,
});
}
if mode == BiasMode::Relative && record.kind == BiasKind::Osb {
diagnostics.push_warning(Warning {
at: RecordRef::at_line(line_number),
kind: WarningKind::Mismatch,
});
}
records.push(record);
}
Err(reason) => diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason,
}),
},
Some(_) | None => {}
}
}
if !saw_file_reference {
diagnostics.push_warning(Warning {
at: RecordRef::at_line(1),
kind: WarningKind::MissingMetadata,
});
}
if let Some(declared) = header.declared_bias_count {
if declared != records.len() {
diagnostics.push_warning(Warning {
at: RecordRef::at_line(solution_count_line.unwrap_or(1)),
kind: WarningKind::Mismatch,
});
}
}
let set = BiasSet::new(
records,
mode,
time_scale,
clock_reference,
header,
diagnostics,
);
let diagnostics = set.diagnostics.clone();
Ok(Parsed::new(set, diagnostics))
}
fn parse_code_dcb_text(
text: &str,
options: Option<CodeDcbOptions>,
) -> Result<Parsed<BiasSet>, BiasError> {
let title_meta = parse_dcb_title_metadata(text);
let options = match (options, title_meta) {
(Some(options), Some(title)) => {
if options.pair != title.pair
|| options.year != title.year
|| options.month != title.month
|| options.time_scale != title.time_scale
{
return Err(BiasError::InvalidInput {
field: "CodeDcbOptions",
reason: "does not match title metadata",
});
}
options
}
(Some(options), None) => options,
(None, Some(title)) => title,
(None, None) => return Err(BiasError::MissingDcbMetadata),
};
validate_dcb_options(&options)?;
let mut diagnostics = Diagnostics::new();
let (valid_from, valid_until) = dcb_month_interval(options.year, options.month)?;
let raw_epochs = (
valid_from.format_sinex(),
valid_until
.map(BiasEpoch::format_sinex)
.unwrap_or_else(|| "0000:000:00000".to_string()),
);
let mut records = Vec::new();
for (line_index, line) in text.lines().enumerate() {
let line_number = line_index + 1;
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with('*')
|| trimmed.starts_with("PRN")
|| trimmed.contains("VALUE")
|| !looks_like_dcb_row(line, &options)
{
continue;
}
let row = match parse_dcb_row(line, &options) {
Ok(Some(row)) => row,
Ok(None) => continue,
Err(reason) => {
diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason,
});
continue;
}
};
let Some((obs1, obs2)) = map_legacy_dcb_pair(row.system, &options.pair.0, &options.pair.1)
else {
diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason: SkipReason::UnsupportedRecordType("DCB_PAIR"),
});
continue;
};
records.push(BiasRecord {
kind: BiasKind::Dsb,
target: row.target,
svn: None,
obs1,
obs2: Some(obs2),
valid_from: Some(valid_from),
valid_until,
raw_epochs: raw_epochs.clone(),
value: row.value_ns * NS_TO_S,
sigma: row.sigma_ns.map(|sigma| sigma * NS_TO_S),
slope: None,
slope_sigma: None,
is_phase: false,
});
}
let mut header = BiasSetHeader {
dcb_meta: Some(options.clone()),
..BiasSetHeader::default()
};
header.description.insert(
"DCB_PAIR".to_string(),
format!("{} {}", options.pair.0, options.pair.1),
);
let set = BiasSet::new(
records,
BiasMode::Relative,
options.time_scale,
ClockReferenceObservables::default(),
header,
diagnostics,
);
let diagnostics = set.diagnostics.clone();
Ok(Parsed::new(set, diagnostics))
}
struct DcbRow {
target: BiasTarget,
system: GnssSystem,
value_ns: f64,
sigma_ns: Option<f64>,
}
fn parse_dcb_row(line: &str, options: &CodeDcbOptions) -> Result<Option<DcbRow>, SkipReason> {
let Some(value_token) = field(line, 24, 38) else {
return Ok(None);
};
let value_ns = fortran_f64(line, 24, 38, "dcb value").ok_or_else(|| {
SkipReason::MalformedField(FieldError::FloatParse {
field: "dcb value",
value: value_token.to_string(),
})
})?;
let sigma_ns = fortran_f64(line, 38, 50, "dcb sigma");
if let Some(sat_token) = field(line, 0, 4).filter(|token| looks_like_satellite_token(token)) {
let sat = sat_token
.parse::<GnssSatelliteId>()
.map_err(|_| SkipReason::UnrepresentableSatellite)?;
return Ok(Some(DcbRow {
target: BiasTarget::Satellite(sat),
system: sat.system,
value_ns,
sigma_ns,
}));
}
if let Some(system) =
field(line, 0, 1).and_then(|token| token.chars().next().and_then(GnssSystem::from_letter))
{
let station = field(line, 6, 10).or_else(|| field(line, 6, 22)).ok_or(
SkipReason::InconsistentRecord("receiver DCB record lacks station"),
)?;
return Ok(Some(DcbRow {
target: BiasTarget::Receiver {
system,
station: normalize_station(station),
},
system,
value_ns,
sigma_ns,
}));
}
if let Some(system) = options.receiver_system {
let station = field(line, 6, 22).or_else(|| field(line, 0, 22)).ok_or(
SkipReason::InconsistentRecord("receiver DCB record lacks station"),
)?;
return Ok(Some(DcbRow {
target: BiasTarget::Receiver {
system,
station: normalize_station(station),
},
system,
value_ns,
sigma_ns,
}));
}
Err(SkipReason::InconsistentRecord(
"receiver DCB record lacks system",
))
}
fn looks_like_dcb_row(line: &str, options: &CodeDcbOptions) -> bool {
if field(line, 0, 4).is_some_and(looks_like_satellite_token) {
return true;
}
if field(line, 0, 1)
.and_then(|token| token.chars().next().and_then(GnssSystem::from_letter))
.is_some()
&& raw_field(line, 1, 6).trim().is_empty()
{
return true;
}
options.receiver_system.is_some()
&& field(line, 24, 38).is_some_and(|token| validate::strict_f64(token, "dcb value").is_ok())
}
fn parse_bias_sinex_header(line: &str, header: &mut BiasSetHeader) -> Result<(), BiasError> {
let mut tokens = line.split_whitespace();
let Some(marker) = tokens.next() else {
return Err(BiasError::InvalidInput {
field: "header",
reason: "missing marker",
});
};
if marker != "%=BIA" {
return Err(BiasError::InvalidInput {
field: "header",
reason: "missing %=BIA marker",
});
}
let version = tokens.next().ok_or(BiasError::InvalidInput {
field: "version",
reason: "missing",
})?;
if !version.starts_with(BIAS_SINEX_MAJOR_VERSION) {
return Err(BiasError::UnsupportedVersion {
version: version.to_string(),
});
}
header.agency = tokens.next().map(str::to_string);
Ok(())
}
fn parse_file_reference_line(line: &str, header: &mut BiasSetHeader) {
let mut parts = line.split_whitespace();
if let Some(key) = parts.next() {
let value = parts.collect::<Vec<_>>().join(" ");
header.file_reference.push((key.to_string(), value));
}
}
fn parse_description_line(
line: &str,
header: &mut BiasSetHeader,
mode: &mut BiasMode,
time_scale: &mut TimeScale,
clock_reference: &mut ClockReferenceObservables,
diagnostics: &mut Diagnostics,
line_number: usize,
) {
let mut parts = line.split_whitespace();
let Some(key) = parts.next() else {
return;
};
match key {
"BIAS_MODE" => {
let value = parts.next().unwrap_or("");
*mode = match value {
"ABSOLUTE" => BiasMode::Absolute,
"RELATIVE" => BiasMode::Relative,
_ => BiasMode::Unspecified,
};
header
.description
.insert(key.to_string(), value.to_string());
}
"TIME_SYSTEM" => {
let value = parts.next().unwrap_or("");
match parse_sinex_time_scale(value) {
Some(scale) => {
*time_scale = scale;
header
.description
.insert(key.to_string(), value.to_string());
}
None => diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason: SkipReason::MalformedField(FieldError::FloatParse {
field: "time system",
value: value.to_string(),
}),
}),
}
}
"SATELLITE_CLOCK_REFERENCE_OBSERVABLES" => {
let system_token = parts.next().unwrap_or("");
let obs1 = parts.next().unwrap_or("");
let obs2 = parts.next().unwrap_or("");
if let Some(system) = system_token
.chars()
.next()
.and_then(GnssSystem::from_letter)
{
clock_reference
.per_system
.insert(system, (obs1.to_string(), obs2.to_string()));
header.description.insert(
format!("{key}_{}", system.letter()),
format!("{obs1} {obs2}"),
);
} else {
diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason: SkipReason::MalformedField(FieldError::IntParse {
field: "system",
value: system_token.to_string(),
}),
});
}
}
_ => {
let value = parts.collect::<Vec<_>>().join(" ");
header.description.insert(key.to_string(), value);
}
}
}
fn parse_solution_line(line: &str) -> Result<BiasRecord, SkipReason> {
if line.len() < 91 {
return Err(SkipReason::Truncated);
}
let kind = field(line, 1, 5)
.ok_or(SkipReason::Truncated)?
.parse::<BiasKind>()
.map_err(|_| SkipReason::UnsupportedRecordType("BIAS"))?;
let svn = field(line, 6, 10).map(str::to_string);
let prn = field(line, 11, 14);
let station = field(line, 15, 24).map(normalize_station);
let obs1 = field(line, 25, 29)
.ok_or(SkipReason::Truncated)?
.to_string();
let obs2 = field(line, 30, 34).map(str::to_string);
let raw_start = raw_field(line, 35, 49).trim().to_string();
let raw_end = raw_field(line, 50, 64).trim().to_string();
let unit = field(line, 65, 69).ok_or(SkipReason::Truncated)?;
let value = fortran_f64(line, 70, 91, "bias value").ok_or_else(|| {
SkipReason::MalformedField(FieldError::FloatParse {
field: "bias value",
value: raw_field(line, 70, 91).trim().to_string(),
})
})?;
let sigma = fortran_f64(line, 92, 103, "bias sigma");
let slope = fortran_f64(line, 104, 125, "bias slope");
let slope_sigma = fortran_f64(line, 126, 137, "bias slope sigma");
let is_phase = match unit {
"ns" => false,
"cyc" => true,
other => return Err(SkipReason::UnsupportedUnit(other.to_string())),
};
if kind == BiasKind::Osb && obs2.is_some() {
return Err(SkipReason::InconsistentRecord("OSB must not carry OBS2"));
}
if kind != BiasKind::Osb && obs2.is_none() {
return Err(SkipReason::InconsistentRecord("DSB or ISB requires OBS2"));
}
let valid_from = BiasEpoch::parse_sinex(&raw_start).map_err(|_| {
SkipReason::MalformedField(FieldError::IntParse {
field: "bias start",
value: raw_start.clone(),
})
})?;
let valid_until = BiasEpoch::parse_sinex(&raw_end)
.map_err(|_| {
SkipReason::MalformedField(FieldError::IntParse {
field: "bias end",
value: raw_end.clone(),
})
})?
.map(|epoch| epoch.normalize_end())
.transpose()
.map_err(|_| {
SkipReason::MalformedField(FieldError::IntParse {
field: "bias end",
value: raw_end.clone(),
})
})?;
let target = parse_bias_target(svn.as_deref(), prn, station.as_deref())?;
let value = if is_phase { value } else { value * NS_TO_S };
let sigma = sigma.map(|sigma| if is_phase { sigma } else { sigma * NS_TO_S });
let slope = slope.map(|slope| {
if is_phase {
slope / SINEX_BIAS_SLOPE_DENOMINATOR_S
} else {
slope * NS_TO_S / SINEX_BIAS_SLOPE_DENOMINATOR_S
}
});
let slope_sigma = slope_sigma.map(|slope_sigma| {
if is_phase {
slope_sigma / SINEX_BIAS_SLOPE_DENOMINATOR_S
} else {
slope_sigma * NS_TO_S / SINEX_BIAS_SLOPE_DENOMINATOR_S
}
});
Ok(BiasRecord {
kind,
target,
svn,
obs1,
obs2,
valid_from,
valid_until,
raw_epochs: (raw_start, raw_end),
value,
sigma,
slope,
slope_sigma,
is_phase,
})
}
fn parse_bias_target(
_svn: Option<&str>,
prn: Option<&str>,
station: Option<&str>,
) -> Result<BiasTarget, SkipReason> {
match (prn, station) {
(Some(prn), None) if prn.len() == 1 => {
let system = prn.chars().next().and_then(GnssSystem::from_letter).ok_or(
SkipReason::MalformedField(FieldError::IntParse {
field: "system",
value: prn.to_string(),
}),
)?;
Ok(BiasTarget::System(system))
}
(Some(prn), Some(station)) if prn.len() == 1 => {
let system = prn.chars().next().and_then(GnssSystem::from_letter).ok_or(
SkipReason::MalformedField(FieldError::IntParse {
field: "system",
value: prn.to_string(),
}),
)?;
Ok(BiasTarget::Receiver {
system,
station: normalize_station(station),
})
}
(Some(prn), None) => prn
.parse::<GnssSatelliteId>()
.map(BiasTarget::Satellite)
.map_err(|_| SkipReason::UnrepresentableSatellite),
(Some(prn), Some(station)) => prn
.parse::<GnssSatelliteId>()
.map(|sat| BiasTarget::SatelliteReceiver {
sat,
station: normalize_station(station),
})
.map_err(|_| SkipReason::UnrepresentableSatellite),
(None, Some(_)) | (None, None) => Err(SkipReason::InconsistentRecord("missing PRN")),
}
}
fn parse_dcb_title_metadata(text: &str) -> Option<CodeDcbOptions> {
for line in text.lines().take(12) {
let mut pair = None;
let mut year = None;
let mut month = None;
let mut scale = None;
let tokens = line
.split_whitespace()
.map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
.collect::<Vec<_>>();
for (index, token) in tokens.iter().enumerate() {
if let Some((a, b)) = token.split_once('-') {
if is_legacy_dcb_label(a) && is_legacy_dcb_label(b) {
pair = Some((a.to_string(), b.to_string()));
}
}
if let Some((y, m)) = token.split_once('-') {
if let (Ok(y), Ok(m)) = (y.parse::<i32>(), m.parse::<u8>()) {
year = Some(y);
month = Some(m);
}
}
if token.eq_ignore_ascii_case("YEAR") {
year = tokens
.get(index + 1)
.and_then(|value| value.parse::<i32>().ok());
}
if token.eq_ignore_ascii_case("MONTH") {
month = tokens
.get(index + 1)
.and_then(|value| value.parse::<u8>().ok());
}
scale = scale.or_else(|| parse_sinex_time_scale(token));
}
if let (Some(pair), Some(year), Some(month)) = (pair, year, month) {
return Some(CodeDcbOptions {
pair,
year,
month,
time_scale: scale.unwrap_or(TimeScale::Gpst),
receiver_system: None,
});
}
}
None
}
fn validate_dcb_options(options: &CodeDcbOptions) -> Result<(), BiasError> {
if !is_legacy_dcb_label(&options.pair.0) || !is_legacy_dcb_label(&options.pair.1) {
return Err(BiasError::InvalidInput {
field: "pair",
reason: "unknown legacy DCB label",
});
}
if !(1..=12).contains(&options.month) {
return Err(BiasError::InvalidInput {
field: "month",
reason: "out of range",
});
}
Ok(())
}
fn dcb_month_interval(year: i32, month: u8) -> Result<(BiasEpoch, Option<BiasEpoch>), BiasError> {
let start = month_epoch(year, month)?;
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
Ok((start, Some(month_epoch(next_year, next_month)?)))
}
fn month_epoch(year: i32, month: u8) -> Result<BiasEpoch, BiasError> {
let doy = day_of_year_int(year, i32::from(month), 1);
BiasEpoch::new(year, doy as u16, 0)
}
fn map_legacy_dcb_pair(system: GnssSystem, left: &str, right: &str) -> Option<(String, String)> {
let left = map_legacy_dcb_label(system, left)?;
let right = map_legacy_dcb_label(system, right)?;
Some((left.to_string(), right.to_string()))
}
fn map_legacy_dcb_label(system: GnssSystem, label: &str) -> Option<&'static str> {
match (system, label) {
(GnssSystem::Gps, "P1") => Some("C1W"),
(GnssSystem::Gps, "P2") => Some("C2W"),
(GnssSystem::Gps, "C1") => Some("C1C"),
(GnssSystem::Gps, "C2") => Some("C2C"),
(GnssSystem::Glonass, "P1") => Some("C1P"),
(GnssSystem::Glonass, "P2") => Some("C2P"),
(GnssSystem::Glonass, "C1") => Some("C1C"),
(GnssSystem::Glonass, "C2") => Some("C2C"),
(GnssSystem::Galileo, "P1") => Some("C1X"),
(GnssSystem::Galileo, "P2") => Some("C5X"),
(GnssSystem::Galileo, "C1") => Some("C1C"),
(GnssSystem::Galileo, "C2") => Some("C5Q"),
(GnssSystem::BeiDou, "P1") => Some("C2I"),
(GnssSystem::BeiDou, "P2") => Some("C6I"),
(GnssSystem::BeiDou, "C1") => Some("C2I"),
(GnssSystem::BeiDou, "C2") => Some("C7I"),
_ => None,
}
}
fn is_legacy_dcb_label(label: &str) -> bool {
matches!(label, "P1" | "P2" | "C1" | "C2")
}
fn format_sinex_solution_record(record: &BiasRecord) -> String {
let (svn, prn, station) = target_fields(record);
let obs2 = record.obs2.as_deref().unwrap_or("");
let (unit, value, sigma, slope, slope_sigma) = if record.is_phase {
(
"cyc",
record.value,
record.sigma,
record.slope.map(|s| s * SINEX_BIAS_SLOPE_DENOMINATOR_S),
record
.slope_sigma
.map(|s| s * SINEX_BIAS_SLOPE_DENOMINATOR_S),
)
} else {
(
"ns",
record.value / NS_TO_S,
record.sigma.map(|s| s / NS_TO_S),
record
.slope
.map(|s| s / NS_TO_S * SINEX_BIAS_SLOPE_DENOMINATOR_S),
record
.slope_sigma
.map(|s| s / NS_TO_S * SINEX_BIAS_SLOPE_DENOMINATOR_S),
)
};
let start = if record.raw_epochs.0.is_empty() {
record
.valid_from
.map(BiasEpoch::format_sinex)
.unwrap_or_else(|| "0000:000:00000".to_string())
} else {
record.raw_epochs.0.clone()
};
let end = if record.raw_epochs.1.is_empty() {
record
.valid_until
.map(BiasEpoch::format_sinex)
.unwrap_or_else(|| "0000:000:00000".to_string())
} else {
record.raw_epochs.1.clone()
};
let mut line = format!(
" {:<4} {:<4} {:<3} {:<9} {:<4} {:<4} {:<14} {:<14} {:<4} {:>21.12E}",
record.kind.label(),
svn,
prn,
station,
record.obs1,
obs2,
start,
end,
unit,
value
);
if let Some(sigma) = sigma {
write!(&mut line, " {sigma:>11.5E}").expect("write string");
} else {
line.push_str(&" ".repeat(12));
}
if let Some(slope) = slope {
write!(&mut line, " {slope:>21.12E}").expect("write string");
if let Some(slope_sigma) = slope_sigma {
write!(&mut line, " {slope_sigma:>11.5E}").expect("write string");
}
}
line
}
fn target_fields(record: &BiasRecord) -> (String, String, String) {
match &record.target {
BiasTarget::System(system) => (
record.svn.clone().unwrap_or_default(),
system.letter().to_string(),
String::new(),
),
BiasTarget::Satellite(sat) => (
record.svn.clone().unwrap_or_default(),
sat.to_string(),
String::new(),
),
BiasTarget::Receiver { system, station } => (
record.svn.clone().unwrap_or_default(),
system.letter().to_string(),
station.clone(),
),
BiasTarget::SatelliteReceiver { sat, station } => (
record.svn.clone().unwrap_or_default(),
sat.to_string(),
station.clone(),
),
}
}
fn resolve_dsb_path(
graph: &BTreeMap<String, Vec<(String, f64)>>,
start: &str,
end: &str,
) -> Option<f64> {
let mut queue = VecDeque::new();
let mut best_depth = None;
let mut candidates = Vec::new();
queue.push_back((vec![start.to_string()], 0.0));
while let Some((path, value)) = queue.pop_front() {
let depth = path.len() - 1;
if best_depth.is_some_and(|best| depth > best) {
continue;
}
let node = path.last().expect("path contains start");
if node == end {
best_depth = Some(depth);
candidates.push((path, value));
continue;
}
for (next, edge_value) in graph.get(node).into_iter().flatten() {
if path.iter().any(|seen| seen == next) {
continue;
}
let mut next_path = path.clone();
next_path.push(next.clone());
queue.push_back((next_path, value + edge_value));
}
}
candidates.sort_by(|a, b| a.0.cmp(&b.0));
let (_, first) = candidates.first()?;
if candidates
.iter()
.any(|(_, value)| (value - first).abs() > DSB_INCONSISTENCY_TOL_S)
{
return None;
}
Some(*first)
}
fn compare_record_start(a: &BiasRecord, b: &BiasRecord) -> Ordering {
a.valid_from
.cmp(&b.valid_from)
.then_with(|| a.raw_epochs.0.cmp(&b.raw_epochs.0))
}
fn intervals_overlap(a: &BiasRecord, b: &BiasRecord) -> bool {
let a_start = a.valid_from;
let b_start = b.valid_from;
let a_end = a.valid_until;
let b_end = b.valid_until;
let a_before_b_end = b_end.is_none_or(|end| a_start.is_none_or(|start| start < end));
let b_before_a_end = a_end.is_none_or(|end| b_start.is_none_or(|start| start < end));
a_before_b_end && b_before_a_end
}
fn obs_key(record: &BiasRecord) -> String {
match record.kind {
BiasKind::Osb => record.obs1.clone(),
BiasKind::Dsb | BiasKind::Isb => {
format!("{}-{}", record.obs1, record.obs2.as_deref().unwrap_or(""))
}
}
}
fn normalize_station(station: &str) -> String {
let upper = station.trim().to_ascii_uppercase();
if upper.len() > 4 && upper.as_bytes()[..4].iter().all(u8::is_ascii_alphanumeric) {
upper[..4].to_string()
} else {
upper
}
}
fn instant_split(epoch: Instant) -> Option<JulianDateSplit> {
match epoch.repr {
InstantRepr::JulianDate(split) => Some(split),
InstantRepr::Nanos(nanos) => {
let seconds = nanos as f64 * NS_TO_S;
let days = seconds.div_euclid(SECONDS_PER_DAY);
let rem = seconds.rem_euclid(SECONDS_PER_DAY);
JulianDateSplit::new(crate::constants::J2000_JD + days, rem / SECONDS_PER_DAY).ok()
}
}
}
pub fn bias_epoch_instant(epoch: BiasEpoch, scale: TimeScale) -> Result<Instant, BiasError> {
Ok(Instant::from_julian_date(scale, epoch.to_split()?))
}
pub fn civil_datetime_instant(
epoch: crate::ppp_corrections::CivilDateTime,
scale: TimeScale,
) -> Result<Instant, BiasError> {
let second_policy = match scale {
TimeScale::Utc | TimeScale::Glonasst => CivilSecondPolicy::UtcLike,
_ => CivilSecondPolicy::Continuous,
};
validate::civil_datetime_with_second_policy(
i64::from(epoch.year),
i64::from(epoch.month),
i64::from(epoch.day),
i64::from(epoch.hour),
i64::from(epoch.minute),
epoch.second,
second_policy,
)
.map_err(|_| BiasError::InvalidEpoch)?;
let (jd_whole, fraction) = split_julian_date(
epoch.year,
i32::from(epoch.month),
i32::from(epoch.day),
i32::from(epoch.hour),
i32::from(epoch.minute),
epoch.second,
);
Ok(Instant::from_julian_date(
scale,
JulianDateSplit::new(jd_whole, fraction).map_err(|_| BiasError::InvalidEpoch)?,
))
}
fn parse_int<T>(token: Option<&str>, field: &'static str) -> Result<T, BiasError>
where
T: FromStr,
{
let token = token.ok_or(BiasError::InvalidInput {
field,
reason: "missing",
})?;
token.parse::<T>().map_err(|_| BiasError::InvalidInput {
field,
reason: "invalid integer",
})
}
fn parse_sinex_time_scale(label: &str) -> Option<TimeScale> {
match label.trim() {
"G" | "GPS" | "GPST" => Some(TimeScale::Gpst),
"R" | "GLO" | "UTC" => Some(TimeScale::Utc),
"E" | "GAL" | "GST" => Some(TimeScale::Gst),
"C" | "BDT" => Some(TimeScale::Bdt),
"J" | "QZS" | "QZSST" => Some(TimeScale::Qzsst),
"TAI" => Some(TimeScale::Tai),
_ => None,
}
}
fn time_scale_sinex_label(scale: TimeScale) -> &'static str {
match scale {
TimeScale::Gpst => "G",
TimeScale::Utc | TimeScale::Glonasst => "R",
TimeScale::Gst => "E",
TimeScale::Bdt => "C",
TimeScale::Qzsst => "J",
TimeScale::Tai => "TAI",
TimeScale::Tt => "TT",
TimeScale::Tdb => "TDB",
}
}
fn days_in_year(year: i32) -> i32 {
if is_leap_year(year) {
366
} else {
365
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn looks_like_satellite_token(token: &str) -> bool {
let mut chars = token.chars();
let Some(system) = chars.next() else {
return false;
};
let Some(first_digit) = chars.next() else {
return false;
};
GnssSystem::from_letter(system).is_some()
&& first_digit.is_ascii_digit()
&& chars.all(|c| c.is_ascii_digit())
}
fn rinex_frequency(sat: GnssSatelliteId, obs: &str, glonass_channel: Option<i8>) -> Option<f64> {
frequencies::rinex_observation_frequency_hz(
sat.system,
obs,
RINEX_VERSION_FOR_BIAS_CODES,
glonass_channel,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::{F_L1_HZ, F_L2_HZ};
fn sat() -> GnssSatelliteId {
GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap()
}
fn epoch(year: i32, doy: u16, sod: u32) -> Instant {
bias_epoch_instant(BiasEpoch::new(year, doy, sod).unwrap(), TimeScale::Gpst).unwrap()
}
#[test]
fn bias_epoch_normalizes_inclusive_end_of_day() {
let end = BiasEpoch::new(2020, 366, 86_399)
.unwrap()
.normalize_end()
.unwrap();
assert_eq!(end, BiasEpoch::new(2021, 1, 0).unwrap());
}
#[test]
fn dsb_path_resolution_uses_fewest_lexicographic_path() {
let mut graph = BTreeMap::new();
graph.insert(
"C1C".to_string(),
vec![("C1W".to_string(), 1.0), ("C1P".to_string(), 2.0)],
);
graph.insert("C1P".to_string(), vec![("C1W".to_string(), 3.0)]);
assert_eq!(resolve_dsb_path(&graph, "C1C", "C1W"), Some(1.0));
}
#[test]
fn dcb_tgd_bridge_matches_pinned_operation_order() {
let dcb_s = 4.2e-9_f64;
let gamma = (F_L1_HZ / F_L2_HZ) * (F_L1_HZ / F_L2_HZ);
let tgd_s = dcb_s / (1.0 - gamma);
assert_eq!(tgd_s.to_bits(), 0xbe3be217807ad49e);
}
#[test]
fn ionosphere_free_bias_model_matches_closed_form_bits() {
let (alpha, beta) = ionosphere_free_coefficients(F_L1_HZ, F_L2_HZ).unwrap();
let used = alpha * 1.25e-9 + beta * -0.5e-9;
let reference = alpha * 0.25e-9 + beta * -0.5e-9;
let model = (used - reference) * C_M_S;
assert_eq!(model.to_bits(), 0x3fe86c0d69376a57);
}
#[test]
fn code_query_ignores_phase_record() {
let record = BiasRecord {
kind: BiasKind::Osb,
target: BiasTarget::Satellite(sat()),
svn: None,
obs1: "L1C".to_string(),
obs2: None,
valid_from: Some(BiasEpoch::new(2020, 1, 0).unwrap()),
valid_until: Some(BiasEpoch::new(2020, 2, 0).unwrap()),
raw_epochs: ("2020:001:00000".to_string(), "2020:002:00000".to_string()),
value: -0.25,
sigma: None,
slope: None,
slope_sigma: None,
is_phase: true,
};
let set = BiasSet::new(
vec![record],
BiasMode::Absolute,
TimeScale::Gpst,
ClockReferenceObservables::default(),
BiasSetHeader::default(),
Diagnostics::new(),
);
assert_eq!(set.code_osb_seconds(sat(), "L1C", epoch(2020, 1, 0)), None);
assert_eq!(
set.phase_osb_cycles(sat(), "L1C", epoch(2020, 1, 0)),
Some(-0.25)
);
}
}