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
812/// Final primarily 15-minute GFZ ultra-rapid date.
813///
814/// Its `0000` issue is the documented transition overlap and publishes both
815/// 15-minute and 5-minute objects; later issues that day publish only 15-minute
816/// objects.
817const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
818    year: 2021,
819    month: 5,
820    day: 15,
821};
822
823/// First and last dates of GFZ's ultra-rapid content-start transition.
824///
825/// Before this window, the first SP3 epoch is one day before the epoch encoded
826/// by the filename. After the window, the two epochs are equal. GFZ's official
827/// objects show a non-monotonic, issue-by-issue transition inside the window,
828/// so those sixteen products are cataloged explicitly below.
829const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
830    year: 2022,
831    month: 9,
832    day: 7,
833};
834const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
835    year: 2022,
836    month: 9,
837    day: 8,
838};
839
840/// Relationship between the epoch in an SP3 filename and its first content
841/// epoch, established from the cataloged public product line.
842///
843/// This is archive metadata, not a value inferred from product bytes. Exact
844/// validation uses it to select one required start instant without relaxing
845/// equality against that instant.
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
847#[non_exhaustive]
848pub enum Sp3ContentStartConvention {
849    /// The first content epoch equals the epoch encoded by the filename.
850    FilenameEpoch,
851    /// The first content epoch is exactly 24 hours before the filename epoch.
852    FilenameEpochMinusOneDay,
853}
854
855impl Sp3ContentStartConvention {
856    /// Stable catalog code for serialization by language interfaces.
857    #[must_use]
858    pub const fn code(self) -> &'static str {
859        match self {
860            Self::FilenameEpoch => "filename_epoch",
861            Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
862        }
863    }
864
865    /// Whole seconds added to the filename epoch to obtain the first content
866    /// epoch.
867    #[must_use]
868    pub const fn content_start_offset_s(self) -> i64 {
869        match self {
870            Self::FilenameEpoch => 0,
871            Self::FilenameEpochMinusOneDay => -86_400,
872        }
873    }
874}
875
876/// Official GFZ objects for every issue in the two-day transition window.
877///
878/// The source URLs and access date are recorded in
879/// `docs/public-gnss-distribution-sources.md`. Keeping this as an exhaustive
880/// table prevents an appealing date/issue threshold from silently
881/// misclassifying the two reversions visible in the archive.
882const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
883    (
884        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
885        "0000",
886        Sp3ContentStartConvention::FilenameEpoch,
887    ),
888    (
889        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
890        "0300",
891        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
892    ),
893    (
894        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
895        "0600",
896        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
897    ),
898    (
899        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
900        "0900",
901        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
902    ),
903    (
904        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
905        "1200",
906        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
907    ),
908    (
909        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
910        "1500",
911        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
912    ),
913    (
914        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
915        "1800",
916        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
917    ),
918    (
919        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
920        "2100",
921        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
922    ),
923    (
924        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
925        "0000",
926        Sp3ContentStartConvention::FilenameEpoch,
927    ),
928    (
929        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
930        "0300",
931        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
932    ),
933    (
934        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
935        "0600",
936        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
937    ),
938    (
939        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
940        "0900",
941        Sp3ContentStartConvention::FilenameEpoch,
942    ),
943    (
944        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
945        "1200",
946        Sp3ContentStartConvention::FilenameEpoch,
947    ),
948    (
949        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
950        "1500",
951        Sp3ContentStartConvention::FilenameEpoch,
952    ),
953    (
954        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
955        "1800",
956        Sp3ContentStartConvention::FilenameEpoch,
957    ),
958    (
959        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
960        "2100",
961        Sp3ContentStartConvention::FilenameEpoch,
962    ),
963];
964
965const CENTER_ORDER: [AnalysisCenter; 11] = [
966    AnalysisCenter::CodRap,
967    AnalysisCenter::CodPrd1,
968    AnalysisCenter::CodPrd2,
969    AnalysisCenter::Igs,
970    AnalysisCenter::Esa,
971    AnalysisCenter::Cod,
972    AnalysisCenter::Gfz,
973    AnalysisCenter::IgsUlt,
974    AnalysisCenter::CodUlt,
975    AnalysisCenter::EsaUlt,
976    AnalysisCenter::GfzUlt,
977];
978
979const CATALOG: [CenterCatalogEntry; 11] = [
980    CenterCatalogEntry {
981        center: AnalysisCenter::CodRap,
982        code: "cod_rap",
983        protocol: ArchiveProtocol::Https,
984        host: "www.aiub.unibe.ch",
985        root_url: "https://www.aiub.unibe.ch/download",
986        products: &COD_RAP_PRODUCTS,
987        issues: &[],
988    },
989    CenterCatalogEntry {
990        center: AnalysisCenter::CodPrd1,
991        code: "cod_prd1",
992        protocol: ArchiveProtocol::Https,
993        host: "www.aiub.unibe.ch",
994        root_url: "https://www.aiub.unibe.ch/download",
995        products: &COD_PRD_PRODUCTS,
996        issues: &[],
997    },
998    CenterCatalogEntry {
999        center: AnalysisCenter::CodPrd2,
1000        code: "cod_prd2",
1001        protocol: ArchiveProtocol::Https,
1002        host: "www.aiub.unibe.ch",
1003        root_url: "https://www.aiub.unibe.ch/download",
1004        products: &COD_PRD_PRODUCTS,
1005        issues: &[],
1006    },
1007    CenterCatalogEntry {
1008        center: AnalysisCenter::Igs,
1009        code: "igs",
1010        protocol: ArchiveProtocol::Https,
1011        host: "igs.bkg.bund.de",
1012        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1013        products: &IGS_PRODUCTS,
1014        issues: &[],
1015    },
1016    CenterCatalogEntry {
1017        center: AnalysisCenter::Esa,
1018        code: "esa",
1019        protocol: ArchiveProtocol::Https,
1020        host: "navigation-office.esa.int",
1021        root_url: "https://navigation-office.esa.int/products/gnss-products",
1022        products: &ESA_PRODUCTS,
1023        issues: &[],
1024    },
1025    CenterCatalogEntry {
1026        center: AnalysisCenter::Cod,
1027        code: "cod",
1028        protocol: ArchiveProtocol::Https,
1029        host: "www.aiub.unibe.ch",
1030        root_url: "https://www.aiub.unibe.ch/download",
1031        products: &COD_PRODUCTS,
1032        issues: &[],
1033    },
1034    CenterCatalogEntry {
1035        center: AnalysisCenter::Gfz,
1036        code: "gfz",
1037        protocol: ArchiveProtocol::Https,
1038        host: "isdc-data.gfz.de",
1039        root_url: "https://isdc-data.gfz.de/gnss/products",
1040        products: &GFZ_PRODUCTS,
1041        issues: &[],
1042    },
1043    CenterCatalogEntry {
1044        center: AnalysisCenter::IgsUlt,
1045        code: "igs_ult",
1046        protocol: ArchiveProtocol::Https,
1047        host: "igs.bkg.bund.de",
1048        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1049        products: &IGS_ULT_PRODUCTS,
1050        issues: &OPSULT_ISSUES,
1051    },
1052    CenterCatalogEntry {
1053        center: AnalysisCenter::CodUlt,
1054        code: "cod_ult",
1055        protocol: ArchiveProtocol::Https,
1056        host: "www.aiub.unibe.ch",
1057        // AIUB retired the old ftp.aiub.unibe.ch HTTP tree. Its public file
1058        // browser links products through this stable HTTPS download surface,
1059        // which redirects to the current object store.
1060        root_url: "https://www.aiub.unibe.ch/download",
1061        products: &COD_ULT_PRODUCTS,
1062        issues: &COD_ULT_ISSUES,
1063    },
1064    CenterCatalogEntry {
1065        center: AnalysisCenter::EsaUlt,
1066        code: "esa_ult",
1067        protocol: ArchiveProtocol::Https,
1068        host: "navigation-office.esa.int",
1069        root_url: "https://navigation-office.esa.int/products/gnss-products",
1070        products: &ESA_ULT_PRODUCTS,
1071        issues: &OPSULT_ISSUES,
1072    },
1073    CenterCatalogEntry {
1074        center: AnalysisCenter::GfzUlt,
1075        code: "gfz_ult",
1076        protocol: ArchiveProtocol::Https,
1077        host: "isdc-data.gfz.de",
1078        root_url: "https://isdc-data.gfz.de/gnss/products",
1079        products: &GFZ_ULT_PRODUCTS,
1080        issues: &GFZ_ULT_ISSUES,
1081    },
1082];
1083
1084const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1085    protocol: ArchiveProtocol::Https,
1086    host: "s3.amazonaws.com",
1087    compression: ArchiveCompression::Gzip,
1088    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1089};
1090
1091const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1092    protocol: ArchiveProtocol::Https,
1093    host: "celestrak.org",
1094    compression: ArchiveCompression::None,
1095    root_url: "https://celestrak.org/SpaceData",
1096};
1097
1098const ALLOWED_HOSTS: [&str; 10] = [
1099    "www.aiub.unibe.ch",
1100    "download.aiub.unibe.ch",
1101    "zhw-b.s3.cloud.switch.ch",
1102    "navigation-office.esa.int",
1103    "isdc-data.gfz.de",
1104    "igs.bkg.bund.de",
1105    "s3.amazonaws.com",
1106    "celestrak.org",
1107    "cddis.nasa.gov",
1108    "urs.earthdata.nasa.gov",
1109];
1110
1111const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1112    NoOpenMirrorProduct {
1113        center: "grg",
1114        product_type: "sp3",
1115    },
1116    NoOpenMirrorProduct {
1117        center: "grg",
1118        product_type: "clk",
1119    },
1120    NoOpenMirrorProduct {
1121        center: "wum",
1122        product_type: "sp3",
1123    },
1124    NoOpenMirrorProduct {
1125        center: "wum",
1126        product_type: "clk",
1127    },
1128    NoOpenMirrorProduct {
1129        center: "grg_ult",
1130        product_type: "sp3",
1131    },
1132    NoOpenMirrorProduct {
1133        center: "grg_ult",
1134        product_type: "clk",
1135    },
1136    NoOpenMirrorProduct {
1137        center: "igs",
1138        product_type: "ionex",
1139    },
1140];
1141
1142/// Error returned by the pure data-product catalog.
1143#[derive(Debug, Clone, PartialEq, Eq)]
1144pub enum DataCatalogError {
1145    /// Unknown analysis-center code.
1146    UnknownCenter(String),
1147    /// Unknown product type code.
1148    UnknownProductType(String),
1149    /// The center does not serve the requested product type.
1150    UnsupportedProduct {
1151        /// Analysis center.
1152        center: AnalysisCenter,
1153        /// Product type.
1154        product_type: ProductType,
1155    },
1156    /// A distributor does not carry the requested product family.
1157    UnsupportedDistribution {
1158        /// Explicit distributor.
1159        source: DistributionSource,
1160        /// Requested product family.
1161        product_type: ProductType,
1162    },
1163    /// The catalog does not claim this product family's historical naming era.
1164    UnsupportedProductEra {
1165        /// Analysis center.
1166        center: AnalysisCenter,
1167        /// Product type.
1168        product_type: ProductType,
1169        /// Requested product date.
1170        date: ProductDate,
1171    },
1172    /// A distributor has no verified uniform layout for this product era.
1173    UnsupportedDistributionEra {
1174        /// Explicit distributor.
1175        source: DistributionSource,
1176        /// Analysis center.
1177        center: AnalysisCenter,
1178        /// Product type.
1179        product_type: ProductType,
1180        /// Requested product date.
1181        date: ProductDate,
1182    },
1183    /// An exact request did not include any acceptable distributor.
1184    NoDistributionSources,
1185    /// A caller-constructed identity contained an unsafe official filename.
1186    InvalidOfficialFilename(String),
1187    /// A caller-constructed identity disagrees with its official filename.
1188    InconsistentProductIdentity {
1189        /// Identity field that did not agree with the filename or catalog convention.
1190        field: &'static str,
1191    },
1192    /// The product has no verified anonymous HTTP(S) mirror.
1193    NoOpenMirror {
1194        /// Analysis-center code.
1195        center: String,
1196        /// Product type code.
1197        product_type: String,
1198    },
1199    /// Bad civil date.
1200    InvalidDate {
1201        /// Year.
1202        year: i32,
1203        /// Month.
1204        month: u8,
1205        /// Day.
1206        day: u8,
1207    },
1208    /// Date cannot be represented by this API.
1209    DateOutOfRange,
1210    /// Date precedes the GPS week epoch.
1211    DateBeforeGpsEpoch(ProductDate),
1212    /// GPS day-of-week must be `0..=6`.
1213    InvalidGpsDayOfWeek(u8),
1214    /// Sampling token is not a supported IGS period token.
1215    InvalidSample(String),
1216    /// A syntactically valid cadence is not published for this catalog line.
1217    UnsupportedSample {
1218        /// Analysis center.
1219        center: AnalysisCenter,
1220        /// Product type.
1221        product_type: ProductType,
1222        /// Requested sample token.
1223        sample: String,
1224    },
1225    /// Coverage-span token is not a supported IGS period token.
1226    InvalidSpan(String),
1227    /// Issue time is malformed.
1228    InvalidIssue(String),
1229    /// The center requires an issue time.
1230    MissingIssue {
1231        /// Analysis center.
1232        center: AnalysisCenter,
1233    },
1234    /// The center does not use issue times.
1235    UnexpectedIssue {
1236        /// Analysis center.
1237        center: AnalysisCenter,
1238    },
1239    /// Issue time is valid text but not published by this center.
1240    UnsupportedIssue {
1241        /// Analysis center.
1242        center: AnalysisCenter,
1243        /// Issue time.
1244        issue: String,
1245    },
1246    /// The target datetime was invalid.
1247    InvalidDateTime {
1248        /// Hour.
1249        hour: u8,
1250        /// Minute.
1251        minute: u8,
1252        /// Second.
1253        second: u8,
1254    },
1255    /// No ultra-rapid issue exists at or before the requested target.
1256    NoUltraIssue,
1257    /// No available ultra-rapid issue exists at or before the requested target.
1258    NoAvailableUltraIssue,
1259    /// Station identifier is not a 9-character upper-case alphanumeric token.
1260    InvalidStation(String),
1261    /// Terrain lookup coordinate is non-finite or outside the reader range.
1262    InvalidCoordinate {
1263        /// Latitude as `f64::to_bits()`.
1264        lat_deg_bits: u64,
1265        /// Longitude as `f64::to_bits()`.
1266        lon_deg_bits: u64,
1267    },
1268    /// Terrain tile index is outside the valid one-degree cell range.
1269    InvalidTileIndex {
1270        /// Latitude index.
1271        lat_index: i32,
1272        /// Longitude index.
1273        lon_index: i32,
1274    },
1275    /// Skadi tile identifier is malformed.
1276    InvalidTileId(String),
1277}
1278
1279impl fmt::Display for DataCatalogError {
1280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1281        match self {
1282            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1283            Self::UnknownProductType(product_type) => {
1284                write!(f, "unknown product type {product_type:?}")
1285            }
1286            Self::UnsupportedProduct {
1287                center,
1288                product_type,
1289            } => write!(f, "{center} does not serve {product_type}"),
1290            Self::UnsupportedDistribution {
1291                source,
1292                product_type,
1293            } => write!(
1294                f,
1295                "distributor {} does not serve {product_type}",
1296                source.code()
1297            ),
1298            Self::UnsupportedProductEra {
1299                center,
1300                product_type,
1301                date,
1302            } => write!(
1303                f,
1304                "{center}/{product_type} has no cataloged naming convention for {date}"
1305            ),
1306            Self::UnsupportedDistributionEra {
1307                source,
1308                center,
1309                product_type,
1310                date,
1311            } => write!(
1312                f,
1313                "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1314                source.code()
1315            ),
1316            Self::NoDistributionSources => {
1317                write!(f, "exact product request has no distributors")
1318            }
1319            Self::InvalidOfficialFilename(filename) => {
1320                write!(f, "invalid official product filename {filename:?}")
1321            }
1322            Self::InconsistentProductIdentity { field } => {
1323                write!(
1324                    f,
1325                    "product identity field {field:?} disagrees with its official filename"
1326                )
1327            }
1328            Self::NoOpenMirror {
1329                center,
1330                product_type,
1331            } => write!(f, "{center}/{product_type} has no open mirror"),
1332            Self::InvalidDate { year, month, day } => {
1333                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1334            }
1335            Self::DateOutOfRange => write!(f, "product date is out of range"),
1336            Self::DateBeforeGpsEpoch(date) => {
1337                write!(f, "product date {date} is before the GPS week epoch")
1338            }
1339            Self::InvalidGpsDayOfWeek(day) => {
1340                write!(f, "invalid GPS day-of-week {day}")
1341            }
1342            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1343            Self::UnsupportedSample {
1344                center,
1345                product_type,
1346                sample,
1347            } => write!(
1348                f,
1349                "{center}/{product_type} does not publish sample interval {sample:?}"
1350            ),
1351            Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1352            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1353            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1354            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1355            Self::UnsupportedIssue { center, issue } => {
1356                write!(f, "{center} does not publish issue {issue:?}")
1357            }
1358            Self::InvalidDateTime {
1359                hour,
1360                minute,
1361                second,
1362            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1363            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1364            Self::NoAvailableUltraIssue => {
1365                write!(f, "no available ultra-rapid issue at or before target")
1366            }
1367            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1368            Self::InvalidCoordinate {
1369                lat_deg_bits,
1370                lon_deg_bits,
1371            } => write!(
1372                f,
1373                "invalid terrain coordinate lat={} lon={}",
1374                f64::from_bits(*lat_deg_bits),
1375                f64::from_bits(*lon_deg_bits)
1376            ),
1377            Self::InvalidTileIndex {
1378                lat_index,
1379                lon_index,
1380            } => write!(
1381                f,
1382                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1383            ),
1384            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1385        }
1386    }
1387}
1388
1389impl std::error::Error for DataCatalogError {}
1390
1391/// Error returned by SRTM HGT to DTED conversion.
1392#[derive(Debug, Clone, PartialEq, Eq)]
1393pub enum HgtConversionError {
1394    /// The decompressed HGT payload is not the SRTM1 byte length.
1395    BadLength {
1396        /// Expected byte length.
1397        expected: usize,
1398        /// Actual byte length.
1399        got: usize,
1400    },
1401    /// Terrain tile index is outside the valid one-degree cell range.
1402    InvalidTileIndex {
1403        /// Latitude index.
1404        lat_index: i32,
1405        /// Longitude index.
1406        lon_index: i32,
1407    },
1408}
1409
1410impl fmt::Display for HgtConversionError {
1411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1412        match self {
1413            Self::BadLength { expected, got } => {
1414                write!(
1415                    f,
1416                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
1417                )
1418            }
1419            Self::InvalidTileIndex {
1420                lat_index,
1421                lon_index,
1422            } => write!(
1423                f,
1424                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1425            ),
1426        }
1427    }
1428}
1429
1430impl std::error::Error for HgtConversionError {}
1431
1432const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1433const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1434const MIN_TERRAIN_LON_INDEX: i32 = -180;
1435const MAX_TERRAIN_LON_INDEX: i32 = 179;
1436const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1437const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1438const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1439const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1440const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1441const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1442const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1443const DTED_SRTM1_LEN: usize =
1444    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1445
1446/// Civil UTC date used by product archive names.
1447#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1448pub struct ProductDate {
1449    /// Year.
1450    pub year: i32,
1451    /// Month in `1..=12`.
1452    pub month: u8,
1453    /// Day of month.
1454    pub day: u8,
1455}
1456
1457impl ProductDate {
1458    /// Build and validate a civil date.
1459    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1460        let days = days_in_month(i64::from(year), i64::from(month));
1461        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1462            return Err(DataCatalogError::InvalidDate { year, month, day });
1463        }
1464        Ok(Self { year, month, day })
1465    }
1466
1467    /// Build a date from GPS week and day-of-week (`0` = Sunday).
1468    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1469        if day_of_week > 6 {
1470            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1471        }
1472        let epoch_jdn =
1473            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1474        let offset_days = i64::from(week)
1475            .checked_mul(7)
1476            .and_then(|days| days.checked_add(i64::from(day_of_week)))
1477            .ok_or(DataCatalogError::DateOutOfRange)?;
1478        product_date_from_jdn(
1479            epoch_jdn
1480                .checked_add(offset_days)
1481                .ok_or(DataCatalogError::DateOutOfRange)?,
1482        )
1483    }
1484
1485    /// GPS week for this date.
1486    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1487        week_from_calendar(
1488            TimeScale::Gpst,
1489            i64::from(self.year),
1490            i64::from(self.month),
1491            i64::from(self.day),
1492        )
1493        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1494    }
1495
1496    /// GPS day of week (`0` = Sunday, `6` = Saturday) for this date.
1497    pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1498        let epoch_jdn =
1499            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1500        let days = self
1501            .julian_day_number()
1502            .checked_sub(epoch_jdn)
1503            .ok_or(DataCatalogError::DateOutOfRange)?;
1504        if days < 0 {
1505            return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1506        }
1507        u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1508    }
1509
1510    /// Day-of-year in `1..=366`.
1511    #[must_use]
1512    pub fn day_of_year(self) -> u16 {
1513        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1514    }
1515
1516    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1517        product_date_from_jdn(
1518            self.julian_day_number()
1519                .checked_add(days)
1520                .ok_or(DataCatalogError::DateOutOfRange)?,
1521        )
1522    }
1523
1524    fn julian_day_number(self) -> i64 {
1525        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1526    }
1527}
1528
1529impl fmt::Display for ProductDate {
1530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1531        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1532    }
1533}
1534
1535/// Civil UTC date and time used for ultra-rapid issue selection.
1536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1537pub struct ProductDateTime {
1538    /// Date.
1539    pub date: ProductDate,
1540    /// Hour in `0..=23`.
1541    pub hour: u8,
1542    /// Minute in `0..=59`.
1543    pub minute: u8,
1544    /// Second in `0..=59`.
1545    pub second: u8,
1546}
1547
1548impl ProductDateTime {
1549    /// Build and validate a civil date and time.
1550    pub fn new(
1551        date: ProductDate,
1552        hour: u8,
1553        minute: u8,
1554        second: u8,
1555    ) -> Result<Self, DataCatalogError> {
1556        if hour > 23 || minute > 59 || second > 59 {
1557            return Err(DataCatalogError::InvalidDateTime {
1558                hour,
1559                minute,
1560                second,
1561            });
1562        }
1563        Ok(Self {
1564            date,
1565            hour,
1566            minute,
1567            second,
1568        })
1569    }
1570
1571    fn ordering_minutes(self) -> i64 {
1572        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1573    }
1574}
1575
1576/// Ultra-rapid issue date and `HHMM` issue time.
1577#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1578pub struct UltraIssue {
1579    /// Product date.
1580    pub date: ProductDate,
1581    /// Issue time.
1582    pub issue: String,
1583}
1584
1585impl UltraIssue {
1586    /// Build and validate an ultra-rapid issue.
1587    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1588        validate_issue(issue)?;
1589        Ok(Self {
1590            date,
1591            issue: issue.to_string(),
1592        })
1593    }
1594}
1595
1596/// One generated ultra-rapid SP3 archive candidate.
1597#[derive(Debug, Clone, PartialEq, Eq)]
1598pub struct UltraSp3Location {
1599    /// Stable catalog label identifying the primary or overlapping dated rule.
1600    pub pattern: String,
1601    /// Product span token used by the candidate.
1602    pub span: String,
1603    /// Sampling token used by the candidate.
1604    pub sample: String,
1605    /// Archive filename without a transport compression suffix.
1606    pub filename: String,
1607    /// Full archive URL, including its compression suffix when applicable.
1608    pub url: String,
1609    /// Archive compression for this candidate.
1610    pub compression: ArchiveCompression,
1611}
1612
1613/// Exact identity of one public GNSS product, independent of distributor.
1614///
1615/// The official filename is part of the identity. Transport compression and
1616/// URL belong to [`DistributionLocation`] because two distributors may package
1617/// the same decompressed product differently.
1618#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1619pub struct ProductIdentity {
1620    /// Product family.
1621    pub family: ProductType,
1622    /// Catalog analysis-center product line.
1623    pub analysis_center: AnalysisCenter,
1624    /// Producing or combining organization.
1625    pub publisher: ProductPublisher,
1626    /// Solution class or tier.
1627    pub solution: SolutionClass,
1628    /// Campaign or project.
1629    pub campaign: ProductCampaign,
1630    /// Product-line version encoded by the long filename.
1631    pub version: u8,
1632    /// Product epoch encoded by the official filename.
1633    ///
1634    /// This is also the content start for most SP3 products. Cataloged
1635    /// historical products whose first epoch differs retain the filename epoch
1636    /// here; exact validation derives their required content start separately.
1637    pub date: ProductDate,
1638    /// Optional `HHMM` issue/epoch time encoded by the official filename.
1639    pub issue: Option<String>,
1640    /// Intended coverage period token, for example `01D`.
1641    pub span: String,
1642    /// Sampling interval token, for example `05M`.
1643    pub sample: String,
1644    /// Official filename without transport compression suffix.
1645    pub official_filename: String,
1646    /// Public serialization format.
1647    pub format: ProductFormat,
1648    /// Parsed serialization revision when the request constrains one.
1649    ///
1650    /// Catalog identities leave this unset because the revision is carried by
1651    /// product content rather than the official filename. A resolved identity
1652    /// may set it after parsing the product.
1653    pub format_version: Option<String>,
1654    /// Prediction horizon when the product line encodes one.
1655    pub prediction_horizon_days: Option<u8>,
1656}
1657
1658impl ProductIdentity {
1659    /// Validate that every identity field agrees with the official filename.
1660    ///
1661    /// This is required for caller-constructed values before using them in a
1662    /// request, URL, or cache path. Catalog-produced identities are validated
1663    /// before they are returned.
1664    pub fn validate(&self) -> Result<(), DataCatalogError> {
1665        validate_official_filename(&self.official_filename)?;
1666        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1667        validate_sample(&self.sample)?;
1668        validate_span(&self.span)?;
1669        if let Some(issue) = self.issue.as_deref() {
1670            validate_issue(issue)?;
1671        }
1672
1673        // Establish catalog support before deriving any URL. A syntactically
1674        // plausible caller-built identity is not evidence that the selected
1675        // center publishes that product family.
1676        let convention = product_convention(self.analysis_center, self.family)?;
1677        validate_product_date(self.analysis_center, self.family, self.date)?;
1678        if self.span != convention.span {
1679            return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1680        }
1681        validate_catalog_sample(
1682            self.analysis_center,
1683            self.family,
1684            self.date,
1685            &self.sample,
1686            self.issue.as_deref(),
1687        )?;
1688
1689        if self.format != product_format(self.family) {
1690            return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1691        }
1692
1693        if self
1694            .format_version
1695            .as_deref()
1696            .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1697        {
1698            return Err(DataCatalogError::InconsistentProductIdentity {
1699                field: "format_version",
1700            });
1701        }
1702
1703        let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1704            (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1705            (_, SolutionClass::Predicted, _) => false,
1706            (_, _, None) => true,
1707            (_, _, Some(_)) => false,
1708        };
1709        if !horizon_valid {
1710            return Err(DataCatalogError::InconsistentProductIdentity {
1711                field: "prediction_horizon_days",
1712            });
1713        }
1714        let descriptor = product_type_convention(self.family);
1715        let legacy_igs_final =
1716            uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1717        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1718            let entry = center_catalog(self.analysis_center)
1719                .expect("validated analysis center has a catalog entry");
1720            let issue_valid = if entry.issues.is_empty() {
1721                self.issue.as_deref() == Some("0000")
1722            } else {
1723                self.issue
1724                    .as_deref()
1725                    .is_some_and(|issue| entry.issues.contains(&issue))
1726            };
1727            if !issue_valid {
1728                return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1729            }
1730        }
1731        let expected = if legacy_igs_final {
1732            let fields_valid = self.publisher == ProductPublisher::Igs
1733                && self.solution == SolutionClass::Final
1734                && self.campaign == ProductCampaign::Operational
1735                && self.version == 0
1736                && self.issue.as_deref() == Some("0000")
1737                && self.span == convention.span
1738                && self.sample == convention.default_sample;
1739            if !fields_valid {
1740                return Err(DataCatalogError::InconsistentProductIdentity {
1741                    field: "legacy_igs_final",
1742                });
1743            }
1744            format!(
1745                "igs{:04}{}.sp3",
1746                self.date.gps_week()?,
1747                self.date.gps_day_of_week()?
1748            )
1749        } else {
1750            match descriptor.kind {
1751                ProductFilenameKind::Sampled => {
1752                    let solution_token = self.solution.filename_token().ok_or(
1753                        DataCatalogError::InconsistentProductIdentity { field: "solution" },
1754                    )?;
1755                    format!(
1756                        "{}{}{}{}_{}_{}_{}_{}.{}",
1757                        self.publisher.code(),
1758                        self.version,
1759                        self.campaign.code(),
1760                        solution_token,
1761                        date_block(self.date, self.issue.as_deref()),
1762                        self.span,
1763                        self.sample,
1764                        descriptor.content_code,
1765                        descriptor.extension
1766                    )
1767                }
1768                ProductFilenameKind::Nav => {
1769                    let nav_fields_valid = self.publisher == ProductPublisher::Igs
1770                        && self.solution == SolutionClass::Broadcast
1771                        && self.campaign == ProductCampaign::Broadcast
1772                        && self.version == 0
1773                        && self.issue.is_none()
1774                        && self.span == "01D"
1775                        && self.sample == "01D";
1776                    if !nav_fields_valid {
1777                        return Err(DataCatalogError::InconsistentProductIdentity {
1778                            field: "broadcast_navigation",
1779                        });
1780                    }
1781                    format!(
1782                        "BRDC00WRD_R_{}_{}_{}.{}",
1783                        date_block(self.date, None),
1784                        self.span,
1785                        descriptor.content_code,
1786                        descriptor.extension
1787                    )
1788                }
1789            }
1790        };
1791        if expected != self.official_filename {
1792            return Err(DataCatalogError::InconsistentProductIdentity {
1793                field: "official_filename",
1794            });
1795        }
1796        if self.publisher != self.analysis_center.publisher()
1797            || self.solution != product_solution_class(self.analysis_center, self.family)?
1798            || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1799        {
1800            return Err(DataCatalogError::InconsistentProductIdentity {
1801                field: "analysis_center",
1802            });
1803        }
1804
1805        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1806            let expected_catalog_filename = format!(
1807                "{}_{}_{}_{}_{}.{}",
1808                convention.token,
1809                date_block(self.date, self.issue.as_deref()),
1810                self.span,
1811                self.sample,
1812                descriptor.content_code,
1813                descriptor.extension
1814            );
1815            if expected_catalog_filename != self.official_filename {
1816                return Err(DataCatalogError::InconsistentProductIdentity {
1817                    field: "analysis_center",
1818                });
1819            }
1820        }
1821        Ok(())
1822    }
1823
1824    /// Deterministic identity key suitable for a portable cache layout.
1825    pub fn key(&self) -> Result<String, DataCatalogError> {
1826        use sha2::{Digest, Sha256};
1827
1828        let canonical = self.canonical_bytes()?;
1829        let digest = Sha256::digest(canonical);
1830        Ok(format!(
1831            "{}-{}-{}",
1832            self.publisher.code().to_ascii_lowercase(),
1833            self.solution.code(),
1834            digest[..10]
1835                .iter()
1836                .map(|byte| format!("{byte:02x}"))
1837                .collect::<String>()
1838        ))
1839    }
1840
1841    /// Canonical, unambiguous bytes containing every exact identity field.
1842    ///
1843    /// The encoding is ASCII/UTF-8 field text separated by NUL bytes. It is a
1844    /// stable cross-interface input to cache identity hashing, not a display
1845    /// or interchange document.
1846    pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1847        self.validate()?;
1848        let date = format!(
1849            "{:04}-{:02}-{:02}",
1850            self.date.year, self.date.month, self.date.day
1851        );
1852        let version = self.version.to_string();
1853        let prediction = self
1854            .prediction_horizon_days
1855            .map(|days| days.to_string())
1856            .unwrap_or_default();
1857        let fields = [
1858            self.family.code(),
1859            self.analysis_center.code(),
1860            self.publisher.code(),
1861            self.solution.code(),
1862            self.campaign.code(),
1863            version.as_str(),
1864            date.as_str(),
1865            self.issue.as_deref().unwrap_or_default(),
1866            self.span.as_str(),
1867            self.sample.as_str(),
1868            self.official_filename.as_str(),
1869            self.format.code(),
1870            self.format_version.as_deref().unwrap_or_default(),
1871            prediction.as_str(),
1872        ];
1873        if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1874            return Err(DataCatalogError::InconsistentProductIdentity {
1875                field: "canonical_encoding",
1876            });
1877        }
1878        Ok(fields.join("\0").into_bytes())
1879    }
1880
1881    /// Deterministic cache path for this identity and distributor.
1882    pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1883        Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1884    }
1885}
1886
1887/// Required first-content offset for a validated exact SP3 identity.
1888///
1889/// This is catalog data, not a property inferred from the bytes under test.
1890/// Keeping the lookup identity-based prevents a caller from weakening exact
1891/// start validation with an arbitrary override.
1892pub(crate) fn exact_sp3_content_start_offset_s(
1893    identity: &ProductIdentity,
1894) -> Result<i64, DataCatalogError> {
1895    identity.validate()?;
1896    if identity.family != ProductType::Sp3 {
1897        return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
1898    }
1899
1900    let entry =
1901        center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
1902    // Sampled non-issue identities encode midnight as `Some("0000")`, while
1903    // the public catalog query follows ProductSpec construction and accepts no
1904    // issue for those centers.
1905    let catalog_issue = if entry.issues.is_empty() {
1906        None
1907    } else {
1908        identity.issue.as_deref()
1909    };
1910    Ok(
1911        sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
1912            .content_start_offset_s(),
1913    )
1914}
1915
1916/// Return the official content-start convention for one cataloged SP3 product.
1917///
1918/// `issue` follows the same rules as [`product`]: it is required for
1919/// ultra-rapid centers, must be one of that center's published issues, and must
1920/// be absent for product lines without issue times. Sampling cadence is not an
1921/// input because the cataloged content-start convention is shared by every
1922/// supported cadence of the same product issue.
1923pub fn sp3_content_start_convention(
1924    center: AnalysisCenter,
1925    date: ProductDate,
1926    issue: Option<&str>,
1927) -> Result<Sp3ContentStartConvention, DataCatalogError> {
1928    ProductDate::new(date.year, date.month, date.day)?;
1929    product_convention(center, ProductType::Sp3)?;
1930    validate_product_date(center, ProductType::Sp3, date)?;
1931    validate_issue_for_center(center, issue)?;
1932
1933    sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
1934        DataCatalogError::UnsupportedIssue {
1935            center,
1936            issue: issue.unwrap_or_default().to_owned(),
1937        }
1938    })
1939}
1940
1941fn sp3_content_start_convention_inner(
1942    center: AnalysisCenter,
1943    date: ProductDate,
1944    issue: Option<&str>,
1945) -> Option<Sp3ContentStartConvention> {
1946    if center != AnalysisCenter::GfzUlt {
1947        return Some(Sp3ContentStartConvention::FilenameEpoch);
1948    }
1949    if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
1950        return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
1951    }
1952    if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
1953        return Some(Sp3ContentStartConvention::FilenameEpoch);
1954    }
1955
1956    let issue = issue?;
1957    GFZ_ULTRA_START_TRANSITION
1958        .iter()
1959        .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
1960        .map(|(_, _, convention)| *convention)
1961}
1962
1963/// Distribution metadata for an exact product identity.
1964#[derive(Debug, Clone, PartialEq, Eq)]
1965pub struct DistributionLocation {
1966    /// Selected distributor.
1967    pub source: DistributionSource,
1968    /// Original public URL. Local and in-memory sources have no URL.
1969    pub original_url: Option<String>,
1970    /// Archive filename as served, including transport compression suffix.
1971    pub archive_filename: String,
1972    /// Compression applied by this distributor.
1973    pub compression: ArchiveCompression,
1974}
1975
1976/// Exact product request with an ordered, caller-controlled distributor list.
1977#[derive(Debug, Clone, PartialEq, Eq)]
1978pub struct ProductRequest {
1979    /// Exact requested identity.
1980    pub identity: ProductIdentity,
1981    /// Ordered acceptable distributors for that identity only.
1982    pub distributors: Vec<DistributionSource>,
1983}
1984
1985/// Complete-set validation failure for exact product identities.
1986#[derive(Debug, Clone, PartialEq, Eq)]
1987pub enum ExactProductSetError {
1988    /// A complete set must declare at least one expected product.
1989    EmptyExpected,
1990    /// One expected identity was not internally consistent.
1991    InvalidExpected {
1992        /// Zero-based position in the expected identity list.
1993        index: usize,
1994        /// Identity validation failure.
1995        source: DataCatalogError,
1996    },
1997    /// One available identity was not internally consistent.
1998    InvalidAvailable {
1999        /// Zero-based position in the available identity list.
2000        index: usize,
2001        /// Identity validation failure.
2002        source: DataCatalogError,
2003    },
2004    /// The available identities were not exactly the expected set.
2005    Mismatch {
2006        /// Expected identities that were not available.
2007        missing: Vec<ProductIdentity>,
2008        /// Available identities that were not expected.
2009        unexpected: Vec<ProductIdentity>,
2010        /// Identities declared more than once in the expected list.
2011        duplicate_expected: Vec<ProductIdentity>,
2012        /// Identities declared more than once in the available list.
2013        duplicate_available: Vec<ProductIdentity>,
2014    },
2015}
2016
2017impl fmt::Display for ExactProductSetError {
2018    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2019        match self {
2020            Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2021            Self::InvalidExpected { index, source } => {
2022                write!(f, "expected product {index} is invalid: {source}")
2023            }
2024            Self::InvalidAvailable { index, source } => {
2025                write!(f, "available product {index} is invalid: {source}")
2026            }
2027            Self::Mismatch {
2028                missing,
2029                unexpected,
2030                duplicate_expected,
2031                duplicate_available,
2032            } => write!(
2033                f,
2034                "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2035                identity_list(missing),
2036                identity_list(unexpected),
2037                identity_list(duplicate_expected),
2038                identity_list(duplicate_available),
2039            ),
2040        }
2041    }
2042}
2043
2044impl std::error::Error for ExactProductSetError {}
2045
2046/// Require an available product inventory to match an expected exact set.
2047///
2048/// Every identity is validated before comparison. The expected list must be
2049/// non-empty, neither list may contain duplicates, every expected identity must
2050/// be available, and no undeclared identity may be present. Comparison uses the
2051/// complete [`ProductIdentity`], not only its filename, so metadata that
2052/// distinguishes otherwise identical archive names remains authoritative.
2053///
2054/// This function is a sans-IO completion gate: pass only identities from
2055/// successfully validated acquisitions, and do not start dependent processing
2056/// unless it returns `Ok(())`. For SP3 observed/predicted timing, use
2057/// [`crate::sp3::Sp3::prediction_summary`]; issue times and catalog fields are
2058/// not substitutes for the record flags in the product itself.
2059pub fn validate_exact_product_set(
2060    expected: &[ProductIdentity],
2061    available: &[ProductIdentity],
2062) -> Result<(), ExactProductSetError> {
2063    if expected.is_empty() {
2064        return Err(ExactProductSetError::EmptyExpected);
2065    }
2066    for (index, identity) in expected.iter().enumerate() {
2067        identity
2068            .validate()
2069            .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2070    }
2071    for (index, identity) in available.iter().enumerate() {
2072        identity
2073            .validate()
2074            .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2075    }
2076
2077    let expected_counts = identity_counts(expected);
2078    let available_counts = identity_counts(available);
2079    let missing = unique_matching(expected, |identity| {
2080        !available_counts.contains_key(identity)
2081    });
2082    let unexpected = unique_matching(available, |identity| {
2083        !expected_counts.contains_key(identity)
2084    });
2085    let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2086    let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2087
2088    if missing.is_empty()
2089        && unexpected.is_empty()
2090        && duplicate_expected.is_empty()
2091        && duplicate_available.is_empty()
2092    {
2093        Ok(())
2094    } else {
2095        Err(ExactProductSetError::Mismatch {
2096            missing,
2097            unexpected,
2098            duplicate_expected,
2099            duplicate_available,
2100        })
2101    }
2102}
2103
2104fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2105    let mut counts = HashMap::with_capacity(identities.len());
2106    for identity in identities {
2107        *counts.entry(identity).or_insert(0) += 1;
2108    }
2109    counts
2110}
2111
2112fn unique_matching(
2113    identities: &[ProductIdentity],
2114    mut predicate: impl FnMut(&ProductIdentity) -> bool,
2115) -> Vec<ProductIdentity> {
2116    let mut seen = HashSet::with_capacity(identities.len());
2117    identities
2118        .iter()
2119        .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2120        .cloned()
2121        .collect()
2122}
2123
2124fn identity_list(identities: &[ProductIdentity]) -> String {
2125    if identities.is_empty() {
2126        return "none".to_string();
2127    }
2128    identities
2129        .iter()
2130        .map(|identity| {
2131            identity
2132                .key()
2133                .unwrap_or_else(|_| identity.official_filename.clone())
2134        })
2135        .collect::<Vec<_>>()
2136        .join(", ")
2137}
2138
2139impl ProductRequest {
2140    /// Build an exact request. At least one distributor is required.
2141    pub fn new(
2142        identity: ProductIdentity,
2143        distributors: Vec<DistributionSource>,
2144    ) -> Result<Self, DataCatalogError> {
2145        if distributors.is_empty() {
2146            return Err(DataCatalogError::NoDistributionSources);
2147        }
2148        identity.validate()?;
2149        Ok(Self {
2150            identity,
2151            distributors,
2152        })
2153    }
2154}
2155
2156/// A pure product specification that resolves to one archive filename and URL.
2157#[derive(Debug, Clone, PartialEq, Eq)]
2158pub struct ProductSpec {
2159    /// Analysis center.
2160    pub center: AnalysisCenter,
2161    /// Product type.
2162    pub product_type: ProductType,
2163    /// Product date.
2164    pub date: ProductDate,
2165    /// Sampling token.
2166    pub sample: String,
2167    /// Optional issue time for ultra-rapid products.
2168    pub issue: Option<String>,
2169}
2170
2171impl ProductSpec {
2172    /// Build a product specification and validate it against the catalog.
2173    pub fn new(
2174        center: AnalysisCenter,
2175        product_type: ProductType,
2176        date: ProductDate,
2177        sample: &str,
2178        issue: Option<&str>,
2179    ) -> Result<Self, DataCatalogError> {
2180        ProductDate::new(date.year, date.month, date.day)?;
2181        validate_product(center, product_type, date, sample, issue)?;
2182        Ok(Self {
2183            center,
2184            product_type,
2185            date,
2186            sample: sample.to_string(),
2187            issue: issue.map(ToOwned::to_owned),
2188        })
2189    }
2190
2191    /// GPS week for the product date.
2192    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2193        self.date.gps_week()
2194    }
2195
2196    /// Day-of-year for the product date.
2197    #[must_use]
2198    pub fn day_of_year(&self) -> u16 {
2199        self.date.day_of_year()
2200    }
2201
2202    /// Canonical official filename without archive compression suffix.
2203    ///
2204    /// IGS combined final SP3 products use the historical
2205    /// `igs<week><day>.sp3` convention before GPS week 2238 and the IGS long
2206    /// filename convention from week 2238 onward.
2207    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2208        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2209        let convention = validate_product(
2210            self.center,
2211            self.product_type,
2212            self.date,
2213            &self.sample,
2214            self.issue.as_deref(),
2215        )?;
2216        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2217            return Ok(format!(
2218                "igs{:04}{}.sp3",
2219                self.date.gps_week()?,
2220                self.date.gps_day_of_week()?
2221            ));
2222        }
2223        let descriptor = product_type_convention(self.product_type);
2224        Ok(match descriptor.kind {
2225            ProductFilenameKind::Sampled => format!(
2226                "{}_{}_{}_{}_{}.{}",
2227                convention.token,
2228                date_block(self.date, self.issue.as_deref()),
2229                convention.span,
2230                self.sample,
2231                descriptor.content_code,
2232                descriptor.extension
2233            ),
2234            ProductFilenameKind::Nav => format!(
2235                "{}_R_{}_{}_{}.{}",
2236                convention.token,
2237                date_block(self.date, None),
2238                convention.span,
2239                descriptor.content_code,
2240                descriptor.extension
2241            ),
2242        })
2243    }
2244
2245    /// Full archive URL, including its cataloged transport-compression suffix.
2246    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2247        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2248        let convention = validate_product(
2249            self.center,
2250            self.product_type,
2251            self.date,
2252            &self.sample,
2253            self.issue.as_deref(),
2254        )?;
2255        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2256            return Err(DataCatalogError::UnsupportedDistributionEra {
2257                source: DistributionSource::Direct,
2258                center: self.center,
2259                product_type: self.product_type,
2260                date: self.date,
2261            });
2262        }
2263        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2264        let filename = self.canonical_filename()?;
2265        let compression = product_archive_compression(
2266            self.center,
2267            self.product_type,
2268            self.date,
2269            convention.compression,
2270        )?;
2271        Ok(format!(
2272            "{}/{}/{}{}",
2273            entry.root_url,
2274            product_dir_path(self.center, convention.layout, self.date)?,
2275            filename,
2276            compression.suffix()
2277        ))
2278    }
2279
2280    /// Exact product identity, independent of distributor.
2281    pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2282        let convention = validate_product(
2283            self.center,
2284            self.product_type,
2285            self.date,
2286            &self.sample,
2287            self.issue.as_deref(),
2288        )?;
2289        let descriptor = product_type_convention(self.product_type);
2290        let campaign = match descriptor.kind {
2291            ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2292            ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2293                Some("OPS") => ProductCampaign::Operational,
2294                Some("MGN") => ProductCampaign::MultiGnss,
2295                Some("MGX") => ProductCampaign::MultiGnssExperiment,
2296                _ => {
2297                    return Err(DataCatalogError::InconsistentProductIdentity {
2298                        field: "campaign",
2299                    });
2300                }
2301            },
2302        };
2303        let identity = ProductIdentity {
2304            family: self.product_type,
2305            analysis_center: self.center,
2306            publisher: self.center.publisher(),
2307            solution: product_solution_class(self.center, self.product_type)?,
2308            campaign,
2309            version: 0,
2310            date: self.date,
2311            issue: match descriptor.kind {
2312                ProductFilenameKind::Sampled => {
2313                    Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2314                }
2315                ProductFilenameKind::Nav => None,
2316            },
2317            span: convention.span.to_string(),
2318            sample: self.sample.clone(),
2319            official_filename: self.canonical_filename()?,
2320            format: product_format(self.product_type),
2321            format_version: None,
2322            prediction_horizon_days: self.center.prediction_horizon_days(),
2323        };
2324        identity.validate()?;
2325        Ok(identity)
2326    }
2327
2328    /// Resolve one explicit distributor without changing product identity.
2329    pub fn distribution_location(
2330        &self,
2331        source: DistributionSource,
2332    ) -> Result<DistributionLocation, DataCatalogError> {
2333        let identity = self.identity()?;
2334        distribution_location_for_identity(&identity, source)
2335    }
2336}
2337
2338/// A pure station observation specification.
2339#[derive(Debug, Clone, PartialEq, Eq)]
2340pub struct StationObservationSpec {
2341    /// 9-character RINEX 3 site identifier.
2342    pub station: String,
2343    /// Observation date.
2344    pub date: ProductDate,
2345    /// Sampling token.
2346    pub sample: String,
2347}
2348
2349impl StationObservationSpec {
2350    /// Build and validate a daily station observation product.
2351    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2352        validate_station(station)?;
2353        validate_sample(sample)?;
2354        Ok(Self {
2355            station: station.to_string(),
2356            date,
2357            sample: sample.to_string(),
2358        })
2359    }
2360
2361    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
2362    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2363        station_obs_filename(&self.station, self.date, &self.sample)
2364    }
2365
2366    /// Full archive URL, including `.gz`.
2367    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2368        station_obs_url(&self.station, self.date, &self.sample)
2369    }
2370}
2371
2372/// Static catalog entries, in the same order as the binding data catalog.
2373#[must_use]
2374pub const fn catalog() -> &'static [CenterCatalogEntry] {
2375    &CATALOG
2376}
2377
2378/// Supported center codes, in catalog order.
2379#[must_use]
2380pub const fn centers() -> &'static [AnalysisCenter] {
2381    &CENTER_ORDER
2382}
2383
2384/// Supported product types.
2385#[must_use]
2386pub const fn product_types() -> &'static [ProductTypeConvention] {
2387    &PRODUCT_TYPE_CONVENTIONS
2388}
2389
2390/// Archive hosts present in the catalog.
2391#[must_use]
2392pub const fn allowed_hosts() -> &'static [&'static str] {
2393    &ALLOWED_HOSTS
2394}
2395
2396/// Catalog entry for the Skadi SRTM terrain source.
2397#[must_use]
2398pub const fn skadi_source_entry() -> TerrainSourceEntry {
2399    SKADI_SOURCE
2400}
2401
2402/// Catalog entry for the CelesTrak CSSI space-weather source.
2403#[must_use]
2404pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2405    CELESTRAK_SPACE_WEATHER_SOURCE
2406}
2407
2408/// Filename for a CelesTrak space-weather product.
2409#[must_use]
2410pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2411    match product {
2412        SpaceWeatherProduct::All => "SW-All.csv",
2413        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2414    }
2415}
2416
2417/// Build the CelesTrak archive URL for a space-weather product.
2418#[must_use]
2419pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2420    format!(
2421        "{}/{}",
2422        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2423        space_weather_filename(product)
2424    )
2425}
2426
2427/// Build the cache relative path for a space-weather product.
2428#[must_use]
2429pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2430    format!("space-weather/{}", space_weather_filename(product))
2431}
2432
2433/// Build the Skadi SRTM tile id, for example `N36W107`.
2434pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2435    validate_terrain_tile_index(lat_index, lon_index)?;
2436    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2437    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2438    Ok(format!(
2439        "{lat_hemi}{:02}{lon_hemi}{:03}",
2440        lat_index.abs(),
2441        lon_index.abs()
2442    ))
2443}
2444
2445/// Build the Skadi latitude band directory, for example `N36`.
2446pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2447    validate_terrain_lat_index(lat_index)?;
2448    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2449    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2450}
2451
2452/// Build the Skadi SRTM archive URL for a tile.
2453pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2454    let band = skadi_band(lat_index)?;
2455    let tile_id = skadi_tile_id(lat_index, lon_index)?;
2456    Ok(format!(
2457        "{}/skadi/{}/{}.hgt{}",
2458        SKADI_SOURCE.root_url,
2459        band,
2460        tile_id,
2461        SKADI_SOURCE.compression.suffix()
2462    ))
2463}
2464
2465/// Build the DTED tile filename read by the terrain module.
2466pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2467    validate_terrain_tile_index(lat_index, lon_index)?;
2468    Ok(format!(
2469        "{}_{}{}",
2470        terrain::format_lat(lat_index),
2471        terrain::format_lon(lon_index),
2472        terrain::DTED_SUFFIX
2473    ))
2474}
2475
2476/// Build the DTED ten-degree cache block directory read by the terrain module.
2477pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2478    validate_terrain_tile_index(lat_index, lon_index)?;
2479    Ok(terrain::terrain_block_dir(lat_index, lon_index))
2480}
2481
2482/// Build the DTED cache relative path read by the terrain module.
2483pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2484    Ok(format!(
2485        "{}/{}",
2486        dted_block_dir(lat_index, lon_index)?,
2487        dted_tile_filename(lat_index, lon_index)?
2488    ))
2489}
2490
2491/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
2492pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2493    let bytes = id.as_bytes();
2494    if bytes.len() != 7
2495        || !matches!(bytes[0], b'N' | b'S')
2496        || !matches!(bytes[3], b'E' | b'W')
2497        || !bytes[1..3].iter().all(u8::is_ascii_digit)
2498        || !bytes[4..7].iter().all(u8::is_ascii_digit)
2499    {
2500        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2501    }
2502
2503    let lat_abs = id[1..3]
2504        .parse::<i32>()
2505        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2506    let lon_abs = id[4..7]
2507        .parse::<i32>()
2508        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2509    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2510        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2511    }
2512
2513    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2514    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2515    validate_terrain_tile_index(lat_index, lon_index)?;
2516    Ok((lat_index, lon_index))
2517}
2518
2519/// Derive the terrain tile index covering a latitude/longitude coordinate.
2520pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2521    if !lat_deg.is_finite()
2522        || !lon_deg.is_finite()
2523        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2524        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2525    {
2526        return Err(DataCatalogError::InvalidCoordinate {
2527            lat_deg_bits: lat_deg.to_bits(),
2528            lon_deg_bits: lon_deg.to_bits(),
2529        });
2530    }
2531
2532    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2533    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2534        lat_index = MAX_TERRAIN_LAT_INDEX;
2535    }
2536    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2537        lon_index = MAX_TERRAIN_LON_INDEX;
2538    }
2539    validate_terrain_tile_index(lat_index, lon_index)?;
2540    Ok((lat_index, lon_index))
2541}
2542
2543/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
2544///
2545/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
2546/// order. HGT rows run north to south; DTED data records are longitude columns
2547/// with postings south to north, so output posting `(i, j)` reads source sample
2548/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
2549/// level (`0`) so the existing terrain reader returns `0` for those postings.
2550pub fn hgt_to_dted(
2551    lat_index: i32,
2552    lon_index: i32,
2553    hgt: &[u8],
2554) -> Result<Vec<u8>, HgtConversionError> {
2555    validate_hgt_tile_index(lat_index, lon_index)?;
2556    if hgt.len() != SRTM1_HGT_LEN {
2557        return Err(HgtConversionError::BadLength {
2558            expected: SRTM1_HGT_LEN,
2559            got: hgt.len(),
2560        });
2561    }
2562
2563    let mut out = vec![b' '; DTED_SRTM1_LEN];
2564    out[0..4].copy_from_slice(b"UHL1");
2565    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2566    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2567    out[47..51].copy_from_slice(b"3601");
2568    out[51..55].copy_from_slice(b"3601");
2569
2570    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2571        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2572        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2573        out[block_start] = terrain::DATA_SENTINEL;
2574
2575        let count = (lon_posting as u32).to_be_bytes();
2576        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2577        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2578        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2579
2580        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2581            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2582            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2583            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2584            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2585            let dted_sample_start = block_start + 8 + 2 * lat_posting;
2586            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2587        }
2588
2589        let checksum = out[block_start..checksum_start]
2590            .iter()
2591            .fold(0i32, |acc, byte| acc + i32::from(*byte));
2592        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2593    }
2594
2595    debug_assert_eq!(out.len(), 25_981_042);
2596    Ok(out)
2597}
2598
2599/// Product pairs intentionally withheld because no open mirror is known.
2600#[must_use]
2601pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2602    &NO_OPEN_MIRRORS
2603}
2604
2605/// Confirm that a center/product pair has an open catalog mirror.
2606pub fn open_mirror(
2607    center: AnalysisCenter,
2608    product_type: ProductType,
2609) -> Result<(), DataCatalogError> {
2610    open_mirror_code(center.code(), product_type.code())
2611}
2612
2613/// Confirm that a center/product code pair is not in the no-open-mirror list.
2614pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2615    if NO_OPEN_MIRRORS
2616        .iter()
2617        .any(|entry| entry.center == center && entry.product_type == product_type)
2618    {
2619        Err(DataCatalogError::NoOpenMirror {
2620            center: center.to_string(),
2621            product_type: product_type.to_string(),
2622        })
2623    } else {
2624        Ok(())
2625    }
2626}
2627
2628/// Look up a center's static catalog entry.
2629#[must_use]
2630pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2631    CATALOG.iter().find(|entry| entry.center == center)
2632}
2633
2634/// Look up the convention for one center and product type.
2635pub fn product_convention(
2636    center: AnalysisCenter,
2637    product_type: ProductType,
2638) -> Result<&'static CenterProductConvention, DataCatalogError> {
2639    open_mirror(center, product_type)?;
2640    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2641    entry
2642        .products
2643        .iter()
2644        .find(|product| product.product_type == product_type)
2645        .ok_or(DataCatalogError::UnsupportedProduct {
2646            center,
2647            product_type,
2648        })
2649}
2650
2651/// Return the solution class for a supported center/product family.
2652///
2653/// This product-aware API resolves the ambiguity in the legacy
2654/// [`AnalysisCenter::solution_class`] method. For example, IGS merged
2655/// broadcast navigation is [`SolutionClass::Broadcast`], while IGS combined
2656/// final SP3 is [`SolutionClass::Final`]. Unsupported combinations are
2657/// rejected before callers derive a filename or attempt acquisition.
2658pub fn product_solution_class(
2659    center: AnalysisCenter,
2660    product_type: ProductType,
2661) -> Result<SolutionClass, DataCatalogError> {
2662    product_convention(center, product_type)?;
2663    Ok(match (center, product_type) {
2664        (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2665        _ => center.solution_class(),
2666    })
2667}
2668
2669/// Current default sampling token for a center/product pair.
2670///
2671/// This preserves the original date-free query and reports the current catalog
2672/// convention. Use [`default_sample_for_date`] when deriving a historical
2673/// product whose published cadence may have changed.
2674pub fn default_sample(
2675    center: AnalysisCenter,
2676    product_type: ProductType,
2677) -> Result<&'static str, DataCatalogError> {
2678    Ok(product_convention(center, product_type)?.default_sample)
2679}
2680
2681/// Published default sampling token for a center/product pair on a date.
2682///
2683/// Most catalog families use one sampling token across their modeled history.
2684/// For issue-based products this date-only query represents the `0000` issue;
2685/// product construction uses its actual issue and can therefore select a
2686/// within-day transition. GFZ rapid and ultra-rapid SP3 and ESA ultra-rapid SP3
2687/// have cataloged cadence transitions.
2688pub fn default_sample_for_date(
2689    center: AnalysisCenter,
2690    product_type: ProductType,
2691    date: ProductDate,
2692) -> Result<&'static str, DataCatalogError> {
2693    default_sample_for_product_issue(center, product_type, date, None)
2694}
2695
2696/// GPS week number for a product date.
2697pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2698    date.gps_week()
2699}
2700
2701/// Day-of-year in `1..=366` for a product date.
2702#[must_use]
2703pub fn day_of_year(date: ProductDate) -> u16 {
2704    date.day_of_year()
2705}
2706
2707/// Build a product specification for any center/product/date combination.
2708pub fn product(
2709    center: AnalysisCenter,
2710    product_type: ProductType,
2711    date: ProductDate,
2712    sample: Option<&str>,
2713    issue: Option<&str>,
2714) -> Result<ProductSpec, DataCatalogError> {
2715    let sample = match sample {
2716        Some(sample) => sample,
2717        None => default_sample_for_product_issue(center, product_type, date, issue)?,
2718    };
2719    ProductSpec::new(center, product_type, date, sample, issue)
2720}
2721
2722/// Build the canonical IGS long-name filename for a product.
2723pub fn canonical_filename(
2724    center: AnalysisCenter,
2725    product_type: ProductType,
2726    date: ProductDate,
2727    sample: Option<&str>,
2728    issue: Option<&str>,
2729) -> Result<String, DataCatalogError> {
2730    product(center, product_type, date, sample, issue)?.canonical_filename()
2731}
2732
2733/// Build the full archive URL for a product.
2734pub fn archive_url(
2735    center: AnalysisCenter,
2736    product_type: ProductType,
2737    date: ProductDate,
2738    sample: Option<&str>,
2739    issue: Option<&str>,
2740) -> Result<String, DataCatalogError> {
2741    product(center, product_type, date, sample, issue)?.archive_url()
2742}
2743
2744/// Build the exact identity for a catalog product.
2745pub fn product_identity(
2746    center: AnalysisCenter,
2747    product_type: ProductType,
2748    date: ProductDate,
2749    sample: Option<&str>,
2750    issue: Option<&str>,
2751) -> Result<ProductIdentity, DataCatalogError> {
2752    product(center, product_type, date, sample, issue)?.identity()
2753}
2754
2755/// Resolve an explicit distributor for a catalog product.
2756pub fn distribution_location(
2757    center: AnalysisCenter,
2758    product_type: ProductType,
2759    date: ProductDate,
2760    sample: Option<&str>,
2761    issue: Option<&str>,
2762    source: DistributionSource,
2763) -> Result<DistributionLocation, DataCatalogError> {
2764    product(center, product_type, date, sample, issue)?.distribution_location(source)
2765}
2766
2767/// Resolve one explicit distributor from a complete exact product identity.
2768///
2769/// Unlike [`distribution_location`], this retains the already validated exact
2770/// center, date, issue, cadence, span, and filename carried by `identity`
2771/// instead of reconstructing a default product specification. The function
2772/// performs no network or file IO.
2773pub fn distribution_location_for_identity(
2774    identity: &ProductIdentity,
2775    source: DistributionSource,
2776) -> Result<DistributionLocation, DataCatalogError> {
2777    identity.validate()?;
2778    match source {
2779        DistributionSource::Direct => {
2780            let convention = product_convention(identity.analysis_center, identity.family)?;
2781            if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2782            {
2783                return Err(DataCatalogError::UnsupportedDistributionEra {
2784                    source,
2785                    center: identity.analysis_center,
2786                    product_type: identity.family,
2787                    date: identity.date,
2788                });
2789            }
2790            let entry = center_catalog(identity.analysis_center)
2791                .expect("validated analysis center has a catalog entry");
2792            let compression = product_archive_compression(
2793                identity.analysis_center,
2794                identity.family,
2795                identity.date,
2796                convention.compression,
2797            )?;
2798            let url = format!(
2799                "{}/{}/{}{}",
2800                entry.root_url,
2801                product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2802                identity.official_filename,
2803                compression.suffix()
2804            );
2805            Ok(DistributionLocation {
2806                source,
2807                original_url: Some(url),
2808                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2809                compression,
2810            })
2811        }
2812        DistributionSource::NasaCddis => {
2813            validate_cddis_distribution_era(identity)?;
2814            let compression = product_archive_compression(
2815                identity.analysis_center,
2816                identity.family,
2817                identity.date,
2818                ArchiveCompression::Gzip,
2819            )?;
2820            Ok(DistributionLocation {
2821                source,
2822                original_url: Some(cddis_archive_url(identity)?),
2823                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2824                compression,
2825            })
2826        }
2827        DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2828            source,
2829            original_url: None,
2830            archive_filename: identity.official_filename.clone(),
2831            compression: ArchiveCompression::None,
2832        }),
2833    }
2834}
2835
2836/// Build the official NASA CDDIS HTTPS URL for an exact SP3 or IONEX identity.
2837///
2838/// CDDIS stores supported current SP3 products by GPS week and current IONEX
2839/// products by year/day-of-year. The decompressed official filename is
2840/// unchanged. Before GPS week 2238, only the modeled IGS combined-final legacy
2841/// short-name SP3 series has a verified mapping; unmodeled long-name SP3 and
2842/// IONEX identities are rejected. ESA's `ESA0MGNFIN` final SP3 line is not
2843/// projected onto CDDIS because no exact CDDIS mapping is cataloged for it.
2844pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2845    identity.validate()?;
2846    validate_cddis_distribution_era(identity)?;
2847    match identity.family {
2848        ProductType::Sp3 => {
2849            let compression = product_archive_compression(
2850                identity.analysis_center,
2851                identity.family,
2852                identity.date,
2853                ArchiveCompression::Gzip,
2854            )?;
2855            Ok(format!(
2856                "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2857                identity.date.gps_week()?,
2858                identity.official_filename,
2859                compression.suffix()
2860            ))
2861        }
2862        ProductType::Ionex => Ok(format!(
2863            "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2864            identity.date.year,
2865            identity.date.day_of_year(),
2866            identity.official_filename
2867        )),
2868        product_type => Err(DataCatalogError::UnsupportedDistribution {
2869            source: DistributionSource::NasaCddis,
2870            product_type,
2871        }),
2872    }
2873}
2874
2875/// Build a clock product for a center and date.
2876pub fn mgex_clk(
2877    center: AnalysisCenter,
2878    date: ProductDate,
2879    sample: Option<&str>,
2880) -> Result<ProductSpec, DataCatalogError> {
2881    product(center, ProductType::Clk, date, sample, None)
2882}
2883
2884/// Build a merged broadcast-navigation product for a center and date.
2885pub fn mgex_nav(
2886    center: AnalysisCenter,
2887    date: ProductDate,
2888    sample: Option<&str>,
2889) -> Result<ProductSpec, DataCatalogError> {
2890    product(center, ProductType::Nav, date, sample, None)
2891}
2892
2893/// Build an IONEX product for a center and date.
2894pub fn mgex_ionex(
2895    center: AnalysisCenter,
2896    date: ProductDate,
2897    sample: Option<&str>,
2898) -> Result<ProductSpec, DataCatalogError> {
2899    product(center, ProductType::Ionex, date, sample, None)
2900}
2901
2902/// Build the CODE rapid IONEX product for a date.
2903pub fn rapid_ionex(
2904    date: ProductDate,
2905    sample: Option<&str>,
2906) -> Result<ProductSpec, DataCatalogError> {
2907    product(
2908        AnalysisCenter::CodRap,
2909        ProductType::Ionex,
2910        date,
2911        sample,
2912        None,
2913    )
2914}
2915
2916/// Day offset for predicted IONEX aliases.
2917#[must_use]
2918pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2919    match center {
2920        AnalysisCenter::CodPrd2 => 1,
2921        _ => 0,
2922    }
2923}
2924
2925/// Build a CODE predicted IONEX product for a target date.
2926pub fn predicted_ionex(
2927    center: AnalysisCenter,
2928    date: ProductDate,
2929    sample: Option<&str>,
2930) -> Result<ProductSpec, DataCatalogError> {
2931    match center {
2932        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2933            let target = date.add_days(predicted_day_offset(center))?;
2934            product(center, ProductType::Ionex, target, sample, None)
2935        }
2936        other => Err(DataCatalogError::UnsupportedProduct {
2937            center: other,
2938            product_type: ProductType::Ionex,
2939        }),
2940    }
2941}
2942
2943/// Build an SP3 product for a center and date.
2944pub fn mgex_sp3(
2945    center: AnalysisCenter,
2946    date: ProductDate,
2947    sample: Option<&str>,
2948) -> Result<ProductSpec, DataCatalogError> {
2949    product(center, ProductType::Sp3, date, sample, None)
2950}
2951
2952/// Build an ultra-rapid OPS SP3 product for a date and issue time.
2953pub fn ops_ultra_sp3(
2954    center: AnalysisCenter,
2955    date: ProductDate,
2956    sample: Option<&str>,
2957    issue: Option<&str>,
2958) -> Result<ProductSpec, DataCatalogError> {
2959    let issue = issue.unwrap_or("0000");
2960    product(center, ProductType::Sp3, date, sample, Some(issue))
2961}
2962
2963/// Generate the officially cataloged ultra-rapid SP3 locations for one issue.
2964///
2965/// The dated locations use only spans and sampling intervals evidenced for the
2966/// exact center, date, and issue. A second dated location is returned only for
2967/// an archive-observed overlap such as GFZ's 2021-05-15 `0000` issue. Moving
2968/// latest-product snapshots are not exact dated identities and are therefore
2969/// outside this API. Callers should try the next location only when the prior
2970/// URL is absent; transport and retry policy remain outside the pure core
2971/// catalog.
2972pub fn ultra_sp3_locations(
2973    center: AnalysisCenter,
2974    date: ProductDate,
2975    issue: &str,
2976) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2977    validate_issue_for_center(center, Some(issue))?;
2978    validate_product_date(center, ProductType::Sp3, date)?;
2979    match center {
2980        AnalysisCenter::IgsUlt
2981        | AnalysisCenter::CodUlt
2982        | AnalysisCenter::EsaUlt
2983        | AnalysisCenter::GfzUlt => {}
2984        other => {
2985            return Err(DataCatalogError::UnsupportedProduct {
2986                center: other,
2987                product_type: ProductType::Sp3,
2988            })
2989        }
2990    };
2991    let default_sample =
2992        default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
2993    let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
2994    samples.sort_by_key(|sample| *sample != default_sample);
2995
2996    samples
2997        .into_iter()
2998        .map(|sample| {
2999            // Reuse the single-product catalog path so candidate enumeration
3000            // cannot drift from canonical filename, URL, identity, or
3001            // date-dependent compression derivation.
3002            let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3003            let identity = spec.identity()?;
3004            let filename = spec.canonical_filename()?;
3005            let url = spec.archive_url()?;
3006            let convention = product_convention(center, ProductType::Sp3)?;
3007            let compression = product_archive_compression(
3008                center,
3009                ProductType::Sp3,
3010                date,
3011                convention.compression,
3012            )?;
3013            Ok(UltraSp3Location {
3014                pattern: if sample == default_sample {
3015                    format!("primary_{}_{}", identity.span, sample)
3016                } else {
3017                    format!("alternate_{}_{}", identity.span, sample)
3018                },
3019                span: identity.span,
3020                sample: sample.to_string(),
3021                url,
3022                filename,
3023                compression,
3024            })
3025        })
3026        .collect()
3027}
3028
3029/// Build an ultra-rapid OPS clock product for a date and issue time.
3030pub fn ops_ultra_clk(
3031    center: AnalysisCenter,
3032    date: ProductDate,
3033    sample: Option<&str>,
3034    issue: Option<&str>,
3035) -> Result<ProductSpec, DataCatalogError> {
3036    let issue = issue.unwrap_or("0000");
3037    product(center, ProductType::Clk, date, sample, Some(issue))
3038}
3039
3040/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
3041pub fn latest_ops_ultra_sp3(
3042    center: AnalysisCenter,
3043    target: ProductDateTime,
3044    sample: Option<&str>,
3045    available_issues: Option<&[UltraIssue]>,
3046) -> Result<ProductSpec, DataCatalogError> {
3047    let selected = latest_ultra_issue(center, target, available_issues)?;
3048    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3049}
3050
3051/// Candidate ultra-rapid issues at or before a target time, newest first.
3052pub fn ultra_issue_candidates(
3053    center: AnalysisCenter,
3054    target: ProductDateTime,
3055) -> Result<Vec<UltraIssue>, DataCatalogError> {
3056    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3057    let _ = product_convention(center, ProductType::Sp3)?;
3058    if entry.issues.is_empty() {
3059        return Err(DataCatalogError::UnsupportedProduct {
3060            center,
3061            product_type: ProductType::Sp3,
3062        });
3063    }
3064    validate_product_date(center, ProductType::Sp3, target.date)?;
3065
3066    let mut candidates = Vec::new();
3067    for date in [target.date, target.date.add_days(-1)?] {
3068        match validate_product_date(center, ProductType::Sp3, date) {
3069            Ok(()) => {}
3070            Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3071            Err(error) => return Err(error),
3072        }
3073        for issue in entry.issues.iter().rev() {
3074            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3075                candidates.push(UltraIssue::new(date, issue)?);
3076            }
3077        }
3078    }
3079    Ok(candidates)
3080}
3081
3082/// Latest ultra-rapid issue at or before a target time.
3083pub fn latest_ultra_issue(
3084    center: AnalysisCenter,
3085    target: ProductDateTime,
3086    available_issues: Option<&[UltraIssue]>,
3087) -> Result<UltraIssue, DataCatalogError> {
3088    let candidates = ultra_issue_candidates(center, target)?;
3089    if candidates.is_empty() {
3090        return Err(DataCatalogError::NoUltraIssue);
3091    }
3092    if let Some(available) = available_issues {
3093        candidates
3094            .into_iter()
3095            .find(|candidate| {
3096                available
3097                    .iter()
3098                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3099            })
3100            .ok_or(DataCatalogError::NoAvailableUltraIssue)
3101    } else {
3102        Ok(candidates[0].clone())
3103    }
3104}
3105
3106/// Candidate IONEX dates at or before a target date, newest first.
3107pub fn gim_date_candidates(
3108    center: AnalysisCenter,
3109    target: ProductDate,
3110    lookback: u32,
3111) -> Result<Vec<ProductDate>, DataCatalogError> {
3112    let _ = product_convention(center, ProductType::Ionex)?;
3113    let base = target.add_days(predicted_day_offset(center))?;
3114    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3115    for back in 0..=lookback {
3116        out.push(base.add_days(-i64::from(back))?);
3117    }
3118    Ok(out)
3119}
3120
3121/// Build a daily station observation product.
3122pub fn station_obs(
3123    station: &str,
3124    date: ProductDate,
3125    sample: Option<&str>,
3126) -> Result<StationObservationSpec, DataCatalogError> {
3127    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3128}
3129
3130/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
3131pub fn station_obs_filename(
3132    station: &str,
3133    date: ProductDate,
3134    sample: &str,
3135) -> Result<String, DataCatalogError> {
3136    validate_station(station)?;
3137    validate_sample(sample)?;
3138    Ok(format!(
3139        "{}_R_{}_01D_{}_MO.crx",
3140        station,
3141        date_block(date, None),
3142        sample
3143    ))
3144}
3145
3146/// Build the full BKG IGS archive URL for a daily station observation.
3147pub fn station_obs_url(
3148    station: &str,
3149    date: ProductDate,
3150    sample: &str,
3151) -> Result<String, DataCatalogError> {
3152    let filename = station_obs_filename(station, date, sample)?;
3153    Ok(format!(
3154        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3155        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3156        filename
3157    ))
3158}
3159
3160/// The transfer protocol for the daily station observation archive.
3161#[must_use]
3162pub const fn station_obs_protocol() -> ArchiveProtocol {
3163    ArchiveProtocol::Https
3164}
3165
3166fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3167    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3168        Ok(())
3169    } else {
3170        Err(DataCatalogError::InvalidTileIndex {
3171            lat_index,
3172            lon_index: 0,
3173        })
3174    }
3175}
3176
3177fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3178    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3179        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3180    {
3181        Ok(())
3182    } else {
3183        Err(DataCatalogError::InvalidTileIndex {
3184            lat_index,
3185            lon_index,
3186        })
3187    }
3188}
3189
3190fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3191    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3192        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3193    {
3194        Ok(())
3195    } else {
3196        Err(HgtConversionError::InvalidTileIndex {
3197            lat_index,
3198            lon_index,
3199        })
3200    }
3201}
3202
3203fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3204    let hemi = match (is_longitude, index >= 0) {
3205        (true, true) => 'E',
3206        (true, false) => 'W',
3207        (false, true) => 'N',
3208        (false, false) => 'S',
3209    };
3210    format!("{:03}0000{hemi}", index.abs())
3211}
3212
3213fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3214    if sample == i16::MIN {
3215        0
3216    } else if sample >= 0 {
3217        sample as u16
3218    } else {
3219        0x8000 | (-i32::from(sample) as u16)
3220    }
3221}
3222
3223fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3224    PRODUCT_TYPE_CONVENTIONS
3225        .iter()
3226        .find(|descriptor| descriptor.product_type == product_type)
3227        .expect("product descriptor exists for enum variant")
3228}
3229
3230const fn product_format(product_type: ProductType) -> ProductFormat {
3231    match product_type {
3232        ProductType::Sp3 => ProductFormat::Sp3,
3233        ProductType::Ionex => ProductFormat::Ionex,
3234        ProductType::Clk => ProductFormat::RinexClock,
3235        ProductType::Nav => ProductFormat::RinexNavigation,
3236    }
3237}
3238
3239fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3240    if filename.is_empty()
3241        || filename == "."
3242        || filename == ".."
3243        || filename.contains('/')
3244        || filename.contains('\\')
3245        || filename.contains('\0')
3246        || filename.contains("..")
3247    {
3248        Err(DataCatalogError::InvalidOfficialFilename(
3249            filename.to_string(),
3250        ))
3251    } else {
3252        Ok(())
3253    }
3254}
3255
3256fn validate_product(
3257    center: AnalysisCenter,
3258    product_type: ProductType,
3259    date: ProductDate,
3260    sample: &str,
3261    issue: Option<&str>,
3262) -> Result<&'static CenterProductConvention, DataCatalogError> {
3263    let convention = product_convention(center, product_type)?;
3264    validate_sample(sample)?;
3265    validate_issue_for_center(center, issue)?;
3266    validate_product_date(center, product_type, date)?;
3267    validate_catalog_sample(center, product_type, date, sample, issue)?;
3268    Ok(convention)
3269}
3270
3271fn validate_catalog_sample(
3272    center: AnalysisCenter,
3273    product_type: ProductType,
3274    date: ProductDate,
3275    sample: &str,
3276    issue: Option<&str>,
3277) -> Result<(), DataCatalogError> {
3278    let supported = supported_samples_inner(center, product_type, date, issue)?;
3279    if supported.contains(&sample) {
3280        return Ok(());
3281    }
3282    Err(DataCatalogError::UnsupportedSample {
3283        center,
3284        product_type,
3285        sample: sample.to_string(),
3286    })
3287}
3288
3289/// Officially evidenced sampling tokens for one exact catalog product.
3290///
3291/// Syntax alone is not publication evidence: this query reports only cadences
3292/// backed by the official product line for the selected center, family, date,
3293/// and issue. Constructors enforce the same result before deriving a filename,
3294/// URL, identity, or cache key.
3295///
3296/// For issue-based product lines, omitting `issue` selects the `0000` issue,
3297/// matching [`default_sample_for_date`]. Product construction itself still
3298/// requires an explicit issue.
3299pub fn supported_samples(
3300    center: AnalysisCenter,
3301    product_type: ProductType,
3302    date: ProductDate,
3303    issue: Option<&str>,
3304) -> Result<&'static [&'static str], DataCatalogError> {
3305    ProductDate::new(date.year, date.month, date.day)?;
3306    product_convention(center, product_type)?;
3307    validate_product_date(center, product_type, date)?;
3308
3309    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3310    if entry.issues.is_empty() {
3311        validate_issue_for_center(center, issue)?;
3312    } else {
3313        validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3314    }
3315    supported_samples_inner(center, product_type, date, issue)
3316}
3317
3318fn supported_samples_inner(
3319    center: AnalysisCenter,
3320    product_type: ProductType,
3321    date: ProductDate,
3322    issue: Option<&str>,
3323) -> Result<&'static [&'static str], DataCatalogError> {
3324    if product_type != ProductType::Sp3 {
3325        let convention = product_convention(center, product_type)?;
3326        return Ok(match convention.default_sample {
3327            "30S" => &["30S"],
3328            "01H" => &["01H"],
3329            "02H" => &["02H"],
3330            "01D" => &["01D"],
3331            _ => &[],
3332        });
3333    }
3334
3335    Ok(match center {
3336        AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3337        AnalysisCenter::Esa | AnalysisCenter::Cod | AnalysisCenter::CodUlt => &["05M"],
3338        AnalysisCenter::Gfz => {
3339            if date < GFZ_RAPID_5M_START_DATE {
3340                &["15M"]
3341            } else {
3342                &["05M"]
3343            }
3344        }
3345        AnalysisCenter::EsaUlt => {
3346            let issue = issue.unwrap_or("0000");
3347            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3348                || (date == ESA_ULTRA_15M_LAST_DATE
3349                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3350            if at_or_before_last_15m {
3351                &["15M"]
3352            } else {
3353                &["05M"]
3354            }
3355        }
3356        AnalysisCenter::GfzUlt => {
3357            if date < GFZ_ULTRA_15M_LAST_DATE {
3358                &["15M"]
3359            } else if date == GFZ_ULTRA_15M_LAST_DATE {
3360                if issue.unwrap_or("0000") == "0000" {
3361                    &["15M", "05M"]
3362                } else {
3363                    &["15M"]
3364                }
3365            } else {
3366                &["05M"]
3367            }
3368        }
3369        AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3370    })
3371}
3372
3373fn validate_product_date(
3374    center: AnalysisCenter,
3375    product_type: ProductType,
3376    date: ProductDate,
3377) -> Result<(), DataCatalogError> {
3378    // The official IGS rapid/final orbit combination began at GPS week 0730.
3379    // Earlier dates must not be assigned a syntactically plausible legacy
3380    // filename for a combined final product that did not yet exist.
3381    if center == AnalysisCenter::Igs
3382        && product_type == ProductType::Sp3
3383        && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3384    {
3385        return Err(DataCatalogError::UnsupportedProductEra {
3386            center,
3387            product_type,
3388            date,
3389        });
3390    }
3391
3392    // AIUB documents different short-name CODE products through week 2237.
3393    // This catalog intentionally refuses those dates until their distinct
3394    // identities and distributor rules are modeled; it must not emit a
3395    // post-transition long filename that never existed.
3396    if center == AnalysisCenter::Cod
3397        && matches!(
3398            product_type,
3399            ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3400        )
3401        && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3402    {
3403        return Err(DataCatalogError::UnsupportedProductEra {
3404            center,
3405            product_type,
3406            date,
3407        });
3408    }
3409
3410    let start_date = match (center, product_type) {
3411        (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
3412            Some(ESA_FINAL_SERIES_START_DATE)
3413        }
3414        (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
3415            Some(GFZ_RAPID_SERIES_START_DATE)
3416        }
3417        (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
3418        (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
3419        _ => None,
3420    };
3421    let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
3422        && product_type == ProductType::Sp3
3423        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
3424    if before_long_name_start || start_date.is_some_and(|start| date < start) {
3425        return Err(DataCatalogError::UnsupportedProductEra {
3426            center,
3427            product_type,
3428            date,
3429        });
3430    }
3431    Ok(())
3432}
3433
3434fn default_sample_for_product_issue(
3435    center: AnalysisCenter,
3436    product_type: ProductType,
3437    date: ProductDate,
3438    issue: Option<&str>,
3439) -> Result<&'static str, DataCatalogError> {
3440    ProductDate::new(date.year, date.month, date.day)?;
3441    let current = default_sample(center, product_type)?;
3442    validate_product_date(center, product_type, date)?;
3443
3444    if product_type != ProductType::Sp3 {
3445        return Ok(current);
3446    }
3447    match center {
3448        AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
3449        AnalysisCenter::EsaUlt => {
3450            // A date-only query represents the 0000/start-of-day issue. Product
3451            // construction supplies the actual issue and therefore observes
3452            // the within-day transition on 2025-02-02.
3453            let issue = issue.unwrap_or("0000");
3454            validate_issue_for_center(center, Some(issue))?;
3455            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3456                || (date == ESA_ULTRA_15M_LAST_DATE
3457                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3458            if at_or_before_last_15m {
3459                Ok("15M")
3460            } else {
3461                Ok(current)
3462            }
3463        }
3464        AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
3465        _ => Ok(current),
3466    }
3467}
3468
3469fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
3470    let gps_week = identity.date.gps_week()?;
3471    let esa_mgex_final_sp3 =
3472        identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
3473    let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
3474        && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
3475        && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
3476    let unmodeled_pretransition_ionex =
3477        identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
3478    if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
3479        Err(DataCatalogError::UnsupportedDistributionEra {
3480            source: DistributionSource::NasaCddis,
3481            center: identity.analysis_center,
3482            product_type: identity.family,
3483            date: identity.date,
3484        })
3485    } else {
3486        Ok(())
3487    }
3488}
3489
3490fn validate_issue_for_center(
3491    center: AnalysisCenter,
3492    issue: Option<&str>,
3493) -> Result<(), DataCatalogError> {
3494    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3495    match (entry.issues.is_empty(), issue) {
3496        (true, None) => Ok(()),
3497        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
3498        (false, None) => Err(DataCatalogError::MissingIssue { center }),
3499        (false, Some(issue)) => {
3500            validate_issue(issue)?;
3501            if entry.issues.contains(&issue) {
3502                Ok(())
3503            } else {
3504                Err(DataCatalogError::UnsupportedIssue {
3505                    center,
3506                    issue: issue.to_string(),
3507                })
3508            }
3509        }
3510    }
3511}
3512
3513fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
3514    if validate_period_token(sample) {
3515        Ok(())
3516    } else {
3517        Err(DataCatalogError::InvalidSample(sample.to_string()))
3518    }
3519}
3520
3521fn validate_span(span: &str) -> Result<(), DataCatalogError> {
3522    if validate_period_token(span) {
3523        Ok(())
3524    } else {
3525        Err(DataCatalogError::InvalidSpan(span.to_string()))
3526    }
3527}
3528
3529fn validate_period_token(token: &str) -> bool {
3530    let bytes = token.as_bytes();
3531    if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
3532        return false;
3533    }
3534    let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
3535    match bytes[2] {
3536        // Reject exact smaller-unit spellings where the public guideline
3537        // unambiguously provides the next sub-day unit. Do not normalize D to
3538        // W or L to Y: official IGS filenames use values such as 07D, and the
3539        // public convention treats those calendar-oriented units as valid.
3540        b'S' | b'M' => amount > 0 && amount % 60 != 0,
3541        b'H' => amount > 0 && amount % 24 != 0,
3542        b'D' | b'W' | b'L' | b'Y' => amount > 0,
3543        // IGS reserves 00U for an unspecified interval. Exact-SP3 validation
3544        // rejects it because it cannot represent a positive cadence.
3545        b'U' => amount == 0,
3546        _ => false,
3547    }
3548}
3549
3550fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
3551    let bytes = issue.as_bytes();
3552    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
3553    if !valid_digits {
3554        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
3555    }
3556    let hour = issue[0..2]
3557        .parse::<u8>()
3558        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3559    let minute = issue[2..4]
3560        .parse::<u8>()
3561        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3562    if hour <= 23 && minute <= 59 {
3563        Ok(())
3564    } else {
3565        Err(DataCatalogError::InvalidIssue(issue.to_string()))
3566    }
3567}
3568
3569fn validate_station(station: &str) -> Result<(), DataCatalogError> {
3570    let bytes = station.as_bytes();
3571    let valid = bytes.len() == 9
3572        && bytes
3573            .iter()
3574            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
3575    if valid {
3576        Ok(())
3577    } else {
3578        Err(DataCatalogError::InvalidStation(station.to_string()))
3579    }
3580}
3581
3582fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
3583    validate_issue(issue)?;
3584    let hour = issue[0..2]
3585        .parse::<u16>()
3586        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3587    let minute = issue[2..4]
3588        .parse::<u16>()
3589        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3590    Ok(hour * 60 + minute)
3591}
3592
3593fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
3594    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
3595}
3596
3597fn date_block(date: ProductDate, issue: Option<&str>) -> String {
3598    format!(
3599        "{}{:03}{}",
3600        date.year,
3601        date.day_of_year(),
3602        issue.unwrap_or("0000")
3603    )
3604}
3605
3606fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
3607    Ok(match layout {
3608        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
3609        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
3610        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
3611        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
3612        ArchiveLayout::BkgBrdcYearDoy => {
3613            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
3614        }
3615        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
3616        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
3617        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
3618        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
3619    })
3620}
3621
3622fn product_dir_path(
3623    center: AnalysisCenter,
3624    layout: ArchiveLayout,
3625    date: ProductDate,
3626) -> Result<String, DataCatalogError> {
3627    match center {
3628        AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
3629        AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
3630        _ => dir_path(layout, date),
3631    }
3632}
3633
3634fn uses_legacy_igs_final_name(
3635    center: AnalysisCenter,
3636    product_type: ProductType,
3637    date: ProductDate,
3638) -> Result<bool, DataCatalogError> {
3639    Ok(center == AnalysisCenter::Igs
3640        && product_type == ProductType::Sp3
3641        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
3642}
3643
3644fn product_archive_compression(
3645    center: AnalysisCenter,
3646    product_type: ProductType,
3647    date: ProductDate,
3648    default: ArchiveCompression,
3649) -> Result<ArchiveCompression, DataCatalogError> {
3650    if uses_legacy_igs_final_name(center, product_type, date)? {
3651        Ok(ArchiveCompression::UnixCompress)
3652    } else {
3653        Ok(default)
3654    }
3655}
3656
3657fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
3658    let (year, month, day) = civil_from_julian_day_number(jdn);
3659    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
3660    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
3661    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
3662    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
3663}
3664
3665#[cfg(test)]
3666mod content_start_tests {
3667    use super::*;
3668
3669    const GFZ_ISSUES: [&str; 8] = [
3670        "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
3671    ];
3672
3673    fn date(year: i32, month: u8, day: u8) -> ProductDate {
3674        ProductDate::new(year, month, day).expect("test date")
3675    }
3676
3677    fn offset(
3678        center: AnalysisCenter,
3679        product_date: ProductDate,
3680        sample: &str,
3681        issue: Option<&str>,
3682    ) -> i64 {
3683        let identity =
3684            product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
3685                .expect("cataloged SP3 identity");
3686        exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
3687    }
3688
3689    #[test]
3690    fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
3691        for issue in GFZ_ISSUES {
3692            assert_eq!(
3693                offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
3694                -86_400,
3695                "2022-09-06 issue {issue}"
3696            );
3697        }
3698    }
3699
3700    #[test]
3701    fn gfz_ultra_transition_is_cataloged_per_issue() {
3702        let day_seven = [
3703            0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
3704        ];
3705        let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
3706
3707        for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
3708            for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
3709                assert_eq!(
3710                    offset(
3711                        AnalysisCenter::GfzUlt,
3712                        date(2022, 9, product_day),
3713                        "05M",
3714                        Some(issue)
3715                    ),
3716                    expected_offset,
3717                    "2022-09-{product_day:02} issue {issue}"
3718                );
3719            }
3720        }
3721    }
3722
3723    #[test]
3724    fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
3725        for issue in GFZ_ISSUES {
3726            assert_eq!(
3727                offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
3728                0,
3729                "2022-09-09 issue {issue}"
3730            );
3731        }
3732
3733        let current = date(2026, 7, 20);
3734        let cases = [
3735            (AnalysisCenter::Igs, "15M", None),
3736            (AnalysisCenter::Esa, "05M", None),
3737            (AnalysisCenter::Cod, "05M", None),
3738            (AnalysisCenter::Gfz, "05M", None),
3739            (AnalysisCenter::IgsUlt, "15M", Some("1200")),
3740            (AnalysisCenter::CodUlt, "05M", Some("0000")),
3741            (AnalysisCenter::EsaUlt, "05M", Some("1800")),
3742            (AnalysisCenter::GfzUlt, "05M", Some("2100")),
3743        ];
3744        for (center, sample, issue) in cases {
3745            assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
3746        }
3747    }
3748
3749    #[test]
3750    fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
3751        assert_eq!(
3752            offset(
3753                AnalysisCenter::GfzUlt,
3754                date(2021, 5, 15),
3755                "15M",
3756                Some("0000")
3757            ),
3758            -86_400
3759        );
3760        assert_eq!(
3761            offset(
3762                AnalysisCenter::GfzUlt,
3763                date(2021, 5, 16),
3764                "05M",
3765                Some("0000")
3766            ),
3767            -86_400
3768        );
3769    }
3770
3771    #[test]
3772    fn public_content_start_query_enforces_center_issue_rules() {
3773        assert_eq!(
3774            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
3775            Err(DataCatalogError::UnsupportedIssue {
3776                center: AnalysisCenter::GfzUlt,
3777                issue: "0130".to_owned(),
3778            })
3779        );
3780        assert_eq!(
3781            sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
3782            Err(DataCatalogError::UnexpectedIssue {
3783                center: AnalysisCenter::Gfz,
3784            })
3785        );
3786        assert_eq!(
3787            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
3788            Err(DataCatalogError::MissingIssue {
3789                center: AnalysisCenter::GfzUlt,
3790            })
3791        );
3792    }
3793}