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