Skip to main content

sidereon_core/
data.rs

1//! Data product filename, cache path, and archive URL catalog.
2//!
3//! This module is sans-IO: it performs no network access, reads no files, and
4//! writes no cache entries. It only turns cataloged product inputs into
5//! canonical archive filenames, URLs, cache relative paths, and deterministic
6//! converted bytes for pure terrain ingestion.
7
8use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11
12use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
13use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
14use crate::astro::time::model::TimeScale;
15use crate::astro::time::scales::julian_day_number;
16use crate::terrain;
17
18/// Analysis-center code supported by the data-product catalog.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum AnalysisCenter {
21    /// `igs`.
22    Igs,
23    /// `cod_rap`.
24    CodRap,
25    /// `cod_prd1`.
26    CodPrd1,
27    /// `cod_prd2`.
28    CodPrd2,
29    /// `esa`.
30    Esa,
31    /// `cod`.
32    Cod,
33    /// `gfz`.
34    Gfz,
35    /// `igs_ult`.
36    IgsUlt,
37    /// `cod_ult`.
38    CodUlt,
39    /// `esa_ult`.
40    EsaUlt,
41    /// `gfz_ult`.
42    GfzUlt,
43}
44
45impl AnalysisCenter {
46    /// The lower-case catalog code.
47    #[must_use]
48    pub const fn code(self) -> &'static str {
49        match self {
50            Self::Igs => "igs",
51            Self::CodRap => "cod_rap",
52            Self::CodPrd1 => "cod_prd1",
53            Self::CodPrd2 => "cod_prd2",
54            Self::Esa => "esa",
55            Self::Cod => "cod",
56            Self::Gfz => "gfz",
57            Self::IgsUlt => "igs_ult",
58            Self::CodUlt => "cod_ult",
59            Self::EsaUlt => "esa_ult",
60            Self::GfzUlt => "gfz_ult",
61        }
62    }
63
64    /// Parse a lower-case catalog code.
65    #[must_use]
66    pub fn from_code(code: &str) -> Option<Self> {
67        match code {
68            "igs" => Some(Self::Igs),
69            "cod_rap" => Some(Self::CodRap),
70            "cod_prd1" => Some(Self::CodPrd1),
71            "cod_prd2" => Some(Self::CodPrd2),
72            "esa" => Some(Self::Esa),
73            "cod" => Some(Self::Cod),
74            "gfz" => Some(Self::Gfz),
75            "igs_ult" => Some(Self::IgsUlt),
76            "cod_ult" => Some(Self::CodUlt),
77            "esa_ult" => Some(Self::EsaUlt),
78            "gfz_ult" => Some(Self::GfzUlt),
79            _ => None,
80        }
81    }
82
83    /// Public publisher represented by this catalog product line.
84    #[must_use]
85    pub const fn publisher(self) -> ProductPublisher {
86        match self {
87            Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
88            Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
89                ProductPublisher::Code
90            }
91            Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
92            Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
93        }
94    }
95
96    /// Public solution class represented by this catalog product line.
97    #[must_use]
98    pub const fn solution_class(self) -> SolutionClass {
99        match self {
100            Self::Igs => SolutionClass::Broadcast,
101            Self::CodRap | Self::Gfz => SolutionClass::Rapid,
102            Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
103            Self::Esa | Self::Cod => SolutionClass::Final,
104            Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
105        }
106    }
107
108    /// Prediction horizon associated with a predicted product alias.
109    #[must_use]
110    pub const fn prediction_horizon_days(self) -> Option<u8> {
111        match self {
112            Self::CodPrd1 => Some(1),
113            Self::CodPrd2 => Some(2),
114            _ => None,
115        }
116    }
117}
118
119impl fmt::Display for AnalysisCenter {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(self.code())
122    }
123}
124
125impl FromStr for AnalysisCenter {
126    type Err = DataCatalogError;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
130    }
131}
132
133/// Product type supported by the data-product catalog.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
135pub enum ProductType {
136    /// Precise orbit SP3.
137    Sp3,
138    /// RINEX clock.
139    Clk,
140    /// Merged broadcast navigation.
141    Nav,
142    /// IONEX global ionosphere map.
143    Ionex,
144}
145
146impl ProductType {
147    /// The lower-case product code.
148    #[must_use]
149    pub const fn code(self) -> &'static str {
150        match self {
151            Self::Sp3 => "sp3",
152            Self::Clk => "clk",
153            Self::Nav => "nav",
154            Self::Ionex => "ionex",
155        }
156    }
157
158    /// Parse a lower-case product code.
159    #[must_use]
160    pub fn from_code(code: &str) -> Option<Self> {
161        match code {
162            "sp3" => Some(Self::Sp3),
163            "clk" => Some(Self::Clk),
164            "nav" => Some(Self::Nav),
165            "ionex" => Some(Self::Ionex),
166            _ => None,
167        }
168    }
169}
170
171impl fmt::Display for ProductType {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(self.code())
174    }
175}
176
177impl FromStr for ProductType {
178    type Err = DataCatalogError;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
182    }
183}
184
185/// Public organization that produced or combined a GNSS product.
186///
187/// This is intentionally separate from [`AnalysisCenter`]. Catalog center
188/// codes such as `cod`, `cod_rap`, and `cod_ult` select different product
189/// lines, but all three have the same publisher.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
191pub enum ProductPublisher {
192    /// International GNSS Service combination product.
193    Igs,
194    /// Center for Orbit Determination in Europe (CODE).
195    Code,
196    /// European Space Agency.
197    Esa,
198    /// GFZ German Research Centre for Geosciences.
199    Gfz,
200}
201
202impl ProductPublisher {
203    /// IGS long-filename publisher token.
204    #[must_use]
205    pub const fn code(self) -> &'static str {
206        match self {
207            Self::Igs => "IGS",
208            Self::Code => "COD",
209            Self::Esa => "ESA",
210            Self::Gfz => "GFZ",
211        }
212    }
213}
214
215impl fmt::Display for ProductPublisher {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.write_str(self.code())
218    }
219}
220
221/// Public solution class encoded in a GNSS product name.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
223pub enum SolutionClass {
224    /// Final product.
225    Final,
226    /// Rapid product.
227    Rapid,
228    /// Ultra-rapid product, which may contain observed and predicted segments.
229    UltraRapid,
230    /// Predicted product.
231    Predicted,
232    /// Broadcast navigation product.
233    Broadcast,
234}
235
236impl SolutionClass {
237    /// Stable public code used in Sidereon provenance and cache paths.
238    #[must_use]
239    pub const fn code(self) -> &'static str {
240        match self {
241            Self::Final => "final",
242            Self::Rapid => "rapid",
243            Self::UltraRapid => "ultra_rapid",
244            Self::Predicted => "predicted",
245            Self::Broadcast => "broadcast",
246        }
247    }
248
249    /// IGS long-filename solution token where one exists.
250    #[must_use]
251    pub const fn filename_token(self) -> Option<&'static str> {
252        match self {
253            Self::Final => Some("FIN"),
254            Self::Rapid => Some("RAP"),
255            Self::UltraRapid => Some("ULT"),
256            Self::Predicted => Some("PRD"),
257            Self::Broadcast => None,
258        }
259    }
260}
261
262impl fmt::Display for SolutionClass {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        f.write_str(self.code())
265    }
266}
267
268/// Public campaign or project encoded in a GNSS product name.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
270pub enum ProductCampaign {
271    /// Operational IGS product line.
272    Operational,
273    /// Multi-GNSS product line (`MGN`).
274    MultiGnss,
275    /// Multi-GNSS Experiment product line (`MGX`).
276    MultiGnssExperiment,
277    /// Broadcast navigation archive product.
278    Broadcast,
279}
280
281impl ProductCampaign {
282    /// Stable public code.
283    #[must_use]
284    pub const fn code(self) -> &'static str {
285        match self {
286            Self::Operational => "OPS",
287            Self::MultiGnss => "MGN",
288            Self::MultiGnssExperiment => "MGX",
289            Self::Broadcast => "BRD",
290        }
291    }
292}
293
294/// Public serialization format carried by a catalog product.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
296pub enum ProductFormat {
297    /// Standard Product 3 orbit format.
298    Sp3,
299    /// IONosphere map EXchange format.
300    Ionex,
301    /// RINEX clock format.
302    RinexClock,
303    /// RINEX navigation format.
304    RinexNavigation,
305}
306
307impl ProductFormat {
308    /// Stable public format code.
309    #[must_use]
310    pub const fn code(self) -> &'static str {
311        match self {
312            Self::Sp3 => "SP3",
313            Self::Ionex => "IONEX",
314            Self::RinexClock => "RINEX_CLK",
315            Self::RinexNavigation => "RINEX_NAV",
316        }
317    }
318}
319
320/// Explicit distributor used to obtain an exact public product.
321///
322/// Distributor selection never changes product publisher, solution class,
323/// issue, cadence, date, or family.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
325pub enum DistributionSource {
326    /// The cataloged analysis-center or IGS direct archive.
327    Direct,
328    /// NASA CDDIS over HTTPS, optionally authenticated with Earthdata Login.
329    NasaCddis,
330    /// Bytes read from a caller-provided local file.
331    LocalFile,
332    /// Bytes supplied directly by the caller.
333    InMemory,
334}
335
336impl DistributionSource {
337    /// Stable public code used in provenance and cache paths.
338    #[must_use]
339    pub const fn code(self) -> &'static str {
340        match self {
341            Self::Direct => "direct",
342            Self::NasaCddis => "nasa_cddis",
343            Self::LocalFile => "local_file",
344            Self::InMemory => "in_memory",
345        }
346    }
347}
348
349/// CelesTrak space-weather product served by the data catalog.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
351pub enum SpaceWeatherProduct {
352    /// `SW-All.csv`: full history plus daily and monthly predictions.
353    All,
354    /// `SW-Last5Years.csv`: observed rolling window.
355    Last5Years,
356}
357
358impl SpaceWeatherProduct {
359    /// The lower-case catalog code.
360    #[must_use]
361    pub const fn code(self) -> &'static str {
362        match self {
363            Self::All => "sw_all",
364            Self::Last5Years => "sw_last5",
365        }
366    }
367
368    /// Parse a lower-case catalog code.
369    #[must_use]
370    pub fn from_code(code: &str) -> Option<Self> {
371        match code {
372            "sw_all" => Some(Self::All),
373            "sw_last5" => Some(Self::Last5Years),
374            _ => None,
375        }
376    }
377}
378
379impl fmt::Display for SpaceWeatherProduct {
380    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381        f.write_str(self.code())
382    }
383}
384
385impl FromStr for SpaceWeatherProduct {
386    type Err = DataCatalogError;
387
388    fn from_str(s: &str) -> Result<Self, Self::Err> {
389        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
390    }
391}
392
393/// Archive transport protocol recorded by the catalog.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum ArchiveProtocol {
396    /// HTTP.
397    Http,
398    /// HTTPS.
399    Https,
400}
401
402impl ArchiveProtocol {
403    /// URI scheme text.
404    #[must_use]
405    pub const fn as_str(self) -> &'static str {
406        match self {
407            Self::Http => "http",
408            Self::Https => "https",
409        }
410    }
411}
412
413/// Archive compression for a cataloged product.
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
415pub enum ArchiveCompression {
416    /// Archive URL has a `.gz` suffix.
417    Gzip,
418    /// Archive URL is the plain product filename.
419    None,
420}
421
422impl ArchiveCompression {
423    /// Catalog text for the compression format.
424    #[must_use]
425    pub const fn as_str(self) -> &'static str {
426        match self {
427            Self::Gzip => "gzip",
428            Self::None => "none",
429        }
430    }
431
432    const fn suffix(self) -> &'static str {
433        match self {
434            Self::Gzip => ".gz",
435            Self::None => "",
436        }
437    }
438}
439
440/// Directory layout used below an archive root.
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
442pub enum ArchiveLayout {
443    /// `rapid/w<gps-week>`.
444    GfzRapidWeek,
445    /// `ultra/w<gps-week>`.
446    GfzUltraWeek,
447    /// `<gps-week>`.
448    GpsWeek,
449    /// `products/<gps-week>`.
450    BkgProductsWeek,
451    /// `BRDC/<year>/<day-of-year>`.
452    BkgBrdcYearDoy,
453    /// `obs/<year>/<day-of-year>`.
454    BkgObsYearDoy,
455    /// `CODE_MGEX/CODE/<year>`.
456    AiubCodeMgexYear,
457    /// `CODE/<year>`.
458    AiubCodeYear,
459    /// `CODE`.
460    AiubCodeRoot,
461}
462
463/// Product filename convention.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum ProductFilenameKind {
466    /// `TOKEN_DATE_LEN_SAMPLE_CODE.EXT`.
467    Sampled,
468    /// `TOKEN_R_DATE_LEN_CODE.ext`.
469    Nav,
470}
471
472/// Product-type filename convention.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub struct ProductTypeConvention {
475    /// Product type.
476    pub product_type: ProductType,
477    /// Filename content code, for example `ORB`.
478    pub content_code: &'static str,
479    /// Filename extension, preserving archive case.
480    pub extension: &'static str,
481    /// Filename convention.
482    pub kind: ProductFilenameKind,
483}
484
485/// Per-center convention for one product type.
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct CenterProductConvention {
488    /// Product type.
489    pub product_type: ProductType,
490    /// IGS long-name token prefix.
491    pub token: &'static str,
492    /// Directory layout under the archive root.
493    pub layout: ArchiveLayout,
494    /// Product span token.
495    pub span: &'static str,
496    /// Default sampling token.
497    pub default_sample: &'static str,
498    /// Archive compression.
499    pub compression: ArchiveCompression,
500}
501
502/// Static catalog entry for one analysis-center code.
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub struct CenterCatalogEntry {
505    /// Analysis-center code.
506    pub center: AnalysisCenter,
507    /// Lower-case catalog code.
508    pub code: &'static str,
509    /// Archive URI scheme.
510    pub protocol: ArchiveProtocol,
511    /// Archive host.
512    pub host: &'static str,
513    /// Archive root URL without trailing slash.
514    pub root_url: &'static str,
515    /// Product conventions served by this center.
516    pub products: &'static [CenterProductConvention],
517    /// Valid issue times for sub-daily products.
518    pub issues: &'static [&'static str],
519}
520
521/// Static catalog entry for one terrain source.
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub struct TerrainSourceEntry {
524    /// Archive URI scheme.
525    pub protocol: ArchiveProtocol,
526    /// Archive host.
527    pub host: &'static str,
528    /// Archive compression.
529    pub compression: ArchiveCompression,
530    /// Archive root URL without trailing slash.
531    pub root_url: &'static str,
532}
533
534/// Static catalog entry for the CelesTrak space-weather source.
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub struct SpaceWeatherSourceEntry {
537    /// Archive URI scheme.
538    pub protocol: ArchiveProtocol,
539    /// Archive host.
540    pub host: &'static str,
541    /// Archive compression.
542    pub compression: ArchiveCompression,
543    /// Archive root URL without trailing slash.
544    pub root_url: &'static str,
545}
546
547/// Product pair that is intentionally not offered because no open mirror exists.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
549pub struct NoOpenMirrorProduct {
550    /// Analysis-center code.
551    pub center: &'static str,
552    /// Product type code.
553    pub product_type: &'static str,
554}
555
556const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
557    ProductTypeConvention {
558        product_type: ProductType::Sp3,
559        content_code: "ORB",
560        extension: "SP3",
561        kind: ProductFilenameKind::Sampled,
562    },
563    ProductTypeConvention {
564        product_type: ProductType::Clk,
565        content_code: "CLK",
566        extension: "CLK",
567        kind: ProductFilenameKind::Sampled,
568    },
569    ProductTypeConvention {
570        product_type: ProductType::Nav,
571        content_code: "MN",
572        extension: "rnx",
573        kind: ProductFilenameKind::Nav,
574    },
575    ProductTypeConvention {
576        product_type: ProductType::Ionex,
577        content_code: "GIM",
578        extension: "INX",
579        kind: ProductFilenameKind::Sampled,
580    },
581];
582
583const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
584    product_type: ProductType::Ionex,
585    token: "COD0OPSRAP",
586    layout: ArchiveLayout::AiubCodeRoot,
587    span: "01D",
588    default_sample: "01H",
589    compression: ArchiveCompression::Gzip,
590}];
591
592const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
593    product_type: ProductType::Ionex,
594    token: "COD0OPSPRD",
595    layout: ArchiveLayout::AiubCodeRoot,
596    span: "01D",
597    default_sample: "01H",
598    compression: ArchiveCompression::Gzip,
599}];
600
601const ESA_PRODUCTS: [CenterProductConvention; 3] = [
602    CenterProductConvention {
603        product_type: ProductType::Sp3,
604        token: "ESA0MGNFIN",
605        layout: ArchiveLayout::GpsWeek,
606        span: "01D",
607        default_sample: "05M",
608        compression: ArchiveCompression::Gzip,
609    },
610    CenterProductConvention {
611        product_type: ProductType::Clk,
612        token: "ESA0MGNFIN",
613        layout: ArchiveLayout::GpsWeek,
614        span: "01D",
615        default_sample: "30S",
616        compression: ArchiveCompression::Gzip,
617    },
618    CenterProductConvention {
619        product_type: ProductType::Ionex,
620        token: "ESA0OPSFIN",
621        layout: ArchiveLayout::GpsWeek,
622        span: "01D",
623        default_sample: "02H",
624        compression: ArchiveCompression::Gzip,
625    },
626];
627
628const COD_PRODUCTS: [CenterProductConvention; 3] = [
629    CenterProductConvention {
630        product_type: ProductType::Sp3,
631        token: "COD0MGXFIN",
632        layout: ArchiveLayout::AiubCodeMgexYear,
633        span: "01D",
634        default_sample: "05M",
635        compression: ArchiveCompression::Gzip,
636    },
637    CenterProductConvention {
638        product_type: ProductType::Clk,
639        token: "COD0MGXFIN",
640        layout: ArchiveLayout::AiubCodeMgexYear,
641        span: "01D",
642        default_sample: "30S",
643        compression: ArchiveCompression::Gzip,
644    },
645    CenterProductConvention {
646        product_type: ProductType::Ionex,
647        token: "COD0OPSFIN",
648        layout: ArchiveLayout::AiubCodeYear,
649        span: "01D",
650        default_sample: "01H",
651        compression: ArchiveCompression::Gzip,
652    },
653];
654
655const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
656    CenterProductConvention {
657        product_type: ProductType::Sp3,
658        token: "GFZ0OPSRAP",
659        layout: ArchiveLayout::GfzRapidWeek,
660        span: "01D",
661        default_sample: "15M",
662        compression: ArchiveCompression::Gzip,
663    },
664    CenterProductConvention {
665        product_type: ProductType::Clk,
666        token: "GFZ0OPSRAP",
667        layout: ArchiveLayout::GfzRapidWeek,
668        span: "01D",
669        default_sample: "30S",
670        compression: ArchiveCompression::Gzip,
671    },
672];
673
674const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
675    product_type: ProductType::Nav,
676    token: "BRDC00WRD",
677    layout: ArchiveLayout::BkgBrdcYearDoy,
678    span: "01D",
679    default_sample: "01D",
680    compression: ArchiveCompression::Gzip,
681}];
682
683const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
684    product_type: ProductType::Sp3,
685    token: "IGS0OPSULT",
686    layout: ArchiveLayout::BkgProductsWeek,
687    span: "02D",
688    default_sample: "15M",
689    compression: ArchiveCompression::Gzip,
690}];
691
692const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
693    product_type: ProductType::Sp3,
694    token: "COD0OPSULT",
695    layout: ArchiveLayout::AiubCodeRoot,
696    span: "01D",
697    default_sample: "05M",
698    compression: ArchiveCompression::None,
699}];
700
701const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
702    product_type: ProductType::Sp3,
703    token: "ESA0OPSULT",
704    layout: ArchiveLayout::GpsWeek,
705    span: "02D",
706    default_sample: "05M",
707    compression: ArchiveCompression::Gzip,
708}];
709
710const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
711    product_type: ProductType::Sp3,
712    token: "GFZ0OPSULT",
713    layout: ArchiveLayout::GfzUltraWeek,
714    span: "02D",
715    default_sample: "05M",
716    compression: ArchiveCompression::Gzip,
717}];
718
719const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
720const COD_ULT_ISSUES: [&str; 1] = ["0000"];
721const GFZ_ULT_ISSUES: [&str; 8] = [
722    "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
723];
724
725const CENTER_ORDER: [AnalysisCenter; 11] = [
726    AnalysisCenter::CodRap,
727    AnalysisCenter::CodPrd1,
728    AnalysisCenter::CodPrd2,
729    AnalysisCenter::Igs,
730    AnalysisCenter::Esa,
731    AnalysisCenter::Cod,
732    AnalysisCenter::Gfz,
733    AnalysisCenter::IgsUlt,
734    AnalysisCenter::CodUlt,
735    AnalysisCenter::EsaUlt,
736    AnalysisCenter::GfzUlt,
737];
738
739const CATALOG: [CenterCatalogEntry; 11] = [
740    CenterCatalogEntry {
741        center: AnalysisCenter::CodRap,
742        code: "cod_rap",
743        protocol: ArchiveProtocol::Http,
744        host: "ftp.aiub.unibe.ch",
745        root_url: "http://ftp.aiub.unibe.ch",
746        products: &COD_RAP_PRODUCTS,
747        issues: &[],
748    },
749    CenterCatalogEntry {
750        center: AnalysisCenter::CodPrd1,
751        code: "cod_prd1",
752        protocol: ArchiveProtocol::Https,
753        host: "www.aiub.unibe.ch",
754        root_url: "https://www.aiub.unibe.ch/download",
755        products: &COD_PRD_PRODUCTS,
756        issues: &[],
757    },
758    CenterCatalogEntry {
759        center: AnalysisCenter::CodPrd2,
760        code: "cod_prd2",
761        protocol: ArchiveProtocol::Https,
762        host: "www.aiub.unibe.ch",
763        root_url: "https://www.aiub.unibe.ch/download",
764        products: &COD_PRD_PRODUCTS,
765        issues: &[],
766    },
767    CenterCatalogEntry {
768        center: AnalysisCenter::Igs,
769        code: "igs",
770        protocol: ArchiveProtocol::Https,
771        host: "igs.bkg.bund.de",
772        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
773        products: &IGS_PRODUCTS,
774        issues: &[],
775    },
776    CenterCatalogEntry {
777        center: AnalysisCenter::Esa,
778        code: "esa",
779        protocol: ArchiveProtocol::Https,
780        host: "navigation-office.esa.int",
781        root_url: "https://navigation-office.esa.int/products/gnss-products",
782        products: &ESA_PRODUCTS,
783        issues: &[],
784    },
785    CenterCatalogEntry {
786        center: AnalysisCenter::Cod,
787        code: "cod",
788        protocol: ArchiveProtocol::Http,
789        host: "ftp.aiub.unibe.ch",
790        root_url: "http://ftp.aiub.unibe.ch",
791        products: &COD_PRODUCTS,
792        issues: &[],
793    },
794    CenterCatalogEntry {
795        center: AnalysisCenter::Gfz,
796        code: "gfz",
797        protocol: ArchiveProtocol::Https,
798        host: "isdc-data.gfz.de",
799        root_url: "https://isdc-data.gfz.de/gnss/products",
800        products: &GFZ_PRODUCTS,
801        issues: &[],
802    },
803    CenterCatalogEntry {
804        center: AnalysisCenter::IgsUlt,
805        code: "igs_ult",
806        protocol: ArchiveProtocol::Https,
807        host: "igs.bkg.bund.de",
808        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
809        products: &IGS_ULT_PRODUCTS,
810        issues: &OPSULT_ISSUES,
811    },
812    CenterCatalogEntry {
813        center: AnalysisCenter::CodUlt,
814        code: "cod_ult",
815        protocol: ArchiveProtocol::Https,
816        host: "www.aiub.unibe.ch",
817        // AIUB retired the old ftp.aiub.unibe.ch HTTP tree. Its public file
818        // browser links products through this stable HTTPS download surface,
819        // which redirects to the current object store.
820        root_url: "https://www.aiub.unibe.ch/download",
821        products: &COD_ULT_PRODUCTS,
822        issues: &COD_ULT_ISSUES,
823    },
824    CenterCatalogEntry {
825        center: AnalysisCenter::EsaUlt,
826        code: "esa_ult",
827        protocol: ArchiveProtocol::Https,
828        host: "navigation-office.esa.int",
829        root_url: "https://navigation-office.esa.int/products/gnss-products",
830        products: &ESA_ULT_PRODUCTS,
831        issues: &OPSULT_ISSUES,
832    },
833    CenterCatalogEntry {
834        center: AnalysisCenter::GfzUlt,
835        code: "gfz_ult",
836        protocol: ArchiveProtocol::Https,
837        host: "isdc-data.gfz.de",
838        root_url: "https://isdc-data.gfz.de/gnss/products",
839        products: &GFZ_ULT_PRODUCTS,
840        issues: &GFZ_ULT_ISSUES,
841    },
842];
843
844const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
845    protocol: ArchiveProtocol::Https,
846    host: "s3.amazonaws.com",
847    compression: ArchiveCompression::Gzip,
848    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
849};
850
851const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
852    protocol: ArchiveProtocol::Https,
853    host: "celestrak.org",
854    compression: ArchiveCompression::None,
855    root_url: "https://celestrak.org/SpaceData",
856};
857
858const ALLOWED_HOSTS: [&str; 11] = [
859    "ftp.aiub.unibe.ch",
860    "www.aiub.unibe.ch",
861    "download.aiub.unibe.ch",
862    "zhw-b.s3.cloud.switch.ch",
863    "navigation-office.esa.int",
864    "isdc-data.gfz.de",
865    "igs.bkg.bund.de",
866    "s3.amazonaws.com",
867    "celestrak.org",
868    "cddis.nasa.gov",
869    "urs.earthdata.nasa.gov",
870];
871
872const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
873    NoOpenMirrorProduct {
874        center: "grg",
875        product_type: "sp3",
876    },
877    NoOpenMirrorProduct {
878        center: "grg",
879        product_type: "clk",
880    },
881    NoOpenMirrorProduct {
882        center: "wum",
883        product_type: "sp3",
884    },
885    NoOpenMirrorProduct {
886        center: "wum",
887        product_type: "clk",
888    },
889    NoOpenMirrorProduct {
890        center: "grg_ult",
891        product_type: "sp3",
892    },
893    NoOpenMirrorProduct {
894        center: "grg_ult",
895        product_type: "clk",
896    },
897    NoOpenMirrorProduct {
898        center: "igs",
899        product_type: "ionex",
900    },
901];
902
903/// Error returned by the pure data-product catalog.
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub enum DataCatalogError {
906    /// Unknown analysis-center code.
907    UnknownCenter(String),
908    /// Unknown product type code.
909    UnknownProductType(String),
910    /// The center does not serve the requested product type.
911    UnsupportedProduct {
912        /// Analysis center.
913        center: AnalysisCenter,
914        /// Product type.
915        product_type: ProductType,
916    },
917    /// A distributor does not carry the requested product family.
918    UnsupportedDistribution {
919        /// Explicit distributor.
920        source: DistributionSource,
921        /// Requested product family.
922        product_type: ProductType,
923    },
924    /// An exact request did not include any acceptable distributor.
925    NoDistributionSources,
926    /// A caller-constructed identity contained an unsafe official filename.
927    InvalidOfficialFilename(String),
928    /// A caller-constructed identity disagrees with its official filename.
929    InconsistentProductIdentity {
930        /// Identity field that did not agree with the filename or catalog convention.
931        field: &'static str,
932    },
933    /// The product has no verified anonymous HTTP(S) mirror.
934    NoOpenMirror {
935        /// Analysis-center code.
936        center: String,
937        /// Product type code.
938        product_type: String,
939    },
940    /// Bad civil date.
941    InvalidDate {
942        /// Year.
943        year: i32,
944        /// Month.
945        month: u8,
946        /// Day.
947        day: u8,
948    },
949    /// Date cannot be represented by this API.
950    DateOutOfRange,
951    /// Date precedes the GPS week epoch.
952    DateBeforeGpsEpoch(ProductDate),
953    /// GPS day-of-week must be `0..=6`.
954    InvalidGpsDayOfWeek(u8),
955    /// Sampling token is not `NNX` with an upper-case unit.
956    InvalidSample(String),
957    /// Issue time is malformed.
958    InvalidIssue(String),
959    /// The center requires an issue time.
960    MissingIssue {
961        /// Analysis center.
962        center: AnalysisCenter,
963    },
964    /// The center does not use issue times.
965    UnexpectedIssue {
966        /// Analysis center.
967        center: AnalysisCenter,
968    },
969    /// Issue time is valid text but not published by this center.
970    UnsupportedIssue {
971        /// Analysis center.
972        center: AnalysisCenter,
973        /// Issue time.
974        issue: String,
975    },
976    /// The target datetime was invalid.
977    InvalidDateTime {
978        /// Hour.
979        hour: u8,
980        /// Minute.
981        minute: u8,
982        /// Second.
983        second: u8,
984    },
985    /// No ultra-rapid issue exists at or before the requested target.
986    NoUltraIssue,
987    /// No available ultra-rapid issue exists at or before the requested target.
988    NoAvailableUltraIssue,
989    /// Station identifier is not a 9-character upper-case alphanumeric token.
990    InvalidStation(String),
991    /// Terrain lookup coordinate is non-finite or outside the reader range.
992    InvalidCoordinate {
993        /// Latitude as `f64::to_bits()`.
994        lat_deg_bits: u64,
995        /// Longitude as `f64::to_bits()`.
996        lon_deg_bits: u64,
997    },
998    /// Terrain tile index is outside the valid one-degree cell range.
999    InvalidTileIndex {
1000        /// Latitude index.
1001        lat_index: i32,
1002        /// Longitude index.
1003        lon_index: i32,
1004    },
1005    /// Skadi tile identifier is malformed.
1006    InvalidTileId(String),
1007}
1008
1009impl fmt::Display for DataCatalogError {
1010    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1011        match self {
1012            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1013            Self::UnknownProductType(product_type) => {
1014                write!(f, "unknown product type {product_type:?}")
1015            }
1016            Self::UnsupportedProduct {
1017                center,
1018                product_type,
1019            } => write!(f, "{center} does not serve {product_type}"),
1020            Self::UnsupportedDistribution {
1021                source,
1022                product_type,
1023            } => write!(
1024                f,
1025                "distributor {} does not serve {product_type}",
1026                source.code()
1027            ),
1028            Self::NoDistributionSources => {
1029                write!(f, "exact product request has no distributors")
1030            }
1031            Self::InvalidOfficialFilename(filename) => {
1032                write!(f, "invalid official product filename {filename:?}")
1033            }
1034            Self::InconsistentProductIdentity { field } => {
1035                write!(
1036                    f,
1037                    "product identity field {field:?} disagrees with its official filename"
1038                )
1039            }
1040            Self::NoOpenMirror {
1041                center,
1042                product_type,
1043            } => write!(f, "{center}/{product_type} has no open mirror"),
1044            Self::InvalidDate { year, month, day } => {
1045                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1046            }
1047            Self::DateOutOfRange => write!(f, "product date is out of range"),
1048            Self::DateBeforeGpsEpoch(date) => {
1049                write!(f, "product date {date} is before the GPS week epoch")
1050            }
1051            Self::InvalidGpsDayOfWeek(day) => {
1052                write!(f, "invalid GPS day-of-week {day}")
1053            }
1054            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1055            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1056            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1057            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1058            Self::UnsupportedIssue { center, issue } => {
1059                write!(f, "{center} does not publish issue {issue:?}")
1060            }
1061            Self::InvalidDateTime {
1062                hour,
1063                minute,
1064                second,
1065            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1066            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1067            Self::NoAvailableUltraIssue => {
1068                write!(f, "no available ultra-rapid issue at or before target")
1069            }
1070            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1071            Self::InvalidCoordinate {
1072                lat_deg_bits,
1073                lon_deg_bits,
1074            } => write!(
1075                f,
1076                "invalid terrain coordinate lat={} lon={}",
1077                f64::from_bits(*lat_deg_bits),
1078                f64::from_bits(*lon_deg_bits)
1079            ),
1080            Self::InvalidTileIndex {
1081                lat_index,
1082                lon_index,
1083            } => write!(
1084                f,
1085                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1086            ),
1087            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1088        }
1089    }
1090}
1091
1092impl std::error::Error for DataCatalogError {}
1093
1094/// Error returned by SRTM HGT to DTED conversion.
1095#[derive(Debug, Clone, PartialEq, Eq)]
1096pub enum HgtConversionError {
1097    /// The decompressed HGT payload is not the SRTM1 byte length.
1098    BadLength {
1099        /// Expected byte length.
1100        expected: usize,
1101        /// Actual byte length.
1102        got: usize,
1103    },
1104    /// Terrain tile index is outside the valid one-degree cell range.
1105    InvalidTileIndex {
1106        /// Latitude index.
1107        lat_index: i32,
1108        /// Longitude index.
1109        lon_index: i32,
1110    },
1111}
1112
1113impl fmt::Display for HgtConversionError {
1114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1115        match self {
1116            Self::BadLength { expected, got } => {
1117                write!(
1118                    f,
1119                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
1120                )
1121            }
1122            Self::InvalidTileIndex {
1123                lat_index,
1124                lon_index,
1125            } => write!(
1126                f,
1127                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1128            ),
1129        }
1130    }
1131}
1132
1133impl std::error::Error for HgtConversionError {}
1134
1135const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1136const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1137const MIN_TERRAIN_LON_INDEX: i32 = -180;
1138const MAX_TERRAIN_LON_INDEX: i32 = 179;
1139const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1140const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1141const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1142const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1143const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1144const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1145const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1146const DTED_SRTM1_LEN: usize =
1147    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1148
1149/// Civil UTC date used by product archive names.
1150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1151pub struct ProductDate {
1152    /// Year.
1153    pub year: i32,
1154    /// Month in `1..=12`.
1155    pub month: u8,
1156    /// Day of month.
1157    pub day: u8,
1158}
1159
1160impl ProductDate {
1161    /// Build and validate a civil date.
1162    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1163        let days = days_in_month(i64::from(year), i64::from(month));
1164        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1165            return Err(DataCatalogError::InvalidDate { year, month, day });
1166        }
1167        Ok(Self { year, month, day })
1168    }
1169
1170    /// Build a date from GPS week and day-of-week (`0` = Sunday).
1171    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1172        if day_of_week > 6 {
1173            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1174        }
1175        let epoch_jdn =
1176            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1177        let offset_days = i64::from(week)
1178            .checked_mul(7)
1179            .and_then(|days| days.checked_add(i64::from(day_of_week)))
1180            .ok_or(DataCatalogError::DateOutOfRange)?;
1181        product_date_from_jdn(
1182            epoch_jdn
1183                .checked_add(offset_days)
1184                .ok_or(DataCatalogError::DateOutOfRange)?,
1185        )
1186    }
1187
1188    /// GPS week for this date.
1189    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1190        week_from_calendar(
1191            TimeScale::Gpst,
1192            i64::from(self.year),
1193            i64::from(self.month),
1194            i64::from(self.day),
1195        )
1196        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1197    }
1198
1199    /// Day-of-year in `1..=366`.
1200    #[must_use]
1201    pub fn day_of_year(self) -> u16 {
1202        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1203    }
1204
1205    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1206        product_date_from_jdn(
1207            self.julian_day_number()
1208                .checked_add(days)
1209                .ok_or(DataCatalogError::DateOutOfRange)?,
1210        )
1211    }
1212
1213    fn julian_day_number(self) -> i64 {
1214        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1215    }
1216}
1217
1218impl fmt::Display for ProductDate {
1219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1220        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1221    }
1222}
1223
1224/// Civil UTC date and time used for ultra-rapid issue selection.
1225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1226pub struct ProductDateTime {
1227    /// Date.
1228    pub date: ProductDate,
1229    /// Hour in `0..=23`.
1230    pub hour: u8,
1231    /// Minute in `0..=59`.
1232    pub minute: u8,
1233    /// Second in `0..=59`.
1234    pub second: u8,
1235}
1236
1237impl ProductDateTime {
1238    /// Build and validate a civil date and time.
1239    pub fn new(
1240        date: ProductDate,
1241        hour: u8,
1242        minute: u8,
1243        second: u8,
1244    ) -> Result<Self, DataCatalogError> {
1245        if hour > 23 || minute > 59 || second > 59 {
1246            return Err(DataCatalogError::InvalidDateTime {
1247                hour,
1248                minute,
1249                second,
1250            });
1251        }
1252        Ok(Self {
1253            date,
1254            hour,
1255            minute,
1256            second,
1257        })
1258    }
1259
1260    fn ordering_minutes(self) -> i64 {
1261        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1262    }
1263}
1264
1265/// Ultra-rapid issue date and `HHMM` issue time.
1266#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1267pub struct UltraIssue {
1268    /// Product date.
1269    pub date: ProductDate,
1270    /// Issue time.
1271    pub issue: String,
1272}
1273
1274impl UltraIssue {
1275    /// Build and validate an ultra-rapid issue.
1276    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1277        validate_issue(issue)?;
1278        Ok(Self {
1279            date,
1280            issue: issue.to_string(),
1281        })
1282    }
1283}
1284
1285/// One generated ultra-rapid SP3 archive candidate.
1286#[derive(Debug, Clone, PartialEq, Eq)]
1287pub struct UltraSp3Location {
1288    /// Stable catalog label identifying the primary, alternate, or alias rule.
1289    pub pattern: String,
1290    /// Product span token used by the candidate.
1291    pub span: String,
1292    /// Sampling token used by the candidate.
1293    pub sample: String,
1294    /// Archive filename without a transport compression suffix.
1295    pub filename: String,
1296    /// Full archive URL, including its compression suffix when applicable.
1297    pub url: String,
1298    /// Archive compression for this candidate.
1299    pub compression: ArchiveCompression,
1300}
1301
1302#[derive(Debug, Clone, Copy)]
1303struct UltraSp3Pattern {
1304    label: &'static str,
1305    span: &'static str,
1306    sample: &'static str,
1307    alias_filename: Option<&'static str>,
1308}
1309
1310const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1311    UltraSp3Pattern {
1312        label: "primary_02D_15M",
1313        span: "02D",
1314        sample: "15M",
1315        alias_filename: None,
1316    },
1317    UltraSp3Pattern {
1318        label: "alternate_02D_05M",
1319        span: "02D",
1320        sample: "05M",
1321        alias_filename: None,
1322    },
1323    UltraSp3Pattern {
1324        label: "alternate_01D_15M",
1325        span: "01D",
1326        sample: "15M",
1327        alias_filename: None,
1328    },
1329];
1330
1331const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1332    UltraSp3Pattern {
1333        label: "primary_01D_05M",
1334        span: "01D",
1335        sample: "05M",
1336        alias_filename: None,
1337    },
1338    UltraSp3Pattern {
1339        label: "alternate_02D_05M",
1340        span: "02D",
1341        sample: "05M",
1342        alias_filename: None,
1343    },
1344    UltraSp3Pattern {
1345        label: "alias_latest",
1346        span: "01D",
1347        sample: "05M",
1348        alias_filename: Some("COD0OPSULT.SP3"),
1349    },
1350];
1351
1352const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1353    UltraSp3Pattern {
1354        label: "primary_02D_05M",
1355        span: "02D",
1356        sample: "05M",
1357        alias_filename: None,
1358    },
1359    UltraSp3Pattern {
1360        label: "alternate_02D_15M",
1361        span: "02D",
1362        sample: "15M",
1363        alias_filename: None,
1364    },
1365    UltraSp3Pattern {
1366        label: "alternate_01D_05M",
1367        span: "01D",
1368        sample: "05M",
1369        alias_filename: None,
1370    },
1371];
1372
1373const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1374    UltraSp3Pattern {
1375        label: "primary_02D_05M",
1376        span: "02D",
1377        sample: "05M",
1378        alias_filename: None,
1379    },
1380    UltraSp3Pattern {
1381        label: "alternate_02D_15M",
1382        span: "02D",
1383        sample: "15M",
1384        alias_filename: None,
1385    },
1386    UltraSp3Pattern {
1387        label: "alternate_01D_05M",
1388        span: "01D",
1389        sample: "05M",
1390        alias_filename: None,
1391    },
1392];
1393
1394/// Exact identity of one public GNSS product, independent of distributor.
1395///
1396/// The official filename is part of the identity. Transport compression and
1397/// URL belong to [`DistributionLocation`] because two distributors may package
1398/// the same decompressed product differently.
1399#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1400pub struct ProductIdentity {
1401    /// Product family.
1402    pub family: ProductType,
1403    /// Catalog analysis-center product line.
1404    pub analysis_center: AnalysisCenter,
1405    /// Producing or combining organization.
1406    pub publisher: ProductPublisher,
1407    /// Solution class or tier.
1408    pub solution: SolutionClass,
1409    /// Campaign or project.
1410    pub campaign: ProductCampaign,
1411    /// Product-line version encoded by the long filename.
1412    pub version: u8,
1413    /// Nominal product start date.
1414    pub date: ProductDate,
1415    /// Optional `HHMM` issue/start time.
1416    pub issue: Option<String>,
1417    /// Intended coverage period token, for example `01D`.
1418    pub span: String,
1419    /// Sampling interval token, for example `05M`.
1420    pub sample: String,
1421    /// Official filename without transport compression suffix.
1422    pub official_filename: String,
1423    /// Public serialization format.
1424    pub format: ProductFormat,
1425    /// Parsed serialization revision when the request constrains one.
1426    ///
1427    /// Catalog identities leave this unset because the revision is carried by
1428    /// product content rather than the official filename. A resolved identity
1429    /// may set it after parsing the product.
1430    pub format_version: Option<String>,
1431    /// Prediction horizon when the product line encodes one.
1432    pub prediction_horizon_days: Option<u8>,
1433}
1434
1435impl ProductIdentity {
1436    /// Validate that every identity field agrees with the official filename.
1437    ///
1438    /// This is required for caller-constructed values before using them in a
1439    /// request, URL, or cache path. Catalog-produced identities are validated
1440    /// before they are returned.
1441    pub fn validate(&self) -> Result<(), DataCatalogError> {
1442        validate_official_filename(&self.official_filename)?;
1443        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1444        validate_sample(&self.sample)?;
1445        if let Some(issue) = self.issue.as_deref() {
1446            validate_issue(issue)?;
1447        }
1448
1449        if self.format != product_format(self.family) {
1450            return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1451        }
1452
1453        if self
1454            .format_version
1455            .as_deref()
1456            .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1457        {
1458            return Err(DataCatalogError::InconsistentProductIdentity {
1459                field: "format_version",
1460            });
1461        }
1462
1463        let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1464            (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1465            (_, SolutionClass::Predicted, _) => false,
1466            (_, _, None) => true,
1467            (_, _, Some(_)) => false,
1468        };
1469        if !horizon_valid {
1470            return Err(DataCatalogError::InconsistentProductIdentity {
1471                field: "prediction_horizon_days",
1472            });
1473        }
1474        let descriptor = product_type_convention(self.family);
1475        let expected = match descriptor.kind {
1476            ProductFilenameKind::Sampled => {
1477                let solution_token = self
1478                    .solution
1479                    .filename_token()
1480                    .ok_or(DataCatalogError::InconsistentProductIdentity { field: "solution" })?;
1481                format!(
1482                    "{}{}{}{}_{}_{}_{}_{}.{}",
1483                    self.publisher.code(),
1484                    self.version,
1485                    self.campaign.code(),
1486                    solution_token,
1487                    date_block(self.date, self.issue.as_deref()),
1488                    self.span,
1489                    self.sample,
1490                    descriptor.content_code,
1491                    descriptor.extension
1492                )
1493            }
1494            ProductFilenameKind::Nav => {
1495                let nav_fields_valid = self.publisher == ProductPublisher::Igs
1496                    && self.solution == SolutionClass::Broadcast
1497                    && self.campaign == ProductCampaign::Broadcast
1498                    && self.version == 0
1499                    && self.issue.is_none()
1500                    && self.span == "01D"
1501                    && self.sample == "01D";
1502                if !nav_fields_valid {
1503                    return Err(DataCatalogError::InconsistentProductIdentity {
1504                        field: "broadcast_navigation",
1505                    });
1506                }
1507                format!(
1508                    "BRDC00WRD_R_{}_{}_{}.{}",
1509                    date_block(self.date, None),
1510                    self.span,
1511                    descriptor.content_code,
1512                    descriptor.extension
1513                )
1514            }
1515        };
1516        if expected != self.official_filename {
1517            return Err(DataCatalogError::InconsistentProductIdentity {
1518                field: "official_filename",
1519            });
1520        }
1521        if self.publisher != self.analysis_center.publisher()
1522            || self.solution != self.analysis_center.solution_class()
1523            || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1524        {
1525            return Err(DataCatalogError::InconsistentProductIdentity {
1526                field: "analysis_center",
1527            });
1528        }
1529        Ok(())
1530    }
1531
1532    /// Deterministic identity key suitable for a portable cache layout.
1533    pub fn key(&self) -> Result<String, DataCatalogError> {
1534        use sha2::{Digest, Sha256};
1535
1536        let canonical = self.canonical_bytes()?;
1537        let digest = Sha256::digest(canonical);
1538        Ok(format!(
1539            "{}-{}-{}",
1540            self.publisher.code().to_ascii_lowercase(),
1541            self.solution.code(),
1542            digest[..10]
1543                .iter()
1544                .map(|byte| format!("{byte:02x}"))
1545                .collect::<String>()
1546        ))
1547    }
1548
1549    /// Canonical, unambiguous bytes containing every exact identity field.
1550    ///
1551    /// The encoding is ASCII/UTF-8 field text separated by NUL bytes. It is a
1552    /// stable cross-interface input to cache identity hashing, not a display
1553    /// or interchange document.
1554    pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1555        self.validate()?;
1556        let date = format!(
1557            "{:04}-{:02}-{:02}",
1558            self.date.year, self.date.month, self.date.day
1559        );
1560        let version = self.version.to_string();
1561        let prediction = self
1562            .prediction_horizon_days
1563            .map(|days| days.to_string())
1564            .unwrap_or_default();
1565        let fields = [
1566            self.family.code(),
1567            self.analysis_center.code(),
1568            self.publisher.code(),
1569            self.solution.code(),
1570            self.campaign.code(),
1571            version.as_str(),
1572            date.as_str(),
1573            self.issue.as_deref().unwrap_or_default(),
1574            self.span.as_str(),
1575            self.sample.as_str(),
1576            self.official_filename.as_str(),
1577            self.format.code(),
1578            self.format_version.as_deref().unwrap_or_default(),
1579            prediction.as_str(),
1580        ];
1581        if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1582            return Err(DataCatalogError::InconsistentProductIdentity {
1583                field: "canonical_encoding",
1584            });
1585        }
1586        Ok(fields.join("\0").into_bytes())
1587    }
1588
1589    /// Deterministic cache path for this identity and distributor.
1590    pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1591        Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1592    }
1593}
1594
1595/// Distribution metadata for an exact product identity.
1596#[derive(Debug, Clone, PartialEq, Eq)]
1597pub struct DistributionLocation {
1598    /// Selected distributor.
1599    pub source: DistributionSource,
1600    /// Original public URL. Local and in-memory sources have no URL.
1601    pub original_url: Option<String>,
1602    /// Archive filename as served, including transport compression suffix.
1603    pub archive_filename: String,
1604    /// Compression applied by this distributor.
1605    pub compression: ArchiveCompression,
1606}
1607
1608/// Exact product request with an ordered, caller-controlled distributor list.
1609#[derive(Debug, Clone, PartialEq, Eq)]
1610pub struct ProductRequest {
1611    /// Exact requested identity.
1612    pub identity: ProductIdentity,
1613    /// Ordered acceptable distributors for that identity only.
1614    pub distributors: Vec<DistributionSource>,
1615}
1616
1617/// Complete-set validation failure for exact product identities.
1618#[derive(Debug, Clone, PartialEq, Eq)]
1619pub enum ExactProductSetError {
1620    /// A complete set must declare at least one expected product.
1621    EmptyExpected,
1622    /// One expected identity was not internally consistent.
1623    InvalidExpected {
1624        /// Zero-based position in the expected identity list.
1625        index: usize,
1626        /// Identity validation failure.
1627        source: DataCatalogError,
1628    },
1629    /// One available identity was not internally consistent.
1630    InvalidAvailable {
1631        /// Zero-based position in the available identity list.
1632        index: usize,
1633        /// Identity validation failure.
1634        source: DataCatalogError,
1635    },
1636    /// The available identities were not exactly the expected set.
1637    Mismatch {
1638        /// Expected identities that were not available.
1639        missing: Vec<ProductIdentity>,
1640        /// Available identities that were not expected.
1641        unexpected: Vec<ProductIdentity>,
1642        /// Identities declared more than once in the expected list.
1643        duplicate_expected: Vec<ProductIdentity>,
1644        /// Identities declared more than once in the available list.
1645        duplicate_available: Vec<ProductIdentity>,
1646    },
1647}
1648
1649impl fmt::Display for ExactProductSetError {
1650    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1651        match self {
1652            Self::EmptyExpected => write!(f, "exact product set has no expected products"),
1653            Self::InvalidExpected { index, source } => {
1654                write!(f, "expected product {index} is invalid: {source}")
1655            }
1656            Self::InvalidAvailable { index, source } => {
1657                write!(f, "available product {index} is invalid: {source}")
1658            }
1659            Self::Mismatch {
1660                missing,
1661                unexpected,
1662                duplicate_expected,
1663                duplicate_available,
1664            } => write!(
1665                f,
1666                "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
1667                identity_list(missing),
1668                identity_list(unexpected),
1669                identity_list(duplicate_expected),
1670                identity_list(duplicate_available),
1671            ),
1672        }
1673    }
1674}
1675
1676impl std::error::Error for ExactProductSetError {}
1677
1678/// Require an available product inventory to match an expected exact set.
1679///
1680/// Every identity is validated before comparison. The expected list must be
1681/// non-empty, neither list may contain duplicates, every expected identity must
1682/// be available, and no undeclared identity may be present. Comparison uses the
1683/// complete [`ProductIdentity`], not only its filename, so metadata that
1684/// distinguishes otherwise identical archive names remains authoritative.
1685///
1686/// This function is a sans-IO completion gate: pass only identities from
1687/// successfully validated acquisitions, and do not start dependent processing
1688/// unless it returns `Ok(())`. For SP3 observed/predicted timing, use
1689/// [`crate::sp3::Sp3::prediction_summary`]; issue times and catalog fields are
1690/// not substitutes for the record flags in the product itself.
1691pub fn validate_exact_product_set(
1692    expected: &[ProductIdentity],
1693    available: &[ProductIdentity],
1694) -> Result<(), ExactProductSetError> {
1695    if expected.is_empty() {
1696        return Err(ExactProductSetError::EmptyExpected);
1697    }
1698    for (index, identity) in expected.iter().enumerate() {
1699        identity
1700            .validate()
1701            .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
1702    }
1703    for (index, identity) in available.iter().enumerate() {
1704        identity
1705            .validate()
1706            .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
1707    }
1708
1709    let expected_counts = identity_counts(expected);
1710    let available_counts = identity_counts(available);
1711    let missing = unique_matching(expected, |identity| {
1712        !available_counts.contains_key(identity)
1713    });
1714    let unexpected = unique_matching(available, |identity| {
1715        !expected_counts.contains_key(identity)
1716    });
1717    let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
1718    let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
1719
1720    if missing.is_empty()
1721        && unexpected.is_empty()
1722        && duplicate_expected.is_empty()
1723        && duplicate_available.is_empty()
1724    {
1725        Ok(())
1726    } else {
1727        Err(ExactProductSetError::Mismatch {
1728            missing,
1729            unexpected,
1730            duplicate_expected,
1731            duplicate_available,
1732        })
1733    }
1734}
1735
1736fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
1737    let mut counts = HashMap::with_capacity(identities.len());
1738    for identity in identities {
1739        *counts.entry(identity).or_insert(0) += 1;
1740    }
1741    counts
1742}
1743
1744fn unique_matching(
1745    identities: &[ProductIdentity],
1746    mut predicate: impl FnMut(&ProductIdentity) -> bool,
1747) -> Vec<ProductIdentity> {
1748    let mut seen = HashSet::with_capacity(identities.len());
1749    identities
1750        .iter()
1751        .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
1752        .cloned()
1753        .collect()
1754}
1755
1756fn identity_list(identities: &[ProductIdentity]) -> String {
1757    if identities.is_empty() {
1758        return "none".to_string();
1759    }
1760    identities
1761        .iter()
1762        .map(|identity| {
1763            identity
1764                .key()
1765                .unwrap_or_else(|_| identity.official_filename.clone())
1766        })
1767        .collect::<Vec<_>>()
1768        .join(", ")
1769}
1770
1771impl ProductRequest {
1772    /// Build an exact request. At least one distributor is required.
1773    pub fn new(
1774        identity: ProductIdentity,
1775        distributors: Vec<DistributionSource>,
1776    ) -> Result<Self, DataCatalogError> {
1777        if distributors.is_empty() {
1778            return Err(DataCatalogError::NoDistributionSources);
1779        }
1780        identity.validate()?;
1781        Ok(Self {
1782            identity,
1783            distributors,
1784        })
1785    }
1786}
1787
1788/// A pure product specification that resolves to one archive filename and URL.
1789#[derive(Debug, Clone, PartialEq, Eq)]
1790pub struct ProductSpec {
1791    /// Analysis center.
1792    pub center: AnalysisCenter,
1793    /// Product type.
1794    pub product_type: ProductType,
1795    /// Product date.
1796    pub date: ProductDate,
1797    /// Sampling token.
1798    pub sample: String,
1799    /// Optional issue time for ultra-rapid products.
1800    pub issue: Option<String>,
1801}
1802
1803impl ProductSpec {
1804    /// Build a product specification and validate it against the catalog.
1805    pub fn new(
1806        center: AnalysisCenter,
1807        product_type: ProductType,
1808        date: ProductDate,
1809        sample: &str,
1810        issue: Option<&str>,
1811    ) -> Result<Self, DataCatalogError> {
1812        validate_product(center, product_type, sample, issue)?;
1813        Ok(Self {
1814            center,
1815            product_type,
1816            date,
1817            sample: sample.to_string(),
1818            issue: issue.map(ToOwned::to_owned),
1819        })
1820    }
1821
1822    /// GPS week for the product date.
1823    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1824        self.date.gps_week()
1825    }
1826
1827    /// Day-of-year for the product date.
1828    #[must_use]
1829    pub fn day_of_year(&self) -> u16 {
1830        self.date.day_of_year()
1831    }
1832
1833    /// Canonical IGS long-name filename without archive compression suffix.
1834    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1835        let convention = validate_product(
1836            self.center,
1837            self.product_type,
1838            &self.sample,
1839            self.issue.as_deref(),
1840        )?;
1841        let descriptor = product_type_convention(self.product_type);
1842        Ok(match descriptor.kind {
1843            ProductFilenameKind::Sampled => format!(
1844                "{}_{}_{}_{}_{}.{}",
1845                convention.token,
1846                date_block(self.date, self.issue.as_deref()),
1847                convention.span,
1848                self.sample,
1849                descriptor.content_code,
1850                descriptor.extension
1851            ),
1852            ProductFilenameKind::Nav => format!(
1853                "{}_R_{}_{}_{}.{}",
1854                convention.token,
1855                date_block(self.date, None),
1856                convention.span,
1857                descriptor.content_code,
1858                descriptor.extension
1859            ),
1860        })
1861    }
1862
1863    /// Full archive URL, including `.gz` when the cataloged archive is gzipped.
1864    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1865        let convention = validate_product(
1866            self.center,
1867            self.product_type,
1868            &self.sample,
1869            self.issue.as_deref(),
1870        )?;
1871        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1872        let filename = self.canonical_filename()?;
1873        Ok(format!(
1874            "{}/{}/{}{}",
1875            entry.root_url,
1876            product_dir_path(self.center, convention.layout, self.date)?,
1877            filename,
1878            convention.compression.suffix()
1879        ))
1880    }
1881
1882    /// Exact product identity, independent of distributor.
1883    pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
1884        let convention = validate_product(
1885            self.center,
1886            self.product_type,
1887            &self.sample,
1888            self.issue.as_deref(),
1889        )?;
1890        let descriptor = product_type_convention(self.product_type);
1891        let campaign = match descriptor.kind {
1892            ProductFilenameKind::Nav => ProductCampaign::Broadcast,
1893            ProductFilenameKind::Sampled => match convention.token.get(4..7) {
1894                Some("OPS") => ProductCampaign::Operational,
1895                Some("MGN") => ProductCampaign::MultiGnss,
1896                Some("MGX") => ProductCampaign::MultiGnssExperiment,
1897                _ => {
1898                    return Err(DataCatalogError::InconsistentProductIdentity {
1899                        field: "campaign",
1900                    });
1901                }
1902            },
1903        };
1904        let identity = ProductIdentity {
1905            family: self.product_type,
1906            analysis_center: self.center,
1907            publisher: self.center.publisher(),
1908            solution: self.center.solution_class(),
1909            campaign,
1910            version: 0,
1911            date: self.date,
1912            issue: match descriptor.kind {
1913                ProductFilenameKind::Sampled => {
1914                    Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
1915                }
1916                ProductFilenameKind::Nav => None,
1917            },
1918            span: convention.span.to_string(),
1919            sample: self.sample.clone(),
1920            official_filename: self.canonical_filename()?,
1921            format: product_format(self.product_type),
1922            format_version: None,
1923            prediction_horizon_days: self.center.prediction_horizon_days(),
1924        };
1925        identity.validate()?;
1926        Ok(identity)
1927    }
1928
1929    /// Resolve one explicit distributor without changing product identity.
1930    pub fn distribution_location(
1931        &self,
1932        source: DistributionSource,
1933    ) -> Result<DistributionLocation, DataCatalogError> {
1934        let identity = self.identity()?;
1935        distribution_location_for_identity(&identity, source)
1936    }
1937}
1938
1939/// A pure station observation specification.
1940#[derive(Debug, Clone, PartialEq, Eq)]
1941pub struct StationObservationSpec {
1942    /// 9-character RINEX 3 site identifier.
1943    pub station: String,
1944    /// Observation date.
1945    pub date: ProductDate,
1946    /// Sampling token.
1947    pub sample: String,
1948}
1949
1950impl StationObservationSpec {
1951    /// Build and validate a daily station observation product.
1952    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1953        validate_station(station)?;
1954        validate_sample(sample)?;
1955        Ok(Self {
1956            station: station.to_string(),
1957            date,
1958            sample: sample.to_string(),
1959        })
1960    }
1961
1962    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
1963    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1964        station_obs_filename(&self.station, self.date, &self.sample)
1965    }
1966
1967    /// Full archive URL, including `.gz`.
1968    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1969        station_obs_url(&self.station, self.date, &self.sample)
1970    }
1971}
1972
1973/// Static catalog entries, in the same order as the binding data catalog.
1974#[must_use]
1975pub const fn catalog() -> &'static [CenterCatalogEntry] {
1976    &CATALOG
1977}
1978
1979/// Supported center codes, in catalog order.
1980#[must_use]
1981pub const fn centers() -> &'static [AnalysisCenter] {
1982    &CENTER_ORDER
1983}
1984
1985/// Supported product types.
1986#[must_use]
1987pub const fn product_types() -> &'static [ProductTypeConvention] {
1988    &PRODUCT_TYPE_CONVENTIONS
1989}
1990
1991/// Archive hosts present in the catalog.
1992#[must_use]
1993pub const fn allowed_hosts() -> &'static [&'static str] {
1994    &ALLOWED_HOSTS
1995}
1996
1997/// Catalog entry for the Skadi SRTM terrain source.
1998#[must_use]
1999pub const fn skadi_source_entry() -> TerrainSourceEntry {
2000    SKADI_SOURCE
2001}
2002
2003/// Catalog entry for the CelesTrak CSSI space-weather source.
2004#[must_use]
2005pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2006    CELESTRAK_SPACE_WEATHER_SOURCE
2007}
2008
2009/// Filename for a CelesTrak space-weather product.
2010#[must_use]
2011pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2012    match product {
2013        SpaceWeatherProduct::All => "SW-All.csv",
2014        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2015    }
2016}
2017
2018/// Build the CelesTrak archive URL for a space-weather product.
2019#[must_use]
2020pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2021    format!(
2022        "{}/{}",
2023        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2024        space_weather_filename(product)
2025    )
2026}
2027
2028/// Build the cache relative path for a space-weather product.
2029#[must_use]
2030pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2031    format!("space-weather/{}", space_weather_filename(product))
2032}
2033
2034/// Build the Skadi SRTM tile id, for example `N36W107`.
2035pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2036    validate_terrain_tile_index(lat_index, lon_index)?;
2037    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2038    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2039    Ok(format!(
2040        "{lat_hemi}{:02}{lon_hemi}{:03}",
2041        lat_index.abs(),
2042        lon_index.abs()
2043    ))
2044}
2045
2046/// Build the Skadi latitude band directory, for example `N36`.
2047pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2048    validate_terrain_lat_index(lat_index)?;
2049    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2050    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2051}
2052
2053/// Build the Skadi SRTM archive URL for a tile.
2054pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2055    let band = skadi_band(lat_index)?;
2056    let tile_id = skadi_tile_id(lat_index, lon_index)?;
2057    Ok(format!(
2058        "{}/skadi/{}/{}.hgt{}",
2059        SKADI_SOURCE.root_url,
2060        band,
2061        tile_id,
2062        SKADI_SOURCE.compression.suffix()
2063    ))
2064}
2065
2066/// Build the DTED tile filename read by the terrain module.
2067pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2068    validate_terrain_tile_index(lat_index, lon_index)?;
2069    Ok(format!(
2070        "{}_{}{}",
2071        terrain::format_lat(lat_index),
2072        terrain::format_lon(lon_index),
2073        terrain::DTED_SUFFIX
2074    ))
2075}
2076
2077/// Build the DTED ten-degree cache block directory read by the terrain module.
2078pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2079    validate_terrain_tile_index(lat_index, lon_index)?;
2080    Ok(terrain::terrain_block_dir(lat_index, lon_index))
2081}
2082
2083/// Build the DTED cache relative path read by the terrain module.
2084pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2085    Ok(format!(
2086        "{}/{}",
2087        dted_block_dir(lat_index, lon_index)?,
2088        dted_tile_filename(lat_index, lon_index)?
2089    ))
2090}
2091
2092/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
2093pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2094    let bytes = id.as_bytes();
2095    if bytes.len() != 7
2096        || !matches!(bytes[0], b'N' | b'S')
2097        || !matches!(bytes[3], b'E' | b'W')
2098        || !bytes[1..3].iter().all(u8::is_ascii_digit)
2099        || !bytes[4..7].iter().all(u8::is_ascii_digit)
2100    {
2101        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2102    }
2103
2104    let lat_abs = id[1..3]
2105        .parse::<i32>()
2106        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2107    let lon_abs = id[4..7]
2108        .parse::<i32>()
2109        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2110    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2111        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2112    }
2113
2114    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2115    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2116    validate_terrain_tile_index(lat_index, lon_index)?;
2117    Ok((lat_index, lon_index))
2118}
2119
2120/// Derive the terrain tile index covering a latitude/longitude coordinate.
2121pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2122    if !lat_deg.is_finite()
2123        || !lon_deg.is_finite()
2124        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2125        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2126    {
2127        return Err(DataCatalogError::InvalidCoordinate {
2128            lat_deg_bits: lat_deg.to_bits(),
2129            lon_deg_bits: lon_deg.to_bits(),
2130        });
2131    }
2132
2133    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2134    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2135        lat_index = MAX_TERRAIN_LAT_INDEX;
2136    }
2137    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2138        lon_index = MAX_TERRAIN_LON_INDEX;
2139    }
2140    validate_terrain_tile_index(lat_index, lon_index)?;
2141    Ok((lat_index, lon_index))
2142}
2143
2144/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
2145///
2146/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
2147/// order. HGT rows run north to south; DTED data records are longitude columns
2148/// with postings south to north, so output posting `(i, j)` reads source sample
2149/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
2150/// level (`0`) so the existing terrain reader returns `0` for those postings.
2151pub fn hgt_to_dted(
2152    lat_index: i32,
2153    lon_index: i32,
2154    hgt: &[u8],
2155) -> Result<Vec<u8>, HgtConversionError> {
2156    validate_hgt_tile_index(lat_index, lon_index)?;
2157    if hgt.len() != SRTM1_HGT_LEN {
2158        return Err(HgtConversionError::BadLength {
2159            expected: SRTM1_HGT_LEN,
2160            got: hgt.len(),
2161        });
2162    }
2163
2164    let mut out = vec![b' '; DTED_SRTM1_LEN];
2165    out[0..4].copy_from_slice(b"UHL1");
2166    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2167    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2168    out[47..51].copy_from_slice(b"3601");
2169    out[51..55].copy_from_slice(b"3601");
2170
2171    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2172        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2173        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2174        out[block_start] = terrain::DATA_SENTINEL;
2175
2176        let count = (lon_posting as u32).to_be_bytes();
2177        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2178        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2179        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2180
2181        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2182            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2183            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2184            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2185            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2186            let dted_sample_start = block_start + 8 + 2 * lat_posting;
2187            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2188        }
2189
2190        let checksum = out[block_start..checksum_start]
2191            .iter()
2192            .fold(0i32, |acc, byte| acc + i32::from(*byte));
2193        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2194    }
2195
2196    debug_assert_eq!(out.len(), 25_981_042);
2197    Ok(out)
2198}
2199
2200/// Product pairs intentionally withheld because no open mirror is known.
2201#[must_use]
2202pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2203    &NO_OPEN_MIRRORS
2204}
2205
2206/// Confirm that a center/product pair has an open catalog mirror.
2207pub fn open_mirror(
2208    center: AnalysisCenter,
2209    product_type: ProductType,
2210) -> Result<(), DataCatalogError> {
2211    open_mirror_code(center.code(), product_type.code())
2212}
2213
2214/// Confirm that a center/product code pair is not in the no-open-mirror list.
2215pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2216    if NO_OPEN_MIRRORS
2217        .iter()
2218        .any(|entry| entry.center == center && entry.product_type == product_type)
2219    {
2220        Err(DataCatalogError::NoOpenMirror {
2221            center: center.to_string(),
2222            product_type: product_type.to_string(),
2223        })
2224    } else {
2225        Ok(())
2226    }
2227}
2228
2229/// Look up a center's static catalog entry.
2230#[must_use]
2231pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2232    CATALOG.iter().find(|entry| entry.center == center)
2233}
2234
2235/// Look up the convention for one center and product type.
2236pub fn product_convention(
2237    center: AnalysisCenter,
2238    product_type: ProductType,
2239) -> Result<&'static CenterProductConvention, DataCatalogError> {
2240    open_mirror(center, product_type)?;
2241    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2242    entry
2243        .products
2244        .iter()
2245        .find(|product| product.product_type == product_type)
2246        .ok_or(DataCatalogError::UnsupportedProduct {
2247            center,
2248            product_type,
2249        })
2250}
2251
2252/// Default sampling token for a center/product pair.
2253pub fn default_sample(
2254    center: AnalysisCenter,
2255    product_type: ProductType,
2256) -> Result<&'static str, DataCatalogError> {
2257    Ok(product_convention(center, product_type)?.default_sample)
2258}
2259
2260/// GPS week number for a product date.
2261pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2262    date.gps_week()
2263}
2264
2265/// Day-of-year in `1..=366` for a product date.
2266#[must_use]
2267pub fn day_of_year(date: ProductDate) -> u16 {
2268    date.day_of_year()
2269}
2270
2271/// Build a product specification for any center/product/date combination.
2272pub fn product(
2273    center: AnalysisCenter,
2274    product_type: ProductType,
2275    date: ProductDate,
2276    sample: Option<&str>,
2277    issue: Option<&str>,
2278) -> Result<ProductSpec, DataCatalogError> {
2279    let sample = match sample {
2280        Some(sample) => sample,
2281        None => default_sample(center, product_type)?,
2282    };
2283    ProductSpec::new(center, product_type, date, sample, issue)
2284}
2285
2286/// Build the canonical IGS long-name filename for a product.
2287pub fn canonical_filename(
2288    center: AnalysisCenter,
2289    product_type: ProductType,
2290    date: ProductDate,
2291    sample: Option<&str>,
2292    issue: Option<&str>,
2293) -> Result<String, DataCatalogError> {
2294    product(center, product_type, date, sample, issue)?.canonical_filename()
2295}
2296
2297/// Build the full archive URL for a product.
2298pub fn archive_url(
2299    center: AnalysisCenter,
2300    product_type: ProductType,
2301    date: ProductDate,
2302    sample: Option<&str>,
2303    issue: Option<&str>,
2304) -> Result<String, DataCatalogError> {
2305    product(center, product_type, date, sample, issue)?.archive_url()
2306}
2307
2308/// Build the exact identity for a catalog product.
2309pub fn product_identity(
2310    center: AnalysisCenter,
2311    product_type: ProductType,
2312    date: ProductDate,
2313    sample: Option<&str>,
2314    issue: Option<&str>,
2315) -> Result<ProductIdentity, DataCatalogError> {
2316    product(center, product_type, date, sample, issue)?.identity()
2317}
2318
2319/// Resolve an explicit distributor for a catalog product.
2320pub fn distribution_location(
2321    center: AnalysisCenter,
2322    product_type: ProductType,
2323    date: ProductDate,
2324    sample: Option<&str>,
2325    issue: Option<&str>,
2326    source: DistributionSource,
2327) -> Result<DistributionLocation, DataCatalogError> {
2328    product(center, product_type, date, sample, issue)?.distribution_location(source)
2329}
2330
2331/// Resolve one explicit distributor from a complete exact product identity.
2332///
2333/// Unlike [`distribution_location`], this retains alternate catalog cadence and
2334/// duration fields already carried by `identity` instead of reconstructing a
2335/// default product specification. The function performs no network or file IO.
2336pub fn distribution_location_for_identity(
2337    identity: &ProductIdentity,
2338    source: DistributionSource,
2339) -> Result<DistributionLocation, DataCatalogError> {
2340    identity.validate()?;
2341    match source {
2342        DistributionSource::Direct => {
2343            let convention = product_convention(identity.analysis_center, identity.family)?;
2344            let entry = center_catalog(identity.analysis_center)
2345                .expect("validated analysis center has a catalog entry");
2346            let url = format!(
2347                "{}/{}/{}{}",
2348                entry.root_url,
2349                product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2350                identity.official_filename,
2351                convention.compression.suffix()
2352            );
2353            Ok(DistributionLocation {
2354                source,
2355                original_url: Some(url),
2356                archive_filename: format!(
2357                    "{}{}",
2358                    identity.official_filename,
2359                    convention.compression.suffix()
2360                ),
2361                compression: convention.compression,
2362            })
2363        }
2364        DistributionSource::NasaCddis => Ok(DistributionLocation {
2365            source,
2366            original_url: Some(cddis_archive_url(identity)?),
2367            archive_filename: format!("{}.gz", identity.official_filename),
2368            compression: ArchiveCompression::Gzip,
2369        }),
2370        DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2371            source,
2372            original_url: None,
2373            archive_filename: identity.official_filename.clone(),
2374            compression: ArchiveCompression::None,
2375        }),
2376    }
2377}
2378
2379/// Build the official NASA CDDIS HTTPS URL for an exact SP3 or IONEX identity.
2380///
2381/// CDDIS stores current SP3 products by GPS week and current IONEX products by
2382/// year/day-of-year. The decompressed official filename is unchanged.
2383pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2384    identity.validate()?;
2385    match identity.family {
2386        ProductType::Sp3 => Ok(format!(
2387            "https://cddis.nasa.gov/archive/gnss/products/{}/{}.gz",
2388            identity.date.gps_week()?,
2389            identity.official_filename
2390        )),
2391        ProductType::Ionex => Ok(format!(
2392            "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2393            identity.date.year,
2394            identity.date.day_of_year(),
2395            identity.official_filename
2396        )),
2397        product_type => Err(DataCatalogError::UnsupportedDistribution {
2398            source: DistributionSource::NasaCddis,
2399            product_type,
2400        }),
2401    }
2402}
2403
2404/// Build a clock product for a center and date.
2405pub fn mgex_clk(
2406    center: AnalysisCenter,
2407    date: ProductDate,
2408    sample: Option<&str>,
2409) -> Result<ProductSpec, DataCatalogError> {
2410    product(center, ProductType::Clk, date, sample, None)
2411}
2412
2413/// Build a merged broadcast-navigation product for a center and date.
2414pub fn mgex_nav(
2415    center: AnalysisCenter,
2416    date: ProductDate,
2417    sample: Option<&str>,
2418) -> Result<ProductSpec, DataCatalogError> {
2419    product(center, ProductType::Nav, date, sample, None)
2420}
2421
2422/// Build an IONEX product for a center and date.
2423pub fn mgex_ionex(
2424    center: AnalysisCenter,
2425    date: ProductDate,
2426    sample: Option<&str>,
2427) -> Result<ProductSpec, DataCatalogError> {
2428    product(center, ProductType::Ionex, date, sample, None)
2429}
2430
2431/// Build the CODE rapid IONEX product for a date.
2432pub fn rapid_ionex(
2433    date: ProductDate,
2434    sample: Option<&str>,
2435) -> Result<ProductSpec, DataCatalogError> {
2436    product(
2437        AnalysisCenter::CodRap,
2438        ProductType::Ionex,
2439        date,
2440        sample,
2441        None,
2442    )
2443}
2444
2445/// Day offset for predicted IONEX aliases.
2446#[must_use]
2447pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2448    match center {
2449        AnalysisCenter::CodPrd2 => 1,
2450        _ => 0,
2451    }
2452}
2453
2454/// Build a CODE predicted IONEX product for a target date.
2455pub fn predicted_ionex(
2456    center: AnalysisCenter,
2457    date: ProductDate,
2458    sample: Option<&str>,
2459) -> Result<ProductSpec, DataCatalogError> {
2460    match center {
2461        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2462            let target = date.add_days(predicted_day_offset(center))?;
2463            product(center, ProductType::Ionex, target, sample, None)
2464        }
2465        other => Err(DataCatalogError::UnsupportedProduct {
2466            center: other,
2467            product_type: ProductType::Ionex,
2468        }),
2469    }
2470}
2471
2472/// Build an SP3 product for a center and date.
2473pub fn mgex_sp3(
2474    center: AnalysisCenter,
2475    date: ProductDate,
2476    sample: Option<&str>,
2477) -> Result<ProductSpec, DataCatalogError> {
2478    product(center, ProductType::Sp3, date, sample, None)
2479}
2480
2481/// Build an ultra-rapid OPS SP3 product for a date and issue time.
2482pub fn ops_ultra_sp3(
2483    center: AnalysisCenter,
2484    date: ProductDate,
2485    sample: Option<&str>,
2486    issue: Option<&str>,
2487) -> Result<ProductSpec, DataCatalogError> {
2488    let issue = issue.unwrap_or("0000");
2489    product(center, ProductType::Sp3, date, sample, Some(issue))
2490}
2491
2492/// Generate the current primary ultra-rapid SP3 location followed by known
2493/// duration/sampling alternates and documented latest-product aliases.
2494///
2495/// Candidate order is deterministic and center-specific. Callers should try
2496/// the next location only when the prior archive URL is absent; transport and
2497/// retry policy remain outside the pure core catalog.
2498pub fn ultra_sp3_locations(
2499    center: AnalysisCenter,
2500    date: ProductDate,
2501    issue: &str,
2502) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2503    validate_issue_for_center(center, Some(issue))?;
2504    let patterns: &[UltraSp3Pattern] = match center {
2505        AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2506        AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2507        AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2508        AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2509        other => {
2510            return Err(DataCatalogError::UnsupportedProduct {
2511                center: other,
2512                product_type: ProductType::Sp3,
2513            })
2514        }
2515    };
2516    let convention = product_convention(center, ProductType::Sp3)?;
2517    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2518    let directory = dir_path(convention.layout, date)?;
2519    let date = date_block(date, Some(issue));
2520
2521    Ok(patterns
2522        .iter()
2523        .map(|pattern| {
2524            let filename = pattern.alias_filename.map_or_else(
2525                || {
2526                    format!(
2527                        "{}_{}_{}_{}_ORB.SP3",
2528                        convention.token, date, pattern.span, pattern.sample
2529                    )
2530                },
2531                ToOwned::to_owned,
2532            );
2533            UltraSp3Location {
2534                pattern: pattern.label.to_string(),
2535                span: pattern.span.to_string(),
2536                sample: pattern.sample.to_string(),
2537                url: format!(
2538                    "{}/{}/{}{}",
2539                    entry.root_url,
2540                    directory,
2541                    filename,
2542                    convention.compression.suffix()
2543                ),
2544                filename,
2545                compression: convention.compression,
2546            }
2547        })
2548        .collect())
2549}
2550
2551/// Build an ultra-rapid OPS clock product for a date and issue time.
2552pub fn ops_ultra_clk(
2553    center: AnalysisCenter,
2554    date: ProductDate,
2555    sample: Option<&str>,
2556    issue: Option<&str>,
2557) -> Result<ProductSpec, DataCatalogError> {
2558    let issue = issue.unwrap_or("0000");
2559    product(center, ProductType::Clk, date, sample, Some(issue))
2560}
2561
2562/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
2563pub fn latest_ops_ultra_sp3(
2564    center: AnalysisCenter,
2565    target: ProductDateTime,
2566    sample: Option<&str>,
2567    available_issues: Option<&[UltraIssue]>,
2568) -> Result<ProductSpec, DataCatalogError> {
2569    let selected = latest_ultra_issue(center, target, available_issues)?;
2570    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2571}
2572
2573/// Candidate ultra-rapid issues at or before a target time, newest first.
2574pub fn ultra_issue_candidates(
2575    center: AnalysisCenter,
2576    target: ProductDateTime,
2577) -> Result<Vec<UltraIssue>, DataCatalogError> {
2578    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2579    let _ = product_convention(center, ProductType::Sp3)?;
2580    if entry.issues.is_empty() {
2581        return Err(DataCatalogError::UnsupportedProduct {
2582            center,
2583            product_type: ProductType::Sp3,
2584        });
2585    }
2586
2587    let mut candidates = Vec::new();
2588    for date in [target.date, target.date.add_days(-1)?] {
2589        for issue in entry.issues.iter().rev() {
2590            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2591                candidates.push(UltraIssue::new(date, issue)?);
2592            }
2593        }
2594    }
2595    Ok(candidates)
2596}
2597
2598/// Latest ultra-rapid issue at or before a target time.
2599pub fn latest_ultra_issue(
2600    center: AnalysisCenter,
2601    target: ProductDateTime,
2602    available_issues: Option<&[UltraIssue]>,
2603) -> Result<UltraIssue, DataCatalogError> {
2604    let candidates = ultra_issue_candidates(center, target)?;
2605    if candidates.is_empty() {
2606        return Err(DataCatalogError::NoUltraIssue);
2607    }
2608    if let Some(available) = available_issues {
2609        candidates
2610            .into_iter()
2611            .find(|candidate| {
2612                available
2613                    .iter()
2614                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2615            })
2616            .ok_or(DataCatalogError::NoAvailableUltraIssue)
2617    } else {
2618        Ok(candidates[0].clone())
2619    }
2620}
2621
2622/// Candidate IONEX dates at or before a target date, newest first.
2623pub fn gim_date_candidates(
2624    center: AnalysisCenter,
2625    target: ProductDate,
2626    lookback: u32,
2627) -> Result<Vec<ProductDate>, DataCatalogError> {
2628    let _ = product_convention(center, ProductType::Ionex)?;
2629    let base = target.add_days(predicted_day_offset(center))?;
2630    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2631    for back in 0..=lookback {
2632        out.push(base.add_days(-i64::from(back))?);
2633    }
2634    Ok(out)
2635}
2636
2637/// Build a daily station observation product.
2638pub fn station_obs(
2639    station: &str,
2640    date: ProductDate,
2641    sample: Option<&str>,
2642) -> Result<StationObservationSpec, DataCatalogError> {
2643    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2644}
2645
2646/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
2647pub fn station_obs_filename(
2648    station: &str,
2649    date: ProductDate,
2650    sample: &str,
2651) -> Result<String, DataCatalogError> {
2652    validate_station(station)?;
2653    validate_sample(sample)?;
2654    Ok(format!(
2655        "{}_R_{}_01D_{}_MO.crx",
2656        station,
2657        date_block(date, None),
2658        sample
2659    ))
2660}
2661
2662/// Build the full BKG IGS archive URL for a daily station observation.
2663pub fn station_obs_url(
2664    station: &str,
2665    date: ProductDate,
2666    sample: &str,
2667) -> Result<String, DataCatalogError> {
2668    let filename = station_obs_filename(station, date, sample)?;
2669    Ok(format!(
2670        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
2671        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
2672        filename
2673    ))
2674}
2675
2676/// The transfer protocol for the daily station observation archive.
2677#[must_use]
2678pub const fn station_obs_protocol() -> ArchiveProtocol {
2679    ArchiveProtocol::Https
2680}
2681
2682fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
2683    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
2684        Ok(())
2685    } else {
2686        Err(DataCatalogError::InvalidTileIndex {
2687            lat_index,
2688            lon_index: 0,
2689        })
2690    }
2691}
2692
2693fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
2694    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2695        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2696    {
2697        Ok(())
2698    } else {
2699        Err(DataCatalogError::InvalidTileIndex {
2700            lat_index,
2701            lon_index,
2702        })
2703    }
2704}
2705
2706fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
2707    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2708        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2709    {
2710        Ok(())
2711    } else {
2712        Err(HgtConversionError::InvalidTileIndex {
2713            lat_index,
2714            lon_index,
2715        })
2716    }
2717}
2718
2719fn dted_coord_field(index: i32, is_longitude: bool) -> String {
2720    let hemi = match (is_longitude, index >= 0) {
2721        (true, true) => 'E',
2722        (true, false) => 'W',
2723        (false, true) => 'N',
2724        (false, false) => 'S',
2725    };
2726    format!("{:03}0000{hemi}", index.abs())
2727}
2728
2729fn encode_dted_signed_magnitude(sample: i16) -> u16 {
2730    if sample == i16::MIN {
2731        0
2732    } else if sample >= 0 {
2733        sample as u16
2734    } else {
2735        0x8000 | (-i32::from(sample) as u16)
2736    }
2737}
2738
2739fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
2740    PRODUCT_TYPE_CONVENTIONS
2741        .iter()
2742        .find(|descriptor| descriptor.product_type == product_type)
2743        .expect("product descriptor exists for enum variant")
2744}
2745
2746const fn product_format(product_type: ProductType) -> ProductFormat {
2747    match product_type {
2748        ProductType::Sp3 => ProductFormat::Sp3,
2749        ProductType::Ionex => ProductFormat::Ionex,
2750        ProductType::Clk => ProductFormat::RinexClock,
2751        ProductType::Nav => ProductFormat::RinexNavigation,
2752    }
2753}
2754
2755fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
2756    if filename.is_empty()
2757        || filename == "."
2758        || filename == ".."
2759        || filename.contains('/')
2760        || filename.contains('\\')
2761        || filename.contains('\0')
2762        || filename.contains("..")
2763    {
2764        Err(DataCatalogError::InvalidOfficialFilename(
2765            filename.to_string(),
2766        ))
2767    } else {
2768        Ok(())
2769    }
2770}
2771
2772fn validate_product(
2773    center: AnalysisCenter,
2774    product_type: ProductType,
2775    sample: &str,
2776    issue: Option<&str>,
2777) -> Result<&'static CenterProductConvention, DataCatalogError> {
2778    let convention = product_convention(center, product_type)?;
2779    validate_sample(sample)?;
2780    validate_issue_for_center(center, issue)?;
2781    Ok(convention)
2782}
2783
2784fn validate_issue_for_center(
2785    center: AnalysisCenter,
2786    issue: Option<&str>,
2787) -> Result<(), DataCatalogError> {
2788    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2789    match (entry.issues.is_empty(), issue) {
2790        (true, None) => Ok(()),
2791        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
2792        (false, None) => Err(DataCatalogError::MissingIssue { center }),
2793        (false, Some(issue)) => {
2794            validate_issue(issue)?;
2795            if entry.issues.contains(&issue) {
2796                Ok(())
2797            } else {
2798                Err(DataCatalogError::UnsupportedIssue {
2799                    center,
2800                    issue: issue.to_string(),
2801                })
2802            }
2803        }
2804    }
2805}
2806
2807fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
2808    let bytes = sample.as_bytes();
2809    let valid = bytes.len() == 3
2810        && bytes[0].is_ascii_digit()
2811        && bytes[1].is_ascii_digit()
2812        && bytes[2].is_ascii_uppercase();
2813    if valid {
2814        Ok(())
2815    } else {
2816        Err(DataCatalogError::InvalidSample(sample.to_string()))
2817    }
2818}
2819
2820fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2821    let bytes = issue.as_bytes();
2822    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2823    if !valid_digits {
2824        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2825    }
2826    let hour = issue[0..2]
2827        .parse::<u8>()
2828        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2829    let minute = issue[2..4]
2830        .parse::<u8>()
2831        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2832    if hour <= 23 && minute <= 59 {
2833        Ok(())
2834    } else {
2835        Err(DataCatalogError::InvalidIssue(issue.to_string()))
2836    }
2837}
2838
2839fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2840    let bytes = station.as_bytes();
2841    let valid = bytes.len() == 9
2842        && bytes
2843            .iter()
2844            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2845    if valid {
2846        Ok(())
2847    } else {
2848        Err(DataCatalogError::InvalidStation(station.to_string()))
2849    }
2850}
2851
2852fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2853    validate_issue(issue)?;
2854    let hour = issue[0..2]
2855        .parse::<u16>()
2856        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2857    let minute = issue[2..4]
2858        .parse::<u16>()
2859        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2860    Ok(hour * 60 + minute)
2861}
2862
2863fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2864    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2865}
2866
2867fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2868    format!(
2869        "{}{:03}{}",
2870        date.year,
2871        date.day_of_year(),
2872        issue.unwrap_or("0000")
2873    )
2874}
2875
2876fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2877    Ok(match layout {
2878        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2879        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2880        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2881        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2882        ArchiveLayout::BkgBrdcYearDoy => {
2883            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2884        }
2885        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2886        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2887        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2888        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2889    })
2890}
2891
2892fn product_dir_path(
2893    center: AnalysisCenter,
2894    layout: ArchiveLayout,
2895    date: ProductDate,
2896) -> Result<String, DataCatalogError> {
2897    match center {
2898        AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
2899        AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
2900        _ => dir_path(layout, date),
2901    }
2902}
2903
2904fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2905    let (year, month, day) = civil_from_julian_day_number(jdn);
2906    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2907    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2908    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2909    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2910}