use core::fmt;
use core::str::FromStr;
use std::collections::{HashMap, HashSet};
use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
use crate::astro::time::model::TimeScale;
use crate::astro::time::scales::julian_day_number;
use crate::terrain;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AnalysisCenter {
Igs,
CodRap,
CodPrd1,
CodPrd2,
Esa,
Cod,
Gfz,
IgsUlt,
CodUlt,
EsaUlt,
GfzUlt,
}
impl AnalysisCenter {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Igs => "igs",
Self::CodRap => "cod_rap",
Self::CodPrd1 => "cod_prd1",
Self::CodPrd2 => "cod_prd2",
Self::Esa => "esa",
Self::Cod => "cod",
Self::Gfz => "gfz",
Self::IgsUlt => "igs_ult",
Self::CodUlt => "cod_ult",
Self::EsaUlt => "esa_ult",
Self::GfzUlt => "gfz_ult",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
match code {
"igs" => Some(Self::Igs),
"cod_rap" => Some(Self::CodRap),
"cod_prd1" => Some(Self::CodPrd1),
"cod_prd2" => Some(Self::CodPrd2),
"esa" => Some(Self::Esa),
"cod" => Some(Self::Cod),
"gfz" => Some(Self::Gfz),
"igs_ult" => Some(Self::IgsUlt),
"cod_ult" => Some(Self::CodUlt),
"esa_ult" => Some(Self::EsaUlt),
"gfz_ult" => Some(Self::GfzUlt),
_ => None,
}
}
#[must_use]
pub const fn publisher(self) -> ProductPublisher {
match self {
Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
ProductPublisher::Code
}
Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
}
}
#[must_use]
pub const fn solution_class(self) -> SolutionClass {
match self {
Self::Igs => SolutionClass::Broadcast,
Self::CodRap | Self::Gfz => SolutionClass::Rapid,
Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
Self::Esa | Self::Cod => SolutionClass::Final,
Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
}
}
#[must_use]
pub const fn prediction_horizon_days(self) -> Option<u8> {
match self {
Self::CodPrd1 => Some(1),
Self::CodPrd2 => Some(2),
_ => None,
}
}
}
impl fmt::Display for AnalysisCenter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
impl FromStr for AnalysisCenter {
type Err = DataCatalogError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProductType {
Sp3,
Clk,
Nav,
Ionex,
}
impl ProductType {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Sp3 => "sp3",
Self::Clk => "clk",
Self::Nav => "nav",
Self::Ionex => "ionex",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
match code {
"sp3" => Some(Self::Sp3),
"clk" => Some(Self::Clk),
"nav" => Some(Self::Nav),
"ionex" => Some(Self::Ionex),
_ => None,
}
}
}
impl fmt::Display for ProductType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
impl FromStr for ProductType {
type Err = DataCatalogError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProductPublisher {
Igs,
Code,
Esa,
Gfz,
}
impl ProductPublisher {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Igs => "IGS",
Self::Code => "COD",
Self::Esa => "ESA",
Self::Gfz => "GFZ",
}
}
}
impl fmt::Display for ProductPublisher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SolutionClass {
Final,
Rapid,
UltraRapid,
Predicted,
Broadcast,
}
impl SolutionClass {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Final => "final",
Self::Rapid => "rapid",
Self::UltraRapid => "ultra_rapid",
Self::Predicted => "predicted",
Self::Broadcast => "broadcast",
}
}
#[must_use]
pub const fn filename_token(self) -> Option<&'static str> {
match self {
Self::Final => Some("FIN"),
Self::Rapid => Some("RAP"),
Self::UltraRapid => Some("ULT"),
Self::Predicted => Some("PRD"),
Self::Broadcast => None,
}
}
}
impl fmt::Display for SolutionClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProductCampaign {
Operational,
MultiGnss,
MultiGnssExperiment,
Broadcast,
}
impl ProductCampaign {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Operational => "OPS",
Self::MultiGnss => "MGN",
Self::MultiGnssExperiment => "MGX",
Self::Broadcast => "BRD",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProductFormat {
Sp3,
Ionex,
RinexClock,
RinexNavigation,
}
impl ProductFormat {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Sp3 => "SP3",
Self::Ionex => "IONEX",
Self::RinexClock => "RINEX_CLK",
Self::RinexNavigation => "RINEX_NAV",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum DistributionSource {
Direct,
NasaCddis,
LocalFile,
InMemory,
}
impl DistributionSource {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::NasaCddis => "nasa_cddis",
Self::LocalFile => "local_file",
Self::InMemory => "in_memory",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SpaceWeatherProduct {
All,
Last5Years,
}
impl SpaceWeatherProduct {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::All => "sw_all",
Self::Last5Years => "sw_last5",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
match code {
"sw_all" => Some(Self::All),
"sw_last5" => Some(Self::Last5Years),
_ => None,
}
}
}
impl fmt::Display for SpaceWeatherProduct {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
impl FromStr for SpaceWeatherProduct {
type Err = DataCatalogError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArchiveProtocol {
Http,
Https,
}
impl ArchiveProtocol {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArchiveCompression {
Gzip,
UnixCompress,
None,
}
impl ArchiveCompression {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Gzip => "gzip",
Self::UnixCompress => "unix_compress",
Self::None => "none",
}
}
const fn suffix(self) -> &'static str {
match self {
Self::Gzip => ".gz",
Self::UnixCompress => ".Z",
Self::None => "",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArchiveLayout {
GfzRapidWeek,
GfzUltraWeek,
GpsWeek,
BkgProductsWeek,
BkgBrdcYearDoy,
BkgObsYearDoy,
AiubCodeMgexYear,
AiubCodeYear,
AiubCodeRoot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProductFilenameKind {
Sampled,
Nav,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProductTypeConvention {
pub product_type: ProductType,
pub content_code: &'static str,
pub extension: &'static str,
pub kind: ProductFilenameKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CenterProductConvention {
pub product_type: ProductType,
pub token: &'static str,
pub layout: ArchiveLayout,
pub span: &'static str,
pub default_sample: &'static str,
pub compression: ArchiveCompression,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CenterCatalogEntry {
pub center: AnalysisCenter,
pub code: &'static str,
pub protocol: ArchiveProtocol,
pub host: &'static str,
pub root_url: &'static str,
pub products: &'static [CenterProductConvention],
pub issues: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerrainSourceEntry {
pub protocol: ArchiveProtocol,
pub host: &'static str,
pub compression: ArchiveCompression,
pub root_url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpaceWeatherSourceEntry {
pub protocol: ArchiveProtocol,
pub host: &'static str,
pub compression: ArchiveCompression,
pub root_url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoOpenMirrorProduct {
pub center: &'static str,
pub product_type: &'static str,
}
const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
ProductTypeConvention {
product_type: ProductType::Sp3,
content_code: "ORB",
extension: "SP3",
kind: ProductFilenameKind::Sampled,
},
ProductTypeConvention {
product_type: ProductType::Clk,
content_code: "CLK",
extension: "CLK",
kind: ProductFilenameKind::Sampled,
},
ProductTypeConvention {
product_type: ProductType::Nav,
content_code: "MN",
extension: "rnx",
kind: ProductFilenameKind::Nav,
},
ProductTypeConvention {
product_type: ProductType::Ionex,
content_code: "GIM",
extension: "INX",
kind: ProductFilenameKind::Sampled,
},
];
const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Ionex,
token: "COD0OPSRAP",
layout: ArchiveLayout::AiubCodeRoot,
span: "01D",
default_sample: "01H",
compression: ArchiveCompression::Gzip,
}];
const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Ionex,
token: "COD0OPSPRD",
layout: ArchiveLayout::AiubCodeRoot,
span: "01D",
default_sample: "01H",
compression: ArchiveCompression::Gzip,
}];
const ESA_PRODUCTS: [CenterProductConvention; 3] = [
CenterProductConvention {
product_type: ProductType::Sp3,
token: "ESA0MGNFIN",
layout: ArchiveLayout::GpsWeek,
span: "01D",
default_sample: "05M",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Clk,
token: "ESA0MGNFIN",
layout: ArchiveLayout::GpsWeek,
span: "01D",
default_sample: "30S",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Ionex,
token: "ESA0OPSFIN",
layout: ArchiveLayout::GpsWeek,
span: "01D",
default_sample: "02H",
compression: ArchiveCompression::Gzip,
},
];
const COD_PRODUCTS: [CenterProductConvention; 3] = [
CenterProductConvention {
product_type: ProductType::Sp3,
token: "COD0MGXFIN",
layout: ArchiveLayout::AiubCodeMgexYear,
span: "01D",
default_sample: "05M",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Clk,
token: "COD0MGXFIN",
layout: ArchiveLayout::AiubCodeMgexYear,
span: "01D",
default_sample: "30S",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Ionex,
token: "COD0OPSFIN",
layout: ArchiveLayout::AiubCodeYear,
span: "01D",
default_sample: "01H",
compression: ArchiveCompression::Gzip,
},
];
const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
CenterProductConvention {
product_type: ProductType::Sp3,
token: "GFZ0OPSRAP",
layout: ArchiveLayout::GfzRapidWeek,
span: "01D",
default_sample: "05M",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Clk,
token: "GFZ0OPSRAP",
layout: ArchiveLayout::GfzRapidWeek,
span: "01D",
default_sample: "30S",
compression: ArchiveCompression::Gzip,
},
];
const IGS_PRODUCTS: [CenterProductConvention; 2] = [
CenterProductConvention {
product_type: ProductType::Sp3,
token: "IGS0OPSFIN",
layout: ArchiveLayout::BkgProductsWeek,
span: "01D",
default_sample: "15M",
compression: ArchiveCompression::Gzip,
},
CenterProductConvention {
product_type: ProductType::Nav,
token: "BRDC00WRD",
layout: ArchiveLayout::BkgBrdcYearDoy,
span: "01D",
default_sample: "01D",
compression: ArchiveCompression::Gzip,
},
];
const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Sp3,
token: "IGS0OPSULT",
layout: ArchiveLayout::BkgProductsWeek,
span: "02D",
default_sample: "15M",
compression: ArchiveCompression::Gzip,
}];
const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Sp3,
token: "COD0OPSULT",
layout: ArchiveLayout::AiubCodeRoot,
span: "01D",
default_sample: "05M",
compression: ArchiveCompression::None,
}];
const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Sp3,
token: "ESA0OPSULT",
layout: ArchiveLayout::GpsWeek,
span: "02D",
default_sample: "05M",
compression: ArchiveCompression::Gzip,
}];
const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
product_type: ProductType::Sp3,
token: "GFZ0OPSULT",
layout: ArchiveLayout::GfzUltraWeek,
span: "02D",
default_sample: "05M",
compression: ArchiveCompression::Gzip,
}];
const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
const COD_ULT_ISSUES: [&str; 1] = ["0000"];
const GFZ_ULT_ISSUES: [&str; 8] = [
"0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
];
const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
year: 2021,
month: 5,
day: 18,
};
const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
year: 2014,
month: 1,
day: 5,
};
const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
year: 2020,
month: 5,
day: 13,
};
const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
year: 2022,
month: 10,
day: 4,
};
const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
year: 2025,
month: 2,
day: 2,
};
const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
year: 2020,
month: 10,
day: 6,
};
const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
year: 2021,
month: 5,
day: 16,
};
const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
year: 2021,
month: 5,
day: 15,
};
const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
year: 2022,
month: 9,
day: 7,
};
const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
year: 2022,
month: 9,
day: 8,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Sp3ContentStartConvention {
FilenameEpoch,
FilenameEpochMinusOneDay,
}
impl Sp3ContentStartConvention {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::FilenameEpoch => "filename_epoch",
Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
}
}
#[must_use]
pub const fn content_start_offset_s(self) -> i64 {
match self {
Self::FilenameEpoch => 0,
Self::FilenameEpochMinusOneDay => -86_400,
}
}
}
const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"0000",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"0300",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"0600",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"0900",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"1200",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"1500",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"1800",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
"2100",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"0000",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"0300",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"0600",
Sp3ContentStartConvention::FilenameEpochMinusOneDay,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"0900",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"1200",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"1500",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"1800",
Sp3ContentStartConvention::FilenameEpoch,
),
(
GFZ_ULTRA_START_TRANSITION_LAST_DATE,
"2100",
Sp3ContentStartConvention::FilenameEpoch,
),
];
const CENTER_ORDER: [AnalysisCenter; 11] = [
AnalysisCenter::CodRap,
AnalysisCenter::CodPrd1,
AnalysisCenter::CodPrd2,
AnalysisCenter::Igs,
AnalysisCenter::Esa,
AnalysisCenter::Cod,
AnalysisCenter::Gfz,
AnalysisCenter::IgsUlt,
AnalysisCenter::CodUlt,
AnalysisCenter::EsaUlt,
AnalysisCenter::GfzUlt,
];
const CATALOG: [CenterCatalogEntry; 11] = [
CenterCatalogEntry {
center: AnalysisCenter::CodRap,
code: "cod_rap",
protocol: ArchiveProtocol::Https,
host: "www.aiub.unibe.ch",
root_url: "https://www.aiub.unibe.ch/download",
products: &COD_RAP_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::CodPrd1,
code: "cod_prd1",
protocol: ArchiveProtocol::Https,
host: "www.aiub.unibe.ch",
root_url: "https://www.aiub.unibe.ch/download",
products: &COD_PRD_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::CodPrd2,
code: "cod_prd2",
protocol: ArchiveProtocol::Https,
host: "www.aiub.unibe.ch",
root_url: "https://www.aiub.unibe.ch/download",
products: &COD_PRD_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::Igs,
code: "igs",
protocol: ArchiveProtocol::Https,
host: "igs.bkg.bund.de",
root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
products: &IGS_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::Esa,
code: "esa",
protocol: ArchiveProtocol::Https,
host: "navigation-office.esa.int",
root_url: "https://navigation-office.esa.int/products/gnss-products",
products: &ESA_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::Cod,
code: "cod",
protocol: ArchiveProtocol::Https,
host: "www.aiub.unibe.ch",
root_url: "https://www.aiub.unibe.ch/download",
products: &COD_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::Gfz,
code: "gfz",
protocol: ArchiveProtocol::Https,
host: "isdc-data.gfz.de",
root_url: "https://isdc-data.gfz.de/gnss/products",
products: &GFZ_PRODUCTS,
issues: &[],
},
CenterCatalogEntry {
center: AnalysisCenter::IgsUlt,
code: "igs_ult",
protocol: ArchiveProtocol::Https,
host: "igs.bkg.bund.de",
root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
products: &IGS_ULT_PRODUCTS,
issues: &OPSULT_ISSUES,
},
CenterCatalogEntry {
center: AnalysisCenter::CodUlt,
code: "cod_ult",
protocol: ArchiveProtocol::Https,
host: "www.aiub.unibe.ch",
root_url: "https://www.aiub.unibe.ch/download",
products: &COD_ULT_PRODUCTS,
issues: &COD_ULT_ISSUES,
},
CenterCatalogEntry {
center: AnalysisCenter::EsaUlt,
code: "esa_ult",
protocol: ArchiveProtocol::Https,
host: "navigation-office.esa.int",
root_url: "https://navigation-office.esa.int/products/gnss-products",
products: &ESA_ULT_PRODUCTS,
issues: &OPSULT_ISSUES,
},
CenterCatalogEntry {
center: AnalysisCenter::GfzUlt,
code: "gfz_ult",
protocol: ArchiveProtocol::Https,
host: "isdc-data.gfz.de",
root_url: "https://isdc-data.gfz.de/gnss/products",
products: &GFZ_ULT_PRODUCTS,
issues: &GFZ_ULT_ISSUES,
},
];
const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
protocol: ArchiveProtocol::Https,
host: "s3.amazonaws.com",
compression: ArchiveCompression::Gzip,
root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
};
const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
protocol: ArchiveProtocol::Https,
host: "celestrak.org",
compression: ArchiveCompression::None,
root_url: "https://celestrak.org/SpaceData",
};
const ALLOWED_HOSTS: [&str; 10] = [
"www.aiub.unibe.ch",
"download.aiub.unibe.ch",
"zhw-b.s3.cloud.switch.ch",
"navigation-office.esa.int",
"isdc-data.gfz.de",
"igs.bkg.bund.de",
"s3.amazonaws.com",
"celestrak.org",
"cddis.nasa.gov",
"urs.earthdata.nasa.gov",
];
const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
NoOpenMirrorProduct {
center: "grg",
product_type: "sp3",
},
NoOpenMirrorProduct {
center: "grg",
product_type: "clk",
},
NoOpenMirrorProduct {
center: "wum",
product_type: "sp3",
},
NoOpenMirrorProduct {
center: "wum",
product_type: "clk",
},
NoOpenMirrorProduct {
center: "grg_ult",
product_type: "sp3",
},
NoOpenMirrorProduct {
center: "grg_ult",
product_type: "clk",
},
NoOpenMirrorProduct {
center: "igs",
product_type: "ionex",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataCatalogError {
UnknownCenter(String),
UnknownProductType(String),
UnsupportedProduct {
center: AnalysisCenter,
product_type: ProductType,
},
UnsupportedDistribution {
source: DistributionSource,
product_type: ProductType,
},
UnsupportedProductEra {
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
},
UnsupportedDistributionEra {
source: DistributionSource,
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
},
NoDistributionSources,
InvalidOfficialFilename(String),
InconsistentProductIdentity {
field: &'static str,
},
NoOpenMirror {
center: String,
product_type: String,
},
InvalidDate {
year: i32,
month: u8,
day: u8,
},
DateOutOfRange,
DateBeforeGpsEpoch(ProductDate),
InvalidGpsDayOfWeek(u8),
InvalidSample(String),
UnsupportedSample {
center: AnalysisCenter,
product_type: ProductType,
sample: String,
},
InvalidSpan(String),
InvalidIssue(String),
MissingIssue {
center: AnalysisCenter,
},
UnexpectedIssue {
center: AnalysisCenter,
},
UnsupportedIssue {
center: AnalysisCenter,
issue: String,
},
InvalidDateTime {
hour: u8,
minute: u8,
second: u8,
},
NoUltraIssue,
NoAvailableUltraIssue,
InvalidStation(String),
InvalidCoordinate {
lat_deg_bits: u64,
lon_deg_bits: u64,
},
InvalidTileIndex {
lat_index: i32,
lon_index: i32,
},
InvalidTileId(String),
}
impl fmt::Display for DataCatalogError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
Self::UnknownProductType(product_type) => {
write!(f, "unknown product type {product_type:?}")
}
Self::UnsupportedProduct {
center,
product_type,
} => write!(f, "{center} does not serve {product_type}"),
Self::UnsupportedDistribution {
source,
product_type,
} => write!(
f,
"distributor {} does not serve {product_type}",
source.code()
),
Self::UnsupportedProductEra {
center,
product_type,
date,
} => write!(
f,
"{center}/{product_type} has no cataloged naming convention for {date}"
),
Self::UnsupportedDistributionEra {
source,
center,
product_type,
date,
} => write!(
f,
"distributor {} has no cataloged {center}/{product_type} layout for {date}",
source.code()
),
Self::NoDistributionSources => {
write!(f, "exact product request has no distributors")
}
Self::InvalidOfficialFilename(filename) => {
write!(f, "invalid official product filename {filename:?}")
}
Self::InconsistentProductIdentity { field } => {
write!(
f,
"product identity field {field:?} disagrees with its official filename"
)
}
Self::NoOpenMirror {
center,
product_type,
} => write!(f, "{center}/{product_type} has no open mirror"),
Self::InvalidDate { year, month, day } => {
write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
}
Self::DateOutOfRange => write!(f, "product date is out of range"),
Self::DateBeforeGpsEpoch(date) => {
write!(f, "product date {date} is before the GPS week epoch")
}
Self::InvalidGpsDayOfWeek(day) => {
write!(f, "invalid GPS day-of-week {day}")
}
Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
Self::UnsupportedSample {
center,
product_type,
sample,
} => write!(
f,
"{center}/{product_type} does not publish sample interval {sample:?}"
),
Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
Self::UnsupportedIssue { center, issue } => {
write!(f, "{center} does not publish issue {issue:?}")
}
Self::InvalidDateTime {
hour,
minute,
second,
} => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
Self::NoAvailableUltraIssue => {
write!(f, "no available ultra-rapid issue at or before target")
}
Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
Self::InvalidCoordinate {
lat_deg_bits,
lon_deg_bits,
} => write!(
f,
"invalid terrain coordinate lat={} lon={}",
f64::from_bits(*lat_deg_bits),
f64::from_bits(*lon_deg_bits)
),
Self::InvalidTileIndex {
lat_index,
lon_index,
} => write!(
f,
"invalid terrain tile index lat={lat_index} lon={lon_index}"
),
Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
}
}
}
impl std::error::Error for DataCatalogError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HgtConversionError {
BadLength {
expected: usize,
got: usize,
},
InvalidTileIndex {
lat_index: i32,
lon_index: i32,
},
}
impl fmt::Display for HgtConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadLength { expected, got } => {
write!(
f,
"invalid SRTM1 HGT length: expected {expected}, got {got}"
)
}
Self::InvalidTileIndex {
lat_index,
lon_index,
} => write!(
f,
"invalid terrain tile index lat={lat_index} lon={lon_index}"
),
}
}
}
impl std::error::Error for HgtConversionError {}
const MIN_TERRAIN_LAT_INDEX: i32 = -90;
const MAX_TERRAIN_LAT_INDEX: i32 = 89;
const MIN_TERRAIN_LON_INDEX: i32 = -180;
const MAX_TERRAIN_LON_INDEX: i32 = 179;
const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
const MIN_TERRAIN_LON_DEG: f64 = -180.0;
const MAX_TERRAIN_LON_DEG: f64 = 180.0;
const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
const DTED_SRTM1_LEN: usize =
terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProductDate {
pub year: i32,
pub month: u8,
pub day: u8,
}
impl ProductDate {
pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
let days = days_in_month(i64::from(year), i64::from(month));
if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
return Err(DataCatalogError::InvalidDate { year, month, day });
}
Ok(Self { year, month, day })
}
pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
if day_of_week > 6 {
return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
}
let epoch_jdn =
week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
let offset_days = i64::from(week)
.checked_mul(7)
.and_then(|days| days.checked_add(i64::from(day_of_week)))
.ok_or(DataCatalogError::DateOutOfRange)?;
product_date_from_jdn(
epoch_jdn
.checked_add(offset_days)
.ok_or(DataCatalogError::DateOutOfRange)?,
)
}
pub fn gps_week(self) -> Result<u32, DataCatalogError> {
week_from_calendar(
TimeScale::Gpst,
i64::from(self.year),
i64::from(self.month),
i64::from(self.day),
)
.ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
}
pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
let epoch_jdn =
week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
let days = self
.julian_day_number()
.checked_sub(epoch_jdn)
.ok_or(DataCatalogError::DateOutOfRange)?;
if days < 0 {
return Err(DataCatalogError::DateBeforeGpsEpoch(self));
}
u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
}
#[must_use]
pub fn day_of_year(self) -> u16 {
day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
}
fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
product_date_from_jdn(
self.julian_day_number()
.checked_add(days)
.ok_or(DataCatalogError::DateOutOfRange)?,
)
}
fn julian_day_number(self) -> i64 {
julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
}
}
impl fmt::Display for ProductDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProductDateTime {
pub date: ProductDate,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
impl ProductDateTime {
pub fn new(
date: ProductDate,
hour: u8,
minute: u8,
second: u8,
) -> Result<Self, DataCatalogError> {
if hour > 23 || minute > 59 || second > 59 {
return Err(DataCatalogError::InvalidDateTime {
hour,
minute,
second,
});
}
Ok(Self {
date,
hour,
minute,
second,
})
}
fn ordering_minutes(self) -> i64 {
self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UltraIssue {
pub date: ProductDate,
pub issue: String,
}
impl UltraIssue {
pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
validate_issue(issue)?;
Ok(Self {
date,
issue: issue.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UltraSp3Location {
pub pattern: String,
pub span: String,
pub sample: String,
pub filename: String,
pub url: String,
pub compression: ArchiveCompression,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProductIdentity {
pub family: ProductType,
pub analysis_center: AnalysisCenter,
pub publisher: ProductPublisher,
pub solution: SolutionClass,
pub campaign: ProductCampaign,
pub version: u8,
pub date: ProductDate,
pub issue: Option<String>,
pub span: String,
pub sample: String,
pub official_filename: String,
pub format: ProductFormat,
pub format_version: Option<String>,
pub prediction_horizon_days: Option<u8>,
}
impl ProductIdentity {
pub fn validate(&self) -> Result<(), DataCatalogError> {
validate_official_filename(&self.official_filename)?;
ProductDate::new(self.date.year, self.date.month, self.date.day)?;
validate_sample(&self.sample)?;
validate_span(&self.span)?;
if let Some(issue) = self.issue.as_deref() {
validate_issue(issue)?;
}
let convention = product_convention(self.analysis_center, self.family)?;
validate_product_date(self.analysis_center, self.family, self.date)?;
if self.span != convention.span {
return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
}
validate_catalog_sample(
self.analysis_center,
self.family,
self.date,
&self.sample,
self.issue.as_deref(),
)?;
if self.format != product_format(self.family) {
return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
}
if self
.format_version
.as_deref()
.is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
{
return Err(DataCatalogError::InconsistentProductIdentity {
field: "format_version",
});
}
let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
(ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
(_, SolutionClass::Predicted, _) => false,
(_, _, None) => true,
(_, _, Some(_)) => false,
};
if !horizon_valid {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "prediction_horizon_days",
});
}
let descriptor = product_type_convention(self.family);
let legacy_igs_final =
uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
let entry = center_catalog(self.analysis_center)
.expect("validated analysis center has a catalog entry");
let issue_valid = if entry.issues.is_empty() {
self.issue.as_deref() == Some("0000")
} else {
self.issue
.as_deref()
.is_some_and(|issue| entry.issues.contains(&issue))
};
if !issue_valid {
return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
}
}
let expected = if legacy_igs_final {
let fields_valid = self.publisher == ProductPublisher::Igs
&& self.solution == SolutionClass::Final
&& self.campaign == ProductCampaign::Operational
&& self.version == 0
&& self.issue.as_deref() == Some("0000")
&& self.span == convention.span
&& self.sample == convention.default_sample;
if !fields_valid {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "legacy_igs_final",
});
}
format!(
"igs{:04}{}.sp3",
self.date.gps_week()?,
self.date.gps_day_of_week()?
)
} else {
match descriptor.kind {
ProductFilenameKind::Sampled => {
let solution_token = self.solution.filename_token().ok_or(
DataCatalogError::InconsistentProductIdentity { field: "solution" },
)?;
format!(
"{}{}{}{}_{}_{}_{}_{}.{}",
self.publisher.code(),
self.version,
self.campaign.code(),
solution_token,
date_block(self.date, self.issue.as_deref()),
self.span,
self.sample,
descriptor.content_code,
descriptor.extension
)
}
ProductFilenameKind::Nav => {
let nav_fields_valid = self.publisher == ProductPublisher::Igs
&& self.solution == SolutionClass::Broadcast
&& self.campaign == ProductCampaign::Broadcast
&& self.version == 0
&& self.issue.is_none()
&& self.span == "01D"
&& self.sample == "01D";
if !nav_fields_valid {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "broadcast_navigation",
});
}
format!(
"BRDC00WRD_R_{}_{}_{}.{}",
date_block(self.date, None),
self.span,
descriptor.content_code,
descriptor.extension
)
}
}
};
if expected != self.official_filename {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "official_filename",
});
}
if self.publisher != self.analysis_center.publisher()
|| self.solution != product_solution_class(self.analysis_center, self.family)?
|| self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
{
return Err(DataCatalogError::InconsistentProductIdentity {
field: "analysis_center",
});
}
if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
let expected_catalog_filename = format!(
"{}_{}_{}_{}_{}.{}",
convention.token,
date_block(self.date, self.issue.as_deref()),
self.span,
self.sample,
descriptor.content_code,
descriptor.extension
);
if expected_catalog_filename != self.official_filename {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "analysis_center",
});
}
}
Ok(())
}
pub fn key(&self) -> Result<String, DataCatalogError> {
use sha2::{Digest, Sha256};
let canonical = self.canonical_bytes()?;
let digest = Sha256::digest(canonical);
Ok(format!(
"{}-{}-{}",
self.publisher.code().to_ascii_lowercase(),
self.solution.code(),
digest[..10]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
))
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
self.validate()?;
let date = format!(
"{:04}-{:02}-{:02}",
self.date.year, self.date.month, self.date.day
);
let version = self.version.to_string();
let prediction = self
.prediction_horizon_days
.map(|days| days.to_string())
.unwrap_or_default();
let fields = [
self.family.code(),
self.analysis_center.code(),
self.publisher.code(),
self.solution.code(),
self.campaign.code(),
version.as_str(),
date.as_str(),
self.issue.as_deref().unwrap_or_default(),
self.span.as_str(),
self.sample.as_str(),
self.official_filename.as_str(),
self.format.code(),
self.format_version.as_deref().unwrap_or_default(),
prediction.as_str(),
];
if fields.iter().any(|field| field.as_bytes().contains(&0)) {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "canonical_encoding",
});
}
Ok(fields.join("\0").into_bytes())
}
pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
}
}
pub(crate) fn exact_sp3_content_start_offset_s(
identity: &ProductIdentity,
) -> Result<i64, DataCatalogError> {
identity.validate()?;
if identity.family != ProductType::Sp3 {
return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
}
let entry =
center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
let catalog_issue = if entry.issues.is_empty() {
None
} else {
identity.issue.as_deref()
};
Ok(
sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
.content_start_offset_s(),
)
}
pub fn sp3_content_start_convention(
center: AnalysisCenter,
date: ProductDate,
issue: Option<&str>,
) -> Result<Sp3ContentStartConvention, DataCatalogError> {
ProductDate::new(date.year, date.month, date.day)?;
product_convention(center, ProductType::Sp3)?;
validate_product_date(center, ProductType::Sp3, date)?;
validate_issue_for_center(center, issue)?;
sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
DataCatalogError::UnsupportedIssue {
center,
issue: issue.unwrap_or_default().to_owned(),
}
})
}
fn sp3_content_start_convention_inner(
center: AnalysisCenter,
date: ProductDate,
issue: Option<&str>,
) -> Option<Sp3ContentStartConvention> {
if center != AnalysisCenter::GfzUlt {
return Some(Sp3ContentStartConvention::FilenameEpoch);
}
if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
}
if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
return Some(Sp3ContentStartConvention::FilenameEpoch);
}
let issue = issue?;
GFZ_ULTRA_START_TRANSITION
.iter()
.find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
.map(|(_, _, convention)| *convention)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistributionLocation {
pub source: DistributionSource,
pub original_url: Option<String>,
pub archive_filename: String,
pub compression: ArchiveCompression,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductRequest {
pub identity: ProductIdentity,
pub distributors: Vec<DistributionSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExactProductSetError {
EmptyExpected,
InvalidExpected {
index: usize,
source: DataCatalogError,
},
InvalidAvailable {
index: usize,
source: DataCatalogError,
},
Mismatch {
missing: Vec<ProductIdentity>,
unexpected: Vec<ProductIdentity>,
duplicate_expected: Vec<ProductIdentity>,
duplicate_available: Vec<ProductIdentity>,
},
}
impl fmt::Display for ExactProductSetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyExpected => write!(f, "exact product set has no expected products"),
Self::InvalidExpected { index, source } => {
write!(f, "expected product {index} is invalid: {source}")
}
Self::InvalidAvailable { index, source } => {
write!(f, "available product {index} is invalid: {source}")
}
Self::Mismatch {
missing,
unexpected,
duplicate_expected,
duplicate_available,
} => write!(
f,
"exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
identity_list(missing),
identity_list(unexpected),
identity_list(duplicate_expected),
identity_list(duplicate_available),
),
}
}
}
impl std::error::Error for ExactProductSetError {}
pub fn validate_exact_product_set(
expected: &[ProductIdentity],
available: &[ProductIdentity],
) -> Result<(), ExactProductSetError> {
if expected.is_empty() {
return Err(ExactProductSetError::EmptyExpected);
}
for (index, identity) in expected.iter().enumerate() {
identity
.validate()
.map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
}
for (index, identity) in available.iter().enumerate() {
identity
.validate()
.map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
}
let expected_counts = identity_counts(expected);
let available_counts = identity_counts(available);
let missing = unique_matching(expected, |identity| {
!available_counts.contains_key(identity)
});
let unexpected = unique_matching(available, |identity| {
!expected_counts.contains_key(identity)
});
let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
if missing.is_empty()
&& unexpected.is_empty()
&& duplicate_expected.is_empty()
&& duplicate_available.is_empty()
{
Ok(())
} else {
Err(ExactProductSetError::Mismatch {
missing,
unexpected,
duplicate_expected,
duplicate_available,
})
}
}
fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
let mut counts = HashMap::with_capacity(identities.len());
for identity in identities {
*counts.entry(identity).or_insert(0) += 1;
}
counts
}
fn unique_matching(
identities: &[ProductIdentity],
mut predicate: impl FnMut(&ProductIdentity) -> bool,
) -> Vec<ProductIdentity> {
let mut seen = HashSet::with_capacity(identities.len());
identities
.iter()
.filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
.cloned()
.collect()
}
fn identity_list(identities: &[ProductIdentity]) -> String {
if identities.is_empty() {
return "none".to_string();
}
identities
.iter()
.map(|identity| {
identity
.key()
.unwrap_or_else(|_| identity.official_filename.clone())
})
.collect::<Vec<_>>()
.join(", ")
}
impl ProductRequest {
pub fn new(
identity: ProductIdentity,
distributors: Vec<DistributionSource>,
) -> Result<Self, DataCatalogError> {
if distributors.is_empty() {
return Err(DataCatalogError::NoDistributionSources);
}
identity.validate()?;
Ok(Self {
identity,
distributors,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductSpec {
pub center: AnalysisCenter,
pub product_type: ProductType,
pub date: ProductDate,
pub sample: String,
pub issue: Option<String>,
}
impl ProductSpec {
pub fn new(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: &str,
issue: Option<&str>,
) -> Result<Self, DataCatalogError> {
ProductDate::new(date.year, date.month, date.day)?;
validate_product(center, product_type, date, sample, issue)?;
Ok(Self {
center,
product_type,
date,
sample: sample.to_string(),
issue: issue.map(ToOwned::to_owned),
})
}
pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
self.date.gps_week()
}
#[must_use]
pub fn day_of_year(&self) -> u16 {
self.date.day_of_year()
}
pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
ProductDate::new(self.date.year, self.date.month, self.date.day)?;
let convention = validate_product(
self.center,
self.product_type,
self.date,
&self.sample,
self.issue.as_deref(),
)?;
if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
return Ok(format!(
"igs{:04}{}.sp3",
self.date.gps_week()?,
self.date.gps_day_of_week()?
));
}
let descriptor = product_type_convention(self.product_type);
Ok(match descriptor.kind {
ProductFilenameKind::Sampled => format!(
"{}_{}_{}_{}_{}.{}",
convention.token,
date_block(self.date, self.issue.as_deref()),
convention.span,
self.sample,
descriptor.content_code,
descriptor.extension
),
ProductFilenameKind::Nav => format!(
"{}_R_{}_{}_{}.{}",
convention.token,
date_block(self.date, None),
convention.span,
descriptor.content_code,
descriptor.extension
),
})
}
pub fn archive_url(&self) -> Result<String, DataCatalogError> {
ProductDate::new(self.date.year, self.date.month, self.date.day)?;
let convention = validate_product(
self.center,
self.product_type,
self.date,
&self.sample,
self.issue.as_deref(),
)?;
if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
return Err(DataCatalogError::UnsupportedDistributionEra {
source: DistributionSource::Direct,
center: self.center,
product_type: self.product_type,
date: self.date,
});
}
let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
let filename = self.canonical_filename()?;
let compression = product_archive_compression(
self.center,
self.product_type,
self.date,
convention.compression,
)?;
Ok(format!(
"{}/{}/{}{}",
entry.root_url,
product_dir_path(self.center, convention.layout, self.date)?,
filename,
compression.suffix()
))
}
pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
let convention = validate_product(
self.center,
self.product_type,
self.date,
&self.sample,
self.issue.as_deref(),
)?;
let descriptor = product_type_convention(self.product_type);
let campaign = match descriptor.kind {
ProductFilenameKind::Nav => ProductCampaign::Broadcast,
ProductFilenameKind::Sampled => match convention.token.get(4..7) {
Some("OPS") => ProductCampaign::Operational,
Some("MGN") => ProductCampaign::MultiGnss,
Some("MGX") => ProductCampaign::MultiGnssExperiment,
_ => {
return Err(DataCatalogError::InconsistentProductIdentity {
field: "campaign",
});
}
},
};
let identity = ProductIdentity {
family: self.product_type,
analysis_center: self.center,
publisher: self.center.publisher(),
solution: product_solution_class(self.center, self.product_type)?,
campaign,
version: 0,
date: self.date,
issue: match descriptor.kind {
ProductFilenameKind::Sampled => {
Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
}
ProductFilenameKind::Nav => None,
},
span: convention.span.to_string(),
sample: self.sample.clone(),
official_filename: self.canonical_filename()?,
format: product_format(self.product_type),
format_version: None,
prediction_horizon_days: self.center.prediction_horizon_days(),
};
identity.validate()?;
Ok(identity)
}
pub fn distribution_location(
&self,
source: DistributionSource,
) -> Result<DistributionLocation, DataCatalogError> {
let identity = self.identity()?;
distribution_location_for_identity(&identity, source)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StationObservationSpec {
pub station: String,
pub date: ProductDate,
pub sample: String,
}
impl StationObservationSpec {
pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
validate_station(station)?;
validate_sample(sample)?;
Ok(Self {
station: station.to_string(),
date,
sample: sample.to_string(),
})
}
pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
station_obs_filename(&self.station, self.date, &self.sample)
}
pub fn archive_url(&self) -> Result<String, DataCatalogError> {
station_obs_url(&self.station, self.date, &self.sample)
}
}
#[must_use]
pub const fn catalog() -> &'static [CenterCatalogEntry] {
&CATALOG
}
#[must_use]
pub const fn centers() -> &'static [AnalysisCenter] {
&CENTER_ORDER
}
#[must_use]
pub const fn product_types() -> &'static [ProductTypeConvention] {
&PRODUCT_TYPE_CONVENTIONS
}
#[must_use]
pub const fn allowed_hosts() -> &'static [&'static str] {
&ALLOWED_HOSTS
}
#[must_use]
pub const fn skadi_source_entry() -> TerrainSourceEntry {
SKADI_SOURCE
}
#[must_use]
pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
CELESTRAK_SPACE_WEATHER_SOURCE
}
#[must_use]
pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
match product {
SpaceWeatherProduct::All => "SW-All.csv",
SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
}
}
#[must_use]
pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
format!(
"{}/{}",
CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
space_weather_filename(product)
)
}
#[must_use]
pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
format!("space-weather/{}", space_weather_filename(product))
}
pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
validate_terrain_tile_index(lat_index, lon_index)?;
let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
Ok(format!(
"{lat_hemi}{:02}{lon_hemi}{:03}",
lat_index.abs(),
lon_index.abs()
))
}
pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
validate_terrain_lat_index(lat_index)?;
let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
}
pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
let band = skadi_band(lat_index)?;
let tile_id = skadi_tile_id(lat_index, lon_index)?;
Ok(format!(
"{}/skadi/{}/{}.hgt{}",
SKADI_SOURCE.root_url,
band,
tile_id,
SKADI_SOURCE.compression.suffix()
))
}
pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
validate_terrain_tile_index(lat_index, lon_index)?;
Ok(format!(
"{}_{}{}",
terrain::format_lat(lat_index),
terrain::format_lon(lon_index),
terrain::DTED_SUFFIX
))
}
pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
validate_terrain_tile_index(lat_index, lon_index)?;
Ok(terrain::terrain_block_dir(lat_index, lon_index))
}
pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
Ok(format!(
"{}/{}",
dted_block_dir(lat_index, lon_index)?,
dted_tile_filename(lat_index, lon_index)?
))
}
pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
let bytes = id.as_bytes();
if bytes.len() != 7
|| !matches!(bytes[0], b'N' | b'S')
|| !matches!(bytes[3], b'E' | b'W')
|| !bytes[1..3].iter().all(u8::is_ascii_digit)
|| !bytes[4..7].iter().all(u8::is_ascii_digit)
{
return Err(DataCatalogError::InvalidTileId(id.to_string()));
}
let lat_abs = id[1..3]
.parse::<i32>()
.map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
let lon_abs = id[4..7]
.parse::<i32>()
.map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
return Err(DataCatalogError::InvalidTileId(id.to_string()));
}
let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
validate_terrain_tile_index(lat_index, lon_index)?;
Ok((lat_index, lon_index))
}
pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
if !lat_deg.is_finite()
|| !lon_deg.is_finite()
|| !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
|| !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
{
return Err(DataCatalogError::InvalidCoordinate {
lat_deg_bits: lat_deg.to_bits(),
lon_deg_bits: lon_deg.to_bits(),
});
}
let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
lat_index = MAX_TERRAIN_LAT_INDEX;
}
if lon_index == MAX_TERRAIN_LON_DEG as i32 {
lon_index = MAX_TERRAIN_LON_INDEX;
}
validate_terrain_tile_index(lat_index, lon_index)?;
Ok((lat_index, lon_index))
}
pub fn hgt_to_dted(
lat_index: i32,
lon_index: i32,
hgt: &[u8],
) -> Result<Vec<u8>, HgtConversionError> {
validate_hgt_tile_index(lat_index, lon_index)?;
if hgt.len() != SRTM1_HGT_LEN {
return Err(HgtConversionError::BadLength {
expected: SRTM1_HGT_LEN,
got: hgt.len(),
});
}
let mut out = vec![b' '; DTED_SRTM1_LEN];
out[0..4].copy_from_slice(b"UHL1");
out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
out[47..51].copy_from_slice(b"3601");
out[51..55].copy_from_slice(b"3601");
for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
out[block_start] = terrain::DATA_SENTINEL;
let count = (lon_posting as u32).to_be_bytes();
out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
let dted_sample_start = block_start + 8 + 2 * lat_posting;
out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
}
let checksum = out[block_start..checksum_start]
.iter()
.fold(0i32, |acc, byte| acc + i32::from(*byte));
out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
}
debug_assert_eq!(out.len(), 25_981_042);
Ok(out)
}
#[must_use]
pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
&NO_OPEN_MIRRORS
}
pub fn open_mirror(
center: AnalysisCenter,
product_type: ProductType,
) -> Result<(), DataCatalogError> {
open_mirror_code(center.code(), product_type.code())
}
pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
if NO_OPEN_MIRRORS
.iter()
.any(|entry| entry.center == center && entry.product_type == product_type)
{
Err(DataCatalogError::NoOpenMirror {
center: center.to_string(),
product_type: product_type.to_string(),
})
} else {
Ok(())
}
}
#[must_use]
pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
CATALOG.iter().find(|entry| entry.center == center)
}
pub fn product_convention(
center: AnalysisCenter,
product_type: ProductType,
) -> Result<&'static CenterProductConvention, DataCatalogError> {
open_mirror(center, product_type)?;
let entry = center_catalog(center).expect("catalog entry exists for enum variant");
entry
.products
.iter()
.find(|product| product.product_type == product_type)
.ok_or(DataCatalogError::UnsupportedProduct {
center,
product_type,
})
}
pub fn product_solution_class(
center: AnalysisCenter,
product_type: ProductType,
) -> Result<SolutionClass, DataCatalogError> {
product_convention(center, product_type)?;
Ok(match (center, product_type) {
(AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
_ => center.solution_class(),
})
}
pub fn default_sample(
center: AnalysisCenter,
product_type: ProductType,
) -> Result<&'static str, DataCatalogError> {
Ok(product_convention(center, product_type)?.default_sample)
}
pub fn default_sample_for_date(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
) -> Result<&'static str, DataCatalogError> {
default_sample_for_product_issue(center, product_type, date, None)
}
pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
date.gps_week()
}
#[must_use]
pub fn day_of_year(date: ProductDate) -> u16 {
date.day_of_year()
}
pub fn product(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
let sample = match sample {
Some(sample) => sample,
None => default_sample_for_product_issue(center, product_type, date, issue)?,
};
ProductSpec::new(center, product_type, date, sample, issue)
}
pub fn canonical_filename(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<String, DataCatalogError> {
product(center, product_type, date, sample, issue)?.canonical_filename()
}
pub fn archive_url(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<String, DataCatalogError> {
product(center, product_type, date, sample, issue)?.archive_url()
}
pub fn product_identity(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<ProductIdentity, DataCatalogError> {
product(center, product_type, date, sample, issue)?.identity()
}
pub fn distribution_location(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
source: DistributionSource,
) -> Result<DistributionLocation, DataCatalogError> {
product(center, product_type, date, sample, issue)?.distribution_location(source)
}
pub fn distribution_location_for_identity(
identity: &ProductIdentity,
source: DistributionSource,
) -> Result<DistributionLocation, DataCatalogError> {
identity.validate()?;
match source {
DistributionSource::Direct => {
let convention = product_convention(identity.analysis_center, identity.family)?;
if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
{
return Err(DataCatalogError::UnsupportedDistributionEra {
source,
center: identity.analysis_center,
product_type: identity.family,
date: identity.date,
});
}
let entry = center_catalog(identity.analysis_center)
.expect("validated analysis center has a catalog entry");
let compression = product_archive_compression(
identity.analysis_center,
identity.family,
identity.date,
convention.compression,
)?;
let url = format!(
"{}/{}/{}{}",
entry.root_url,
product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
identity.official_filename,
compression.suffix()
);
Ok(DistributionLocation {
source,
original_url: Some(url),
archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
compression,
})
}
DistributionSource::NasaCddis => {
validate_cddis_distribution_era(identity)?;
let compression = product_archive_compression(
identity.analysis_center,
identity.family,
identity.date,
ArchiveCompression::Gzip,
)?;
Ok(DistributionLocation {
source,
original_url: Some(cddis_archive_url(identity)?),
archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
compression,
})
}
DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
source,
original_url: None,
archive_filename: identity.official_filename.clone(),
compression: ArchiveCompression::None,
}),
}
}
pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
identity.validate()?;
validate_cddis_distribution_era(identity)?;
match identity.family {
ProductType::Sp3 => {
let compression = product_archive_compression(
identity.analysis_center,
identity.family,
identity.date,
ArchiveCompression::Gzip,
)?;
Ok(format!(
"https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
identity.date.gps_week()?,
identity.official_filename,
compression.suffix()
))
}
ProductType::Ionex => Ok(format!(
"https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
identity.date.year,
identity.date.day_of_year(),
identity.official_filename
)),
product_type => Err(DataCatalogError::UnsupportedDistribution {
source: DistributionSource::NasaCddis,
product_type,
}),
}
}
pub fn mgex_clk(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
product(center, ProductType::Clk, date, sample, None)
}
pub fn mgex_nav(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
product(center, ProductType::Nav, date, sample, None)
}
pub fn mgex_ionex(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
product(center, ProductType::Ionex, date, sample, None)
}
pub fn rapid_ionex(
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
product(
AnalysisCenter::CodRap,
ProductType::Ionex,
date,
sample,
None,
)
}
#[must_use]
pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
match center {
AnalysisCenter::CodPrd2 => 1,
_ => 0,
}
}
pub fn predicted_ionex(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
match center {
AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
let target = date.add_days(predicted_day_offset(center))?;
product(center, ProductType::Ionex, target, sample, None)
}
other => Err(DataCatalogError::UnsupportedProduct {
center: other,
product_type: ProductType::Ionex,
}),
}
}
pub fn mgex_sp3(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
product(center, ProductType::Sp3, date, sample, None)
}
pub fn ops_ultra_sp3(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
let issue = issue.unwrap_or("0000");
product(center, ProductType::Sp3, date, sample, Some(issue))
}
pub fn ultra_sp3_locations(
center: AnalysisCenter,
date: ProductDate,
issue: &str,
) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
validate_issue_for_center(center, Some(issue))?;
validate_product_date(center, ProductType::Sp3, date)?;
match center {
AnalysisCenter::IgsUlt
| AnalysisCenter::CodUlt
| AnalysisCenter::EsaUlt
| AnalysisCenter::GfzUlt => {}
other => {
return Err(DataCatalogError::UnsupportedProduct {
center: other,
product_type: ProductType::Sp3,
})
}
};
let default_sample =
default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
samples.sort_by_key(|sample| *sample != default_sample);
samples
.into_iter()
.map(|sample| {
let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
let identity = spec.identity()?;
let filename = spec.canonical_filename()?;
let url = spec.archive_url()?;
let convention = product_convention(center, ProductType::Sp3)?;
let compression = product_archive_compression(
center,
ProductType::Sp3,
date,
convention.compression,
)?;
Ok(UltraSp3Location {
pattern: if sample == default_sample {
format!("primary_{}_{}", identity.span, sample)
} else {
format!("alternate_{}_{}", identity.span, sample)
},
span: identity.span,
sample: sample.to_string(),
url,
filename,
compression,
})
})
.collect()
}
pub fn ops_ultra_clk(
center: AnalysisCenter,
date: ProductDate,
sample: Option<&str>,
issue: Option<&str>,
) -> Result<ProductSpec, DataCatalogError> {
let issue = issue.unwrap_or("0000");
product(center, ProductType::Clk, date, sample, Some(issue))
}
pub fn latest_ops_ultra_sp3(
center: AnalysisCenter,
target: ProductDateTime,
sample: Option<&str>,
available_issues: Option<&[UltraIssue]>,
) -> Result<ProductSpec, DataCatalogError> {
let selected = latest_ultra_issue(center, target, available_issues)?;
ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
}
pub fn ultra_issue_candidates(
center: AnalysisCenter,
target: ProductDateTime,
) -> Result<Vec<UltraIssue>, DataCatalogError> {
let entry = center_catalog(center).expect("catalog entry exists for enum variant");
let _ = product_convention(center, ProductType::Sp3)?;
if entry.issues.is_empty() {
return Err(DataCatalogError::UnsupportedProduct {
center,
product_type: ProductType::Sp3,
});
}
validate_product_date(center, ProductType::Sp3, target.date)?;
let mut candidates = Vec::new();
for date in [target.date, target.date.add_days(-1)?] {
match validate_product_date(center, ProductType::Sp3, date) {
Ok(()) => {}
Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
Err(error) => return Err(error),
}
for issue in entry.issues.iter().rev() {
if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
candidates.push(UltraIssue::new(date, issue)?);
}
}
}
Ok(candidates)
}
pub fn latest_ultra_issue(
center: AnalysisCenter,
target: ProductDateTime,
available_issues: Option<&[UltraIssue]>,
) -> Result<UltraIssue, DataCatalogError> {
let candidates = ultra_issue_candidates(center, target)?;
if candidates.is_empty() {
return Err(DataCatalogError::NoUltraIssue);
}
if let Some(available) = available_issues {
candidates
.into_iter()
.find(|candidate| {
available
.iter()
.any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
})
.ok_or(DataCatalogError::NoAvailableUltraIssue)
} else {
Ok(candidates[0].clone())
}
}
pub fn gim_date_candidates(
center: AnalysisCenter,
target: ProductDate,
lookback: u32,
) -> Result<Vec<ProductDate>, DataCatalogError> {
let _ = product_convention(center, ProductType::Ionex)?;
let base = target.add_days(predicted_day_offset(center))?;
let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
for back in 0..=lookback {
out.push(base.add_days(-i64::from(back))?);
}
Ok(out)
}
pub fn station_obs(
station: &str,
date: ProductDate,
sample: Option<&str>,
) -> Result<StationObservationSpec, DataCatalogError> {
StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
}
pub fn station_obs_filename(
station: &str,
date: ProductDate,
sample: &str,
) -> Result<String, DataCatalogError> {
validate_station(station)?;
validate_sample(sample)?;
Ok(format!(
"{}_R_{}_01D_{}_MO.crx",
station,
date_block(date, None),
sample
))
}
pub fn station_obs_url(
station: &str,
date: ProductDate,
sample: &str,
) -> Result<String, DataCatalogError> {
let filename = station_obs_filename(station, date, sample)?;
Ok(format!(
"https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
filename
))
}
#[must_use]
pub const fn station_obs_protocol() -> ArchiveProtocol {
ArchiveProtocol::Https
}
fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
Ok(())
} else {
Err(DataCatalogError::InvalidTileIndex {
lat_index,
lon_index: 0,
})
}
}
fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
&& (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
{
Ok(())
} else {
Err(DataCatalogError::InvalidTileIndex {
lat_index,
lon_index,
})
}
}
fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
&& (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
{
Ok(())
} else {
Err(HgtConversionError::InvalidTileIndex {
lat_index,
lon_index,
})
}
}
fn dted_coord_field(index: i32, is_longitude: bool) -> String {
let hemi = match (is_longitude, index >= 0) {
(true, true) => 'E',
(true, false) => 'W',
(false, true) => 'N',
(false, false) => 'S',
};
format!("{:03}0000{hemi}", index.abs())
}
fn encode_dted_signed_magnitude(sample: i16) -> u16 {
if sample == i16::MIN {
0
} else if sample >= 0 {
sample as u16
} else {
0x8000 | (-i32::from(sample) as u16)
}
}
fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
PRODUCT_TYPE_CONVENTIONS
.iter()
.find(|descriptor| descriptor.product_type == product_type)
.expect("product descriptor exists for enum variant")
}
const fn product_format(product_type: ProductType) -> ProductFormat {
match product_type {
ProductType::Sp3 => ProductFormat::Sp3,
ProductType::Ionex => ProductFormat::Ionex,
ProductType::Clk => ProductFormat::RinexClock,
ProductType::Nav => ProductFormat::RinexNavigation,
}
}
fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
if filename.is_empty()
|| filename == "."
|| filename == ".."
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains('\0')
|| filename.contains("..")
{
Err(DataCatalogError::InvalidOfficialFilename(
filename.to_string(),
))
} else {
Ok(())
}
}
fn validate_product(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: &str,
issue: Option<&str>,
) -> Result<&'static CenterProductConvention, DataCatalogError> {
let convention = product_convention(center, product_type)?;
validate_sample(sample)?;
validate_issue_for_center(center, issue)?;
validate_product_date(center, product_type, date)?;
validate_catalog_sample(center, product_type, date, sample, issue)?;
Ok(convention)
}
fn validate_catalog_sample(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
sample: &str,
issue: Option<&str>,
) -> Result<(), DataCatalogError> {
let supported = supported_samples_inner(center, product_type, date, issue)?;
if supported.contains(&sample) {
return Ok(());
}
Err(DataCatalogError::UnsupportedSample {
center,
product_type,
sample: sample.to_string(),
})
}
pub fn supported_samples(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
issue: Option<&str>,
) -> Result<&'static [&'static str], DataCatalogError> {
ProductDate::new(date.year, date.month, date.day)?;
product_convention(center, product_type)?;
validate_product_date(center, product_type, date)?;
let entry = center_catalog(center).expect("catalog entry exists for enum variant");
if entry.issues.is_empty() {
validate_issue_for_center(center, issue)?;
} else {
validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
}
supported_samples_inner(center, product_type, date, issue)
}
fn supported_samples_inner(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
issue: Option<&str>,
) -> Result<&'static [&'static str], DataCatalogError> {
if product_type != ProductType::Sp3 {
let convention = product_convention(center, product_type)?;
return Ok(match convention.default_sample {
"30S" => &["30S"],
"01H" => &["01H"],
"02H" => &["02H"],
"01D" => &["01D"],
_ => &[],
});
}
Ok(match center {
AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
AnalysisCenter::Esa | AnalysisCenter::Cod | AnalysisCenter::CodUlt => &["05M"],
AnalysisCenter::Gfz => {
if date < GFZ_RAPID_5M_START_DATE {
&["15M"]
} else {
&["05M"]
}
}
AnalysisCenter::EsaUlt => {
let issue = issue.unwrap_or("0000");
let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
|| (date == ESA_ULTRA_15M_LAST_DATE
&& issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
if at_or_before_last_15m {
&["15M"]
} else {
&["05M"]
}
}
AnalysisCenter::GfzUlt => {
if date < GFZ_ULTRA_15M_LAST_DATE {
&["15M"]
} else if date == GFZ_ULTRA_15M_LAST_DATE {
if issue.unwrap_or("0000") == "0000" {
&["15M", "05M"]
} else {
&["15M"]
}
} else {
&["05M"]
}
}
AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
})
}
fn validate_product_date(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
) -> Result<(), DataCatalogError> {
if center == AnalysisCenter::Igs
&& product_type == ProductType::Sp3
&& date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
{
return Err(DataCatalogError::UnsupportedProductEra {
center,
product_type,
date,
});
}
if center == AnalysisCenter::Cod
&& matches!(
product_type,
ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
)
&& date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
{
return Err(DataCatalogError::UnsupportedProductEra {
center,
product_type,
date,
});
}
let start_date = match (center, product_type) {
(AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
Some(ESA_FINAL_SERIES_START_DATE)
}
(AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
Some(GFZ_RAPID_SERIES_START_DATE)
}
(AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
(AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
_ => None,
};
let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
&& product_type == ProductType::Sp3
&& date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
if before_long_name_start || start_date.is_some_and(|start| date < start) {
return Err(DataCatalogError::UnsupportedProductEra {
center,
product_type,
date,
});
}
Ok(())
}
fn default_sample_for_product_issue(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
issue: Option<&str>,
) -> Result<&'static str, DataCatalogError> {
ProductDate::new(date.year, date.month, date.day)?;
let current = default_sample(center, product_type)?;
validate_product_date(center, product_type, date)?;
if product_type != ProductType::Sp3 {
return Ok(current);
}
match center {
AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
AnalysisCenter::EsaUlt => {
let issue = issue.unwrap_or("0000");
validate_issue_for_center(center, Some(issue))?;
let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
|| (date == ESA_ULTRA_15M_LAST_DATE
&& issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
if at_or_before_last_15m {
Ok("15M")
} else {
Ok(current)
}
}
AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
_ => Ok(current),
}
}
fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
let gps_week = identity.date.gps_week()?;
let esa_mgex_final_sp3 =
identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
&& gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
&& !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
let unmodeled_pretransition_ionex =
identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
Err(DataCatalogError::UnsupportedDistributionEra {
source: DistributionSource::NasaCddis,
center: identity.analysis_center,
product_type: identity.family,
date: identity.date,
})
} else {
Ok(())
}
}
fn validate_issue_for_center(
center: AnalysisCenter,
issue: Option<&str>,
) -> Result<(), DataCatalogError> {
let entry = center_catalog(center).expect("catalog entry exists for enum variant");
match (entry.issues.is_empty(), issue) {
(true, None) => Ok(()),
(true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
(false, None) => Err(DataCatalogError::MissingIssue { center }),
(false, Some(issue)) => {
validate_issue(issue)?;
if entry.issues.contains(&issue) {
Ok(())
} else {
Err(DataCatalogError::UnsupportedIssue {
center,
issue: issue.to_string(),
})
}
}
}
}
fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
if validate_period_token(sample) {
Ok(())
} else {
Err(DataCatalogError::InvalidSample(sample.to_string()))
}
}
fn validate_span(span: &str) -> Result<(), DataCatalogError> {
if validate_period_token(span) {
Ok(())
} else {
Err(DataCatalogError::InvalidSpan(span.to_string()))
}
}
fn validate_period_token(token: &str) -> bool {
let bytes = token.as_bytes();
if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
return false;
}
let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
match bytes[2] {
b'S' | b'M' => amount > 0 && amount % 60 != 0,
b'H' => amount > 0 && amount % 24 != 0,
b'D' | b'W' | b'L' | b'Y' => amount > 0,
b'U' => amount == 0,
_ => false,
}
}
fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
let bytes = issue.as_bytes();
let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
if !valid_digits {
return Err(DataCatalogError::InvalidIssue(issue.to_string()));
}
let hour = issue[0..2]
.parse::<u8>()
.map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
let minute = issue[2..4]
.parse::<u8>()
.map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
if hour <= 23 && minute <= 59 {
Ok(())
} else {
Err(DataCatalogError::InvalidIssue(issue.to_string()))
}
}
fn validate_station(station: &str) -> Result<(), DataCatalogError> {
let bytes = station.as_bytes();
let valid = bytes.len() == 9
&& bytes
.iter()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
if valid {
Ok(())
} else {
Err(DataCatalogError::InvalidStation(station.to_string()))
}
}
fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
validate_issue(issue)?;
let hour = issue[0..2]
.parse::<u16>()
.map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
let minute = issue[2..4]
.parse::<u16>()
.map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
Ok(hour * 60 + minute)
}
fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
}
fn date_block(date: ProductDate, issue: Option<&str>) -> String {
format!(
"{}{:03}{}",
date.year,
date.day_of_year(),
issue.unwrap_or("0000")
)
}
fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
Ok(match layout {
ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
ArchiveLayout::BkgBrdcYearDoy => {
format!("BRDC/{}/{:03}", date.year, date.day_of_year())
}
ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
})
}
fn product_dir_path(
center: AnalysisCenter,
layout: ArchiveLayout,
date: ProductDate,
) -> Result<String, DataCatalogError> {
match center {
AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
_ => dir_path(layout, date),
}
}
fn uses_legacy_igs_final_name(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
) -> Result<bool, DataCatalogError> {
Ok(center == AnalysisCenter::Igs
&& product_type == ProductType::Sp3
&& date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
}
fn product_archive_compression(
center: AnalysisCenter,
product_type: ProductType,
date: ProductDate,
default: ArchiveCompression,
) -> Result<ArchiveCompression, DataCatalogError> {
if uses_legacy_igs_final_name(center, product_type, date)? {
Ok(ArchiveCompression::UnixCompress)
} else {
Ok(default)
}
}
fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
let (year, month, day) = civil_from_julian_day_number(jdn);
let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
}
#[cfg(test)]
mod content_start_tests {
use super::*;
const GFZ_ISSUES: [&str; 8] = [
"0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
];
fn date(year: i32, month: u8, day: u8) -> ProductDate {
ProductDate::new(year, month, day).expect("test date")
}
fn offset(
center: AnalysisCenter,
product_date: ProductDate,
sample: &str,
issue: Option<&str>,
) -> i64 {
let identity =
product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
.expect("cataloged SP3 identity");
exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
}
#[test]
fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
for issue in GFZ_ISSUES {
assert_eq!(
offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
-86_400,
"2022-09-06 issue {issue}"
);
}
}
#[test]
fn gfz_ultra_transition_is_cataloged_per_issue() {
let day_seven = [
0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
];
let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
assert_eq!(
offset(
AnalysisCenter::GfzUlt,
date(2022, 9, product_day),
"05M",
Some(issue)
),
expected_offset,
"2022-09-{product_day:02} issue {issue}"
);
}
}
}
#[test]
fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
for issue in GFZ_ISSUES {
assert_eq!(
offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
0,
"2022-09-09 issue {issue}"
);
}
let current = date(2026, 7, 20);
let cases = [
(AnalysisCenter::Igs, "15M", None),
(AnalysisCenter::Esa, "05M", None),
(AnalysisCenter::Cod, "05M", None),
(AnalysisCenter::Gfz, "05M", None),
(AnalysisCenter::IgsUlt, "15M", Some("1200")),
(AnalysisCenter::CodUlt, "05M", Some("0000")),
(AnalysisCenter::EsaUlt, "05M", Some("1800")),
(AnalysisCenter::GfzUlt, "05M", Some("2100")),
];
for (center, sample, issue) in cases {
assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
}
}
#[test]
fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
assert_eq!(
offset(
AnalysisCenter::GfzUlt,
date(2021, 5, 15),
"15M",
Some("0000")
),
-86_400
);
assert_eq!(
offset(
AnalysisCenter::GfzUlt,
date(2021, 5, 16),
"05M",
Some("0000")
),
-86_400
);
}
#[test]
fn public_content_start_query_enforces_center_issue_rules() {
assert_eq!(
sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
Err(DataCatalogError::UnsupportedIssue {
center: AnalysisCenter::GfzUlt,
issue: "0130".to_owned(),
})
);
assert_eq!(
sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
Err(DataCatalogError::UnexpectedIssue {
center: AnalysisCenter::Gfz,
})
);
assert_eq!(
sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
Err(DataCatalogError::MissingIssue {
center: AnalysisCenter::GfzUlt,
})
);
}
}