Skip to main content

sidereon_core/sp3/
exact.rs

1//! Semantic validation for an exact SP3 product request.
2//!
3//! [`Sp3::parse`] remains a general, permissive SP3 reader. Acquisition code
4//! that promised a particular date, span, and sample interval should additionally
5//! use [`validate_exact_sp3`] (or [`parse_exact_sp3`]) before accepting bytes as
6//! that product.
7
8use core::fmt;
9
10use crate::astro::time::civil::j2000_seconds;
11use crate::data::{AnalysisCenter, DataCatalogError, ProductDate, ProductIdentity, ProductType};
12use crate::tolerances::WHOLE_SECOND_EPS_S;
13
14use super::{Sp3, Sp3DataType, Sp3Version};
15
16/// Maximum legal epoch interval from the SP3-d specification, in seconds.
17/// The interval must be strictly less than this value.
18const SP3_MAX_EPOCH_INTERVAL_S: f64 = 100_000.0;
19/// Modified Julian day containing the J2000 epoch (at 12:00).
20const J2000_MJD_DAY: i64 = 51_544;
21/// Modified Julian day at the GPS week-numbering origin, 1980-01-06 00:00.
22const GPS_ZERO_MJD_DAY: i64 = 44_244;
23const SECONDS_PER_DAY_I64: i64 = 86_400;
24const SECONDS_PER_WEEK_I64: i64 = 604_800;
25
26/// The two regular-grid representations accepted for an exact declared span.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ExactSp3Coverage {
29    /// The boundary is excluded. A 24-hour, five-minute product has 288 epochs,
30    /// ending at 23:55.
31    HalfOpen,
32    /// The boundary is included. A 24-hour, five-minute product has 289 epochs,
33    /// ending at the following midnight.
34    Inclusive,
35}
36
37/// Requested identity fields needed to validate decompressed SP3 content.
38///
39/// This source-independent request is useful to acquisition resolvers whose
40/// candidates are defined by an official archive convention but are not catalog
41/// entries. [`ExactSp3Request::from_identity`] supplies the same values from a
42/// full [`ProductIdentity`].
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExactSp3Request {
45    date: ProductDate,
46    issue: Option<String>,
47    span: String,
48    sample: String,
49    format_version: Option<String>,
50    expected_agency: Option<String>,
51}
52
53impl ExactSp3Request {
54    /// Build and validate a source-independent exact SP3 request.
55    pub fn new(
56        date: ProductDate,
57        issue: Option<&str>,
58        span: &str,
59        sample: &str,
60    ) -> Result<Self, ExactSp3ValidationError> {
61        // ProductDate's fields are public, so revalidate caller-built values.
62        ProductDate::new(date.year, date.month, date.day)
63            .map_err(ExactSp3ValidationError::Catalog)?;
64        parse_issue(issue)?;
65        parse_duration_token(span, DurationField::Span)?;
66        let cadence_s = parse_duration_token(sample, DurationField::Sample)?;
67        if cadence_s as f64 >= SP3_MAX_EPOCH_INTERVAL_S {
68            return Err(ExactSp3ValidationError::UnsupportedSampleToken {
69                token: sample.to_owned(),
70            });
71        }
72
73        Ok(Self {
74            date,
75            issue: issue.map(str::to_owned),
76            span: span.to_owned(),
77            sample: sample.to_owned(),
78            format_version: None,
79            expected_agency: None,
80        })
81    }
82
83    /// Build a request from a complete catalog identity.
84    pub fn from_identity(identity: &ProductIdentity) -> Result<Self, ExactSp3ValidationError> {
85        if identity.family != ProductType::Sp3 {
86            return Err(ExactSp3ValidationError::WrongProductFamily {
87                actual: identity.family,
88            });
89        }
90        identity
91            .validate()
92            .map_err(ExactSp3ValidationError::Catalog)?;
93        let mut request = Self::new(
94            identity.date,
95            identity.issue.as_deref(),
96            &identity.span,
97            &identity.sample,
98        )?;
99        request.format_version = identity.format_version.clone();
100        request.expected_agency = Some(
101            match identity.analysis_center {
102                AnalysisCenter::Igs => "IGS",
103                AnalysisCenter::Esa | AnalysisCenter::EsaUlt => "ESOC",
104                AnalysisCenter::Gfz | AnalysisCenter::GfzUlt => "GFZ",
105                AnalysisCenter::Cod
106                | AnalysisCenter::CodRap
107                | AnalysisCenter::CodPrd1
108                | AnalysisCenter::CodPrd2
109                | AnalysisCenter::CodUlt => "AIUB",
110                AnalysisCenter::IgsUlt => "IGS",
111            }
112            .to_owned(),
113        );
114        Ok(request)
115    }
116
117    /// Require the producing-agency code declared on SP3 header line 1.
118    ///
119    /// Agency codes are one to four upper-case ASCII alphanumeric characters,
120    /// matching the SP3 `A4` field and official IGS producer codes.
121    pub fn with_expected_agency(mut self, agency: &str) -> Result<Self, ExactSp3ValidationError> {
122        if agency.is_empty()
123            || agency.len() > 4
124            || !agency
125                .bytes()
126                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
127        {
128            return Err(ExactSp3ValidationError::InvalidExpectedAgency {
129                agency: agency.to_owned(),
130            });
131        }
132        self.expected_agency = Some(agency.to_owned());
133        Ok(self)
134    }
135
136    /// Requested nominal start date.
137    pub fn date(&self) -> ProductDate {
138        self.date
139    }
140
141    /// Optional requested `HHMM` start/issue token. No issue means midnight.
142    pub fn issue(&self) -> Option<&str> {
143        self.issue.as_deref()
144    }
145
146    /// Requested coverage-period token.
147    pub fn span(&self) -> &str {
148        &self.span
149    }
150
151    /// Requested sample-interval token.
152    pub fn sample(&self) -> &str {
153        &self.sample
154    }
155
156    /// Optional content revision inherited from a resolved product identity.
157    pub fn format_version(&self) -> Option<&str> {
158        self.format_version.as_deref()
159    }
160
161    /// Producing-agency code required from SP3 header line 1, when constrained.
162    pub fn expected_agency(&self) -> Option<&str> {
163        self.expected_agency.as_deref()
164    }
165}
166
167/// Integrity failure while parsing or validating an exact SP3 product.
168#[derive(Debug, Clone, PartialEq)]
169#[non_exhaustive]
170pub enum ExactSp3ValidationError {
171    /// The bytes are not a parseable SP3 product.
172    Parse(crate::Error),
173    /// The full catalog identity is invalid.
174    Catalog(DataCatalogError),
175    /// A non-SP3 identity was supplied to the SP3 validator.
176    WrongProductFamily {
177        /// Supplied family.
178        actual: ProductType,
179    },
180    /// The optional start/issue token is not a valid `HHMM` value.
181    InvalidIssue {
182        /// Supplied token; an absent issue is represented by midnight and is
183        /// never invalid.
184        issue: String,
185    },
186    /// The requested span token is not a supported fixed-duration token.
187    UnsupportedSpanToken {
188        /// Supplied token.
189        token: String,
190    },
191    /// The requested sample token is not a supported positive SP3 interval.
192    UnsupportedSampleToken {
193        /// Supplied token.
194        token: String,
195    },
196    /// A valid fixed-duration span token did not use the longest exact unit.
197    NonCanonicalSpanToken {
198        /// Supplied token.
199        token: String,
200        /// Equivalent canonical token.
201        canonical: String,
202    },
203    /// A valid fixed-duration sample token did not use the longest exact unit.
204    NonCanonicalSampleToken {
205        /// Supplied token.
206        token: String,
207        /// Equivalent canonical token.
208        canonical: String,
209    },
210    /// An expected agency constraint is not a valid SP3 `A4` producer code.
211    InvalidExpectedAgency {
212        /// Supplied agency code.
213        agency: String,
214    },
215    /// Parsed SP3 producing agency differs from the exact request.
216    AgencyMismatch {
217        /// Required agency code.
218        expected: String,
219        /// Parsed header agency code.
220        actual: String,
221    },
222    /// The product omitted its terminal `EOF` record.
223    MissingEof,
224    /// Nonblank records appeared after the terminal `EOF` marker.
225    TrailingContentAfterEof,
226    /// A mandatory SP3 header record count is incomplete or inconsistent.
227    MandatoryHeaderRecordCount {
228        /// Record prefix (`+`, `++`, `%c`, `%f`, or `%i`).
229        record: &'static str,
230        /// Required exact or minimum count.
231        expected: usize,
232        /// Parsed count.
233        actual: usize,
234    },
235    /// The first `+` record does not carry a parseable declared satellite count.
236    MissingDeclaredSatelliteCount,
237    /// The line-3 count differs from the number of non-padding raw declaration
238    /// tokens across all `+` records.
239    DeclaredSatelliteCountMismatch {
240        /// Count from line 3.
241        declared: usize,
242        /// Number of raw non-padding tokens.
243        tokens: usize,
244    },
245    /// A satellite token appears more than once in the header declaration.
246    DuplicateDeclaredSatellite {
247        /// Duplicate raw satellite token.
248        token: String,
249        /// First declaration index.
250        first_index: usize,
251        /// Duplicate declaration index.
252        duplicate_index: usize,
253    },
254    /// The header declares no satellites.
255    NoDeclaredSatellites,
256    /// One epoch's raw P or V records do not exactly match the declared
257    /// satellite count and order.
258    SatelliteRecordSequenceMismatch {
259        /// Record type (`P` or `V`).
260        record: &'static str,
261        /// Zero-based parsed epoch index.
262        epoch_index: usize,
263        /// Raw declared satellite order.
264        expected: Vec<String>,
265        /// Raw record satellite order.
266        actual: Vec<String>,
267    },
268    /// P/V records have the right per-type tokens but not the required body
269    /// order (for a velocity product, `P(sat), V(sat)` pairs).
270    BodyRecordInterleavingMismatch {
271        /// Zero-based parsed epoch index.
272        epoch_index: usize,
273        /// Required tagged record sequence, such as `PG01`, `VG01`.
274        expected: Vec<String>,
275        /// Parsed tagged record sequence.
276        actual: Vec<String>,
277    },
278    /// The SP3 header declares a non-finite epoch interval.
279    NonFiniteHeaderCadence,
280    /// The SP3 header declares a zero or negative epoch interval.
281    NonPositiveHeaderCadence {
282        /// Declared interval.
283        actual_s: f64,
284    },
285    /// The SP3 header interval is outside the range allowed by the format.
286    UnsupportedHeaderCadence {
287        /// Declared interval.
288        actual_s: f64,
289    },
290    /// Header cadence does not equal the exact requested sample interval.
291    CadenceMismatch {
292        /// Cadence derived from the trusted request token.
293        requested_s: f64,
294        /// Cadence declared on SP3 header line 2.
295        header_s: f64,
296    },
297    /// Header line 1's declared number of epochs differs from the parsed grid.
298    DeclaredEpochCountMismatch {
299        /// Count from header line 1.
300        declared: u64,
301        /// Number of parsed epoch records.
302        parsed: usize,
303    },
304    /// Header line 1 does not contain an interpretable start epoch.
305    MissingDeclaredStart,
306    /// Header line 1's start does not equal the exact requested start.
307    DeclaredStartMismatch {
308        /// Exact requested start, seconds since J2000.
309        requested_j2000_s: f64,
310        /// Header line 1 start, seconds since J2000.
311        declared_j2000_s: f64,
312    },
313    /// The requested start predates the GPS week-numbering epoch that SP3 line
314    /// 2 uses.
315    RequestBeforeGpsEpoch,
316    /// A floating-point SP3 line-2 start field is not finite.
317    NonFiniteHeaderStartMetadata {
318        /// SP3 header field name.
319        field: &'static str,
320    },
321    /// An SP3 line-2 start field is outside its specified range.
322    InvalidHeaderStartMetadata {
323        /// SP3 header field name.
324        field: &'static str,
325        /// Parsed field value.
326        actual: f64,
327    },
328    /// SP3 line-2 week/SOW or MJD metadata does not represent the trusted
329    /// requested start.
330    HeaderStartMetadataMismatch {
331        /// Logical SP3 header field (`gps_week`, `seconds_of_week`, or `mjd`).
332        field: &'static str,
333        /// Value derived from the trusted request.
334        requested: f64,
335        /// Parsed header value.
336        actual: f64,
337    },
338    /// The product contains no parsed epoch records.
339    EmptyEpochGrid,
340    /// The first parsed epoch does not equal the exact requested start.
341    FirstEpochMismatch {
342        /// Exact requested start, seconds since J2000.
343        requested_j2000_s: f64,
344        /// First parsed epoch, seconds since J2000.
345        actual_j2000_s: f64,
346    },
347    /// Parsed epochs are not a strictly increasing regular requested-cadence grid.
348    IrregularEpochGrid {
349        /// Index of the later epoch in the failing pair.
350        epoch_index: usize,
351        /// Exact requested cadence.
352        requested_s: f64,
353        /// Difference between this epoch and its predecessor.
354        actual_s: f64,
355    },
356    /// The declared span is not an integer multiple of the requested cadence.
357    SpanNotMultipleOfCadence {
358        /// Requested span.
359        span_s: u64,
360        /// Requested cadence.
361        cadence_s: u64,
362    },
363    /// A regular grid does not represent either the half-open or inclusive
364    /// form of the exact requested span.
365    SpanMismatch {
366        /// Parsed epoch count.
367        parsed: usize,
368        /// Required count for half-open coverage.
369        half_open: usize,
370        /// Required count for inclusive coverage.
371        inclusive: usize,
372    },
373    /// A resolved identity constrained an SP3 content revision that does not
374    /// match the parsed header.
375    FormatVersionMismatch {
376        /// Requested revision string.
377        requested: String,
378        /// Parsed revision string.
379        actual: String,
380    },
381}
382
383impl fmt::Display for ExactSp3ValidationError {
384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385        match self {
386            Self::Parse(error) => write!(f, "exact SP3 parse failed: {error}"),
387            Self::Catalog(error) => write!(f, "invalid exact SP3 identity: {error}"),
388            Self::WrongProductFamily { actual } => {
389                write!(f, "exact SP3 validation cannot validate {actual}")
390            }
391            Self::InvalidIssue { issue } => {
392                write!(f, "invalid exact SP3 issue time {issue:?}; expected HHMM")
393            }
394            Self::UnsupportedSpanToken { token } => {
395                write!(f, "unsupported exact SP3 span token {token:?}")
396            }
397            Self::UnsupportedSampleToken { token } => {
398                write!(f, "unsupported exact SP3 sample token {token:?}")
399            }
400            Self::NonCanonicalSpanToken { token, canonical } => write!(
401                f,
402                "noncanonical exact SP3 span token {token:?}; use {canonical:?}"
403            ),
404            Self::NonCanonicalSampleToken { token, canonical } => write!(
405                f,
406                "noncanonical exact SP3 sample token {token:?}; use {canonical:?}"
407            ),
408            Self::InvalidExpectedAgency { agency } => {
409                write!(f, "invalid exact SP3 expected agency {agency:?}")
410            }
411            Self::AgencyMismatch { expected, actual } => write!(
412                f,
413                "SP3 agency mismatch: requested {expected:?}, header declares {actual:?}"
414            ),
415            Self::MissingEof => write!(f, "SP3 product is missing its EOF record"),
416            Self::TrailingContentAfterEof => {
417                write!(f, "SP3 product contains nonblank records after EOF")
418            }
419            Self::MandatoryHeaderRecordCount {
420                record,
421                expected,
422                actual,
423            } => write!(
424                f,
425                "SP3 header record {record} count is {actual}, expected {expected}"
426            ),
427            Self::MissingDeclaredSatelliteCount => {
428                write!(f, "SP3 line 3 has no valid declared satellite count")
429            }
430            Self::DeclaredSatelliteCountMismatch { declared, tokens } => write!(
431                f,
432                "SP3 declared satellite-count mismatch: line 3 declares {declared}, header contains {tokens} tokens"
433            ),
434            Self::DuplicateDeclaredSatellite {
435                token,
436                first_index,
437                duplicate_index,
438            } => write!(
439                f,
440                "SP3 header satellite {token:?} is duplicated at indices {first_index} and {duplicate_index}"
441            ),
442            Self::NoDeclaredSatellites => {
443                write!(f, "SP3 product declares no satellites")
444            }
445            Self::SatelliteRecordSequenceMismatch {
446                record,
447                epoch_index,
448                expected,
449                actual,
450            } => write!(
451                f,
452                "SP3 epoch {epoch_index} {record}-record sequence mismatch: expected {expected:?}, got {actual:?}"
453            ),
454            Self::BodyRecordInterleavingMismatch {
455                epoch_index,
456                expected,
457                actual,
458            } => write!(
459                f,
460                "SP3 epoch {epoch_index} body record ordering mismatch: expected {expected:?}, got {actual:?}"
461            ),
462            Self::NonFiniteHeaderCadence => {
463                write!(f, "SP3 header cadence is not finite")
464            }
465            Self::NonPositiveHeaderCadence { actual_s } => {
466                write!(f, "SP3 header cadence must be positive, got {actual_s}")
467            }
468            Self::UnsupportedHeaderCadence { actual_s } => write!(
469                f,
470                "SP3 header cadence {actual_s} s is outside the supported format range"
471            ),
472            Self::CadenceMismatch {
473                requested_s,
474                header_s,
475            } => write!(
476                f,
477                "SP3 cadence mismatch: requested {requested_s} s, header declares {header_s} s"
478            ),
479            Self::DeclaredEpochCountMismatch { declared, parsed } => write!(
480                f,
481                "SP3 epoch-count mismatch: header declares {declared}, parsed {parsed}"
482            ),
483            Self::MissingDeclaredStart => {
484                write!(f, "SP3 header line 1 has no valid declared start epoch")
485            }
486            Self::DeclaredStartMismatch {
487                requested_j2000_s,
488                declared_j2000_s,
489            } => write!(
490                f,
491                "SP3 declared start mismatch: requested {requested_j2000_s} J2000 s, header declares {declared_j2000_s} J2000 s"
492            ),
493            Self::RequestBeforeGpsEpoch => {
494                write!(f, "exact SP3 request starts before the GPS week epoch")
495            }
496            Self::NonFiniteHeaderStartMetadata { field } => {
497                write!(f, "SP3 header start field {field} is not finite")
498            }
499            Self::InvalidHeaderStartMetadata { field, actual } => {
500                write!(f, "SP3 header start field {field} is out of range: {actual}")
501            }
502            Self::HeaderStartMetadataMismatch {
503                field,
504                requested,
505                actual,
506            } => write!(
507                f,
508                "SP3 header start mismatch in {field}: requested {requested}, header has {actual}"
509            ),
510            Self::EmptyEpochGrid => write!(f, "SP3 product has no epoch records"),
511            Self::FirstEpochMismatch {
512                requested_j2000_s,
513                actual_j2000_s,
514            } => write!(
515                f,
516                "SP3 first epoch mismatch: requested {requested_j2000_s} J2000 s, parsed {actual_j2000_s} J2000 s"
517            ),
518            Self::IrregularEpochGrid {
519                epoch_index,
520                requested_s,
521                actual_s,
522            } => write!(
523                f,
524                "SP3 epoch grid is irregular at index {epoch_index}: requested step {requested_s} s, got {actual_s} s"
525            ),
526            Self::SpanNotMultipleOfCadence {
527                span_s,
528                cadence_s,
529            } => write!(
530                f,
531                "exact SP3 span {span_s} s is not a multiple of cadence {cadence_s} s"
532            ),
533            Self::SpanMismatch {
534                parsed,
535                half_open,
536                inclusive,
537            } => write!(
538                f,
539                "SP3 span mismatch: parsed {parsed} epochs, expected {half_open} half-open or {inclusive} inclusive"
540            ),
541            Self::FormatVersionMismatch { requested, actual } => write!(
542                f,
543                "SP3 format-version mismatch: requested {requested:?}, parsed {actual:?}"
544            ),
545        }
546    }
547}
548
549impl std::error::Error for ExactSp3ValidationError {
550    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
551        match self {
552            Self::Parse(error) => Some(error),
553            Self::Catalog(error) => Some(error),
554            _ => None,
555        }
556    }
557}
558
559/// Parse and semantically validate bytes for one exact SP3 request.
560///
561/// The returned coverage value states which of the two supported boundary
562/// representations was present.
563pub fn parse_exact_sp3(
564    bytes: &[u8],
565    request: &ExactSp3Request,
566) -> Result<(Sp3, ExactSp3Coverage), ExactSp3ValidationError> {
567    let product = Sp3::parse(bytes).map_err(ExactSp3ValidationError::Parse)?;
568    let coverage = validate_exact_sp3(&product, request)?;
569    Ok((product, coverage))
570}
571
572/// Validate a parsed product against an exact date, span, and sample request.
573///
574/// Durations and permitted epoch counts are derived from the validated request
575/// tokens. Header cadence is checked independently and never becomes the source
576/// of truth for the allowed coverage.
577pub fn validate_exact_sp3(
578    product: &Sp3,
579    request: &ExactSp3Request,
580) -> Result<ExactSp3Coverage, ExactSp3ValidationError> {
581    let cadence_s = parse_duration_token(&request.sample, DurationField::Sample)?;
582    let span_s = parse_duration_token(&request.span, DurationField::Span)?;
583
584    validate_mandatory_structure(product)?;
585    if let Some(expected) = request.expected_agency.as_deref() {
586        let actual = product.header.agency.trim();
587        if actual != expected {
588            return Err(ExactSp3ValidationError::AgencyMismatch {
589                expected: expected.to_owned(),
590                actual: actual.to_owned(),
591            });
592        }
593    }
594
595    let header_cadence_s = product.header.epoch_interval_s;
596    if !header_cadence_s.is_finite() {
597        return Err(ExactSp3ValidationError::NonFiniteHeaderCadence);
598    }
599    if header_cadence_s <= 0.0 {
600        return Err(ExactSp3ValidationError::NonPositiveHeaderCadence {
601            actual_s: header_cadence_s,
602        });
603    }
604    if header_cadence_s >= SP3_MAX_EPOCH_INTERVAL_S {
605        return Err(ExactSp3ValidationError::UnsupportedHeaderCadence {
606            actual_s: header_cadence_s,
607        });
608    }
609    if !seconds_match(header_cadence_s, cadence_s as f64) {
610        return Err(ExactSp3ValidationError::CadenceMismatch {
611            requested_s: cadence_s as f64,
612            header_s: header_cadence_s,
613        });
614    }
615
616    if product.declared_num_epochs != product.epoch_j2000_s.len() as u64 {
617        return Err(ExactSp3ValidationError::DeclaredEpochCountMismatch {
618            declared: product.declared_num_epochs,
619            parsed: product.epoch_j2000_s.len(),
620        });
621    }
622
623    let requested_start_j2000_s = requested_start_j2000_s(request);
624    let declared_start_j2000_s = product
625        .declared_start_j2000_s
626        .ok_or(ExactSp3ValidationError::MissingDeclaredStart)?;
627    if !seconds_match(declared_start_j2000_s, requested_start_j2000_s) {
628        return Err(ExactSp3ValidationError::DeclaredStartMismatch {
629            requested_j2000_s: requested_start_j2000_s,
630            declared_j2000_s: declared_start_j2000_s,
631        });
632    }
633    validate_line2_start_metadata(product, requested_start_j2000_s)?;
634
635    let first_j2000_s = product
636        .epoch_j2000_s
637        .first()
638        .copied()
639        .ok_or(ExactSp3ValidationError::EmptyEpochGrid)?;
640    if !seconds_match(first_j2000_s, requested_start_j2000_s) {
641        return Err(ExactSp3ValidationError::FirstEpochMismatch {
642            requested_j2000_s: requested_start_j2000_s,
643            actual_j2000_s: first_j2000_s,
644        });
645    }
646
647    for (index, pair) in product.epoch_j2000_s.windows(2).enumerate() {
648        let actual_s = pair[1] - pair[0];
649        if !actual_s.is_finite() || !seconds_match(actual_s, cadence_s as f64) {
650            return Err(ExactSp3ValidationError::IrregularEpochGrid {
651                epoch_index: index + 1,
652                requested_s: cadence_s as f64,
653                actual_s,
654            });
655        }
656    }
657
658    if span_s % cadence_s != 0 {
659        return Err(ExactSp3ValidationError::SpanNotMultipleOfCadence { span_s, cadence_s });
660    }
661    let half_open = usize::try_from(span_s / cadence_s).unwrap_or(usize::MAX);
662    let inclusive = half_open.saturating_add(1);
663    match product.epoch_j2000_s.len() {
664        count if count == half_open => Ok(ExactSp3Coverage::HalfOpen),
665        count if count == inclusive => Ok(ExactSp3Coverage::Inclusive),
666        parsed => Err(ExactSp3ValidationError::SpanMismatch {
667            parsed,
668            half_open,
669            inclusive,
670        }),
671    }
672    .and_then(|coverage| {
673        validate_format_version(product.header.version, request.format_version.as_deref())?;
674        Ok(coverage)
675    })
676}
677
678fn validate_mandatory_structure(product: &Sp3) -> Result<(), ExactSp3ValidationError> {
679    if !product.had_eof {
680        return Err(ExactSp3ValidationError::MissingEof);
681    }
682    if product.trailing_content_after_eof {
683        return Err(ExactSp3ValidationError::TrailingContentAfterEof);
684    }
685    if product.satellite_header_lines < 5 {
686        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
687            record: "+",
688            expected: 5,
689            actual: product.satellite_header_lines,
690        });
691    }
692    if product.accuracy_header_lines != product.satellite_header_lines {
693        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
694            record: "++",
695            expected: product.satellite_header_lines,
696            actual: product.accuracy_header_lines,
697        });
698    }
699    for (record, actual) in [
700        ("%c", product.time_system_header_lines),
701        ("%f", product.float_header_lines),
702        ("%i", product.integer_header_lines),
703    ] {
704        if actual != 2 {
705            return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
706                record,
707                expected: 2,
708                actual,
709            });
710        }
711    }
712    if product.header_comment_lines < 4 {
713        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
714            record: "/*",
715            expected: 4,
716            actual: product.header_comment_lines,
717        });
718    }
719    let declared_count = product
720        .declared_satellite_count
721        .ok_or(ExactSp3ValidationError::MissingDeclaredSatelliteCount)?;
722    if declared_count != product.declared_satellite_tokens.len() {
723        return Err(ExactSp3ValidationError::DeclaredSatelliteCountMismatch {
724            declared: declared_count,
725            tokens: product.declared_satellite_tokens.len(),
726        });
727    }
728    for duplicate_index in 0..product.declared_satellite_tokens.len() {
729        if let Some(first_index) = product.declared_satellite_tokens[..duplicate_index]
730            .iter()
731            .position(|token| token == &product.declared_satellite_tokens[duplicate_index])
732        {
733            return Err(ExactSp3ValidationError::DuplicateDeclaredSatellite {
734                token: product.declared_satellite_tokens[duplicate_index].clone(),
735                first_index,
736                duplicate_index,
737            });
738        }
739    }
740    if product.header.satellites.is_empty() {
741        return Err(ExactSp3ValidationError::NoDeclaredSatellites);
742    }
743    for epoch_index in 0..product.epochs.len() {
744        let positions = product
745            .epoch_position_tokens
746            .get(epoch_index)
747            .cloned()
748            .unwrap_or_default();
749        if positions != product.declared_satellite_tokens {
750            return Err(ExactSp3ValidationError::SatelliteRecordSequenceMismatch {
751                record: "P",
752                epoch_index,
753                expected: product.declared_satellite_tokens.clone(),
754                actual: positions,
755            });
756        }
757        let velocities = product
758            .epoch_velocity_tokens
759            .get(epoch_index)
760            .cloned()
761            .unwrap_or_default();
762        let expected_velocities = match product.header.data_type {
763            Sp3DataType::Position => Vec::new(),
764            Sp3DataType::Velocity => product.declared_satellite_tokens.clone(),
765        };
766        if velocities != expected_velocities {
767            return Err(ExactSp3ValidationError::SatelliteRecordSequenceMismatch {
768                record: "V",
769                epoch_index,
770                expected: expected_velocities,
771                actual: velocities,
772            });
773        }
774        let mut expected_body = Vec::with_capacity(match product.header.data_type {
775            Sp3DataType::Position => product.declared_satellite_tokens.len(),
776            Sp3DataType::Velocity => product.declared_satellite_tokens.len() * 2,
777        });
778        for token in &product.declared_satellite_tokens {
779            expected_body.push(format!("P{token}"));
780            if matches!(product.header.data_type, Sp3DataType::Velocity) {
781                expected_body.push(format!("V{token}"));
782            }
783        }
784        let actual_body = product
785            .epoch_state_record_sequence
786            .get(epoch_index)
787            .map(|records| {
788                records
789                    .iter()
790                    .map(|(record, token)| format!("{record}{token}"))
791                    .collect::<Vec<_>>()
792            })
793            .unwrap_or_default();
794        if actual_body != expected_body {
795            return Err(ExactSp3ValidationError::BodyRecordInterleavingMismatch {
796                epoch_index,
797                expected: expected_body,
798                actual: actual_body,
799            });
800        }
801    }
802    Ok(())
803}
804
805#[derive(Debug, Clone, Copy)]
806enum DurationField {
807    Span,
808    Sample,
809}
810
811fn parse_duration_token(token: &str, field: DurationField) -> Result<u64, ExactSp3ValidationError> {
812    let bytes = token.as_bytes();
813    let invalid = || match field {
814        DurationField::Span => ExactSp3ValidationError::UnsupportedSpanToken {
815            token: token.to_owned(),
816        },
817        DurationField::Sample => ExactSp3ValidationError::UnsupportedSampleToken {
818            token: token.to_owned(),
819        },
820    };
821    if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
822        return Err(invalid());
823    }
824    let amount = u64::from(bytes[0] - b'0') * 10 + u64::from(bytes[1] - b'0');
825    if amount == 0 {
826        return Err(invalid());
827    }
828    let unit_s = match bytes[2] {
829        b'S' => 1,
830        b'M' => 60,
831        b'H' => 3_600,
832        b'D' => 86_400,
833        b'W' if matches!(field, DurationField::Span) => 604_800,
834        // Month/year duration depends on the start calendar and `U` has no
835        // declared duration; neither is an exact fixed interval here.
836        _ => return Err(invalid()),
837    };
838    let canonical = match bytes[2] {
839        b'S' if amount % 60 == 0 => Some(format!("{:02}M", amount / 60)),
840        b'M' if amount % 60 == 0 => Some(format!("{:02}H", amount / 60)),
841        b'H' if amount % 24 == 0 => Some(format!("{:02}D", amount / 24)),
842        _ => None,
843    };
844    if let Some(canonical) = canonical {
845        return Err(match field {
846            DurationField::Span => ExactSp3ValidationError::NonCanonicalSpanToken {
847                token: token.to_owned(),
848                canonical,
849            },
850            DurationField::Sample => ExactSp3ValidationError::NonCanonicalSampleToken {
851                token: token.to_owned(),
852                canonical,
853            },
854        });
855    }
856    amount.checked_mul(unit_s).ok_or_else(invalid)
857}
858
859fn parse_issue(issue: Option<&str>) -> Result<(u8, u8), ExactSp3ValidationError> {
860    let Some(issue) = issue else {
861        return Ok((0, 0));
862    };
863    let bytes = issue.as_bytes();
864    if bytes.len() != 4 || !bytes.iter().all(u8::is_ascii_digit) {
865        return Err(ExactSp3ValidationError::InvalidIssue {
866            issue: issue.to_owned(),
867        });
868    }
869    let hour = (bytes[0] - b'0') * 10 + (bytes[1] - b'0');
870    let minute = (bytes[2] - b'0') * 10 + (bytes[3] - b'0');
871    if hour > 23 || minute > 59 {
872        return Err(ExactSp3ValidationError::InvalidIssue {
873            issue: issue.to_owned(),
874        });
875    }
876    Ok((hour, minute))
877}
878
879fn requested_start_j2000_s(request: &ExactSp3Request) -> f64 {
880    let (hour, minute) = parse_issue(request.issue.as_deref())
881        .expect("ExactSp3Request construction validates its issue token");
882    j2000_seconds(
883        request.date.year,
884        i32::from(request.date.month),
885        i32::from(request.date.day),
886        i32::from(hour),
887        i32::from(minute),
888        0.0,
889    )
890}
891
892/// Cross-check SP3 line 2 against the trusted civil start.
893///
894/// SP3-d states that all time fields in a file use the `%c` time system, even
895/// Gregorian and Modified Julian representations. Therefore this is coordinate
896/// arithmetic in the product's declared time system; applying a GPS/UTC leap
897/// conversion between line 1, line 2, and epoch records would be incorrect.
898fn validate_line2_start_metadata(
899    product: &Sp3,
900    requested_start_j2000_s: f64,
901) -> Result<(), ExactSp3ValidationError> {
902    // ExactSp3Request starts on a whole minute, so this conversion is exact over
903    // its supported civil-year range.
904    let requested_start_j2000_s = requested_start_j2000_s as i64;
905    let requested_mjd_total_s =
906        requested_start_j2000_s + J2000_MJD_DAY * SECONDS_PER_DAY_I64 + SECONDS_PER_DAY_I64 / 2;
907    let gps_zero_mjd_total_s = GPS_ZERO_MJD_DAY * SECONDS_PER_DAY_I64;
908    let since_gps_zero_s = requested_mjd_total_s - gps_zero_mjd_total_s;
909    if since_gps_zero_s < 0 {
910        return Err(ExactSp3ValidationError::RequestBeforeGpsEpoch);
911    }
912
913    let requested_week = since_gps_zero_s.div_euclid(SECONDS_PER_WEEK_I64);
914    let requested_sow_s = since_gps_zero_s.rem_euclid(SECONDS_PER_WEEK_I64) as f64;
915    if i64::from(product.header.gnss_week) != requested_week {
916        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
917            field: "gps_week",
918            requested: requested_week as f64,
919            actual: f64::from(product.header.gnss_week),
920        });
921    }
922
923    let header_sow_s = product.header.seconds_of_week;
924    if !header_sow_s.is_finite() {
925        return Err(ExactSp3ValidationError::NonFiniteHeaderStartMetadata {
926            field: "seconds_of_week",
927        });
928    }
929    if !(0.0..SECONDS_PER_WEEK_I64 as f64).contains(&header_sow_s) {
930        return Err(ExactSp3ValidationError::InvalidHeaderStartMetadata {
931            field: "seconds_of_week",
932            actual: header_sow_s,
933        });
934    }
935    if !seconds_match(header_sow_s, requested_sow_s) {
936        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
937            field: "seconds_of_week",
938            requested: requested_sow_s,
939            actual: header_sow_s,
940        });
941    }
942
943    let header_mjd_fraction = product.header.mjd_fraction;
944    if !header_mjd_fraction.is_finite() {
945        return Err(ExactSp3ValidationError::NonFiniteHeaderStartMetadata {
946            field: "mjd_fraction",
947        });
948    }
949    if !(0.0..1.0).contains(&header_mjd_fraction) {
950        return Err(ExactSp3ValidationError::InvalidHeaderStartMetadata {
951            field: "mjd_fraction",
952            actual: header_mjd_fraction,
953        });
954    }
955    let header_mjd_total_s =
956        i64::from(product.header.mjd) as f64 * 86_400.0 + header_mjd_fraction * 86_400.0;
957    if !seconds_match(header_mjd_total_s, requested_mjd_total_s as f64) {
958        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
959            field: "mjd",
960            requested: requested_mjd_total_s as f64 / 86_400.0,
961            actual: header_mjd_total_s / 86_400.0,
962        });
963    }
964    Ok(())
965}
966
967fn seconds_match(left: f64, right: f64) -> bool {
968    (left - right).abs() <= WHOLE_SECOND_EPS_S
969}
970
971fn validate_format_version(
972    actual: Sp3Version,
973    requested: Option<&str>,
974) -> Result<(), ExactSp3ValidationError> {
975    let Some(requested) = requested else {
976        return Ok(());
977    };
978    let actual = match actual {
979        Sp3Version::A => "SP3-a",
980        Sp3Version::B => "SP3-b",
981        Sp3Version::C => "SP3-c",
982        Sp3Version::D => "SP3-d",
983    };
984    if requested.eq_ignore_ascii_case(actual) {
985        Ok(())
986    } else {
987        Err(ExactSp3ValidationError::FormatVersionMismatch {
988            requested: requested.to_owned(),
989            actual: actual.to_owned(),
990        })
991    }
992}