Skip to main content

sidereon_core/
validate.rs

1//! Shared validation helpers for parser and solver boundaries.
2//!
3//! These functions keep malformed text, non-finite numbers, and invalid civil
4//! timestamps out of typed product records and public solver entry points. The
5//! computational kernels stay focused on their numerical recipes; callers map
6//! [`FieldError`] into their local error enums at the boundary.
7
8#![allow(dead_code)]
9
10use core::str::FromStr;
11
12use crate::id::GnssSatelliteId;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15#[error("{field} length is {actual}, expected {expected}")]
16pub(crate) struct LengthError {
17    pub(crate) field: &'static str,
18    pub(crate) expected: usize,
19    pub(crate) actual: usize,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
23#[error("{field} integer arithmetic overflow")]
24pub(crate) struct ArithmeticError {
25    pub(crate) field: &'static str,
26}
27
28#[derive(Debug, Clone, PartialEq, thiserror::Error)]
29pub enum FieldError {
30    #[error("{field} is missing")]
31    Missing { field: &'static str },
32    #[error("{field} is not a finite number")]
33    NonFinite { field: &'static str },
34    #[error("{field} must be positive")]
35    NotPositive { field: &'static str },
36    #[error("{field} must be non-negative")]
37    Negative { field: &'static str },
38    #[error("{field} is out of range")]
39    OutOfRange {
40        field: &'static str,
41        min: f64,
42        max: f64,
43        upper_inclusive: bool,
44    },
45    #[error("{field} is not a valid float: {value:?}")]
46    FloatParse { field: &'static str, value: String },
47    #[error("{field} is not a valid integer: {value:?}")]
48    IntParse { field: &'static str, value: String },
49    #[error("{field} is not a valid civil date: {year:04}-{month:02}-{day:02}")]
50    InvalidCivilDate {
51        field: &'static str,
52        year: i64,
53        month: i64,
54        day: i64,
55    },
56    #[error("{field} is not a valid civil time: {hour:02}:{minute:02}:{second}")]
57    InvalidCivilTime {
58        field: &'static str,
59        hour: i64,
60        minute: i64,
61        second: f64,
62    },
63}
64
65impl FieldError {
66    pub const fn field(&self) -> &'static str {
67        match self {
68            Self::Missing { field }
69            | Self::NonFinite { field }
70            | Self::NotPositive { field }
71            | Self::Negative { field }
72            | Self::OutOfRange { field, .. }
73            | Self::FloatParse { field, .. }
74            | Self::IntParse { field, .. }
75            | Self::InvalidCivilDate { field, .. }
76            | Self::InvalidCivilTime { field, .. } => field,
77        }
78    }
79
80    pub const fn reason(&self) -> &'static str {
81        match self {
82            Self::Missing { .. } => "missing",
83            Self::NonFinite { .. } => "not finite",
84            Self::NotPositive { .. } => "not positive",
85            Self::Negative { .. } => "negative",
86            Self::OutOfRange { .. } => "out of range",
87            Self::FloatParse { .. } => "invalid float",
88            Self::IntParse { .. } => "invalid integer",
89            Self::InvalidCivilDate { .. } => "invalid civil date",
90            Self::InvalidCivilTime { .. } => "invalid civil time",
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub(crate) struct ValidCivil {
97    pub(crate) year: i64,
98    pub(crate) month: u32,
99    pub(crate) day: u32,
100    pub(crate) hour: u32,
101    pub(crate) minute: u32,
102    pub(crate) second: f64,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub(crate) struct ValidCivilMicrosecond {
107    pub(crate) year: i64,
108    pub(crate) month: u32,
109    pub(crate) day: u32,
110    pub(crate) hour: u32,
111    pub(crate) minute: u32,
112    pub(crate) second: u32,
113    pub(crate) microsecond: u32,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub(crate) enum CivilSecondPolicy {
118    /// UTC-like labels, including GLONASS UTC, permit a `:60` leap-second label.
119    UtcLike,
120    /// Continuous system times such as GPS, Galileo, BeiDou, QZSS, IRNSS, TAI,
121    /// TT, and TDB do not carry civil leap-second labels.
122    Continuous,
123}
124
125#[derive(Debug, Clone, Copy)]
126struct CivilMinute {
127    year: i64,
128    month: i64,
129    day: i64,
130    hour: i64,
131    minute: i64,
132}
133
134const CIVIL_YEAR_MIN: i64 = 0;
135const CIVIL_YEAR_MAX: i64 = 9999;
136
137impl CivilSecondPolicy {
138    const fn allows_leap_second_label(self) -> bool {
139        match self {
140            Self::UtcLike => true,
141            Self::Continuous => false,
142        }
143    }
144}
145
146pub(crate) fn finite(x: f64, field: &'static str) -> Result<f64, FieldError> {
147    if x.is_finite() {
148        Ok(x)
149    } else {
150        Err(FieldError::NonFinite { field })
151    }
152}
153
154pub(crate) fn finite_positive(x: f64, field: &'static str) -> Result<f64, FieldError> {
155    finite(x, field).and_then(|x| {
156        if x > 0.0 {
157            Ok(x)
158        } else {
159            Err(FieldError::NotPositive { field })
160        }
161    })
162}
163
164pub(crate) fn finite_nonneg(x: f64, field: &'static str) -> Result<f64, FieldError> {
165    finite(x, field).and_then(|x| {
166        if x >= 0.0 {
167            Ok(x)
168        } else {
169            Err(FieldError::Negative { field })
170        }
171    })
172}
173
174pub(crate) fn finite_in_range(
175    x: f64,
176    min: f64,
177    max: f64,
178    field: &'static str,
179) -> Result<f64, FieldError> {
180    finite_in_range_impl(x, min, max, true, field)
181}
182
183pub(crate) fn finite_in_range_exclusive_upper(
184    x: f64,
185    min: f64,
186    max: f64,
187    field: &'static str,
188) -> Result<f64, FieldError> {
189    finite_in_range_impl(x, min, max, false, field)
190}
191
192pub(crate) fn fraction(x: f64, field: &'static str) -> Result<f64, FieldError> {
193    finite_in_range(x, 0.0, 1.0, field)
194}
195
196pub(crate) fn second_of_day(x: f64, field: &'static str) -> Result<f64, FieldError> {
197    finite_in_range_exclusive_upper(x, 0.0, crate::constants::SECONDS_PER_DAY, field)
198}
199
200pub(crate) fn positive_step(x: f64, field: &'static str) -> Result<f64, FieldError> {
201    finite_positive(x, field)
202}
203
204pub(crate) fn range_order(lo: f64, hi: f64, field: &'static str) -> Result<(), FieldError> {
205    if lo <= hi {
206        Ok(())
207    } else {
208        Err(FieldError::OutOfRange {
209            field,
210            min: lo,
211            max: hi,
212            upper_inclusive: true,
213        })
214    }
215}
216
217pub(crate) fn clamp_magnitude(x: f64, max_magnitude: f64) -> f64 {
218    debug_assert!(max_magnitude.is_finite());
219    debug_assert!(max_magnitude > 0.0);
220
221    x.clamp(-max_magnitude, max_magnitude)
222}
223
224fn finite_in_range_impl(
225    x: f64,
226    min: f64,
227    max: f64,
228    upper_inclusive: bool,
229    field: &'static str,
230) -> Result<f64, FieldError> {
231    debug_assert!(min.is_finite());
232    debug_assert!(max.is_finite());
233    debug_assert!(min <= max);
234
235    let x = finite(x, field)?;
236    let upper_ok = if upper_inclusive { x <= max } else { x < max };
237    if x >= min && upper_ok {
238        Ok(x)
239    } else {
240        Err(FieldError::OutOfRange {
241            field,
242            min,
243            max,
244            upper_inclusive,
245        })
246    }
247}
248
249pub(crate) fn finite_vec3(v: [f64; 3], field: &'static str) -> Result<[f64; 3], FieldError> {
250    finite_slice(&v, field).map(|()| v)
251}
252
253pub(crate) fn finite_slice(xs: &[f64], field: &'static str) -> Result<(), FieldError> {
254    if xs.iter().all(|x| x.is_finite()) {
255        Ok(())
256    } else {
257        Err(FieldError::NonFinite { field })
258    }
259}
260
261pub(crate) fn require_strictly_increasing<I>(
262    values: I,
263    field: &'static str,
264) -> Result<(), FieldError>
265where
266    I: IntoIterator<Item = f64>,
267{
268    let mut previous = None;
269    for value in values {
270        finite(value, field)?;
271        if let Some(previous) = previous {
272            if value <= previous {
273                return Err(FieldError::OutOfRange {
274                    field,
275                    min: previous,
276                    max: value,
277                    upper_inclusive: false,
278                });
279            }
280        }
281        previous = Some(value);
282    }
283    Ok(())
284}
285
286#[allow(clippy::needless_range_loop)]
287pub(crate) fn validate_covariance_psd<const N: usize>(
288    m: &[[f64; N]; N],
289    field: &'static str,
290) -> Result<(), FieldError> {
291    for row in m {
292        finite_slice(row, field)?;
293    }
294
295    let scale = matrix_scale(m);
296    let tol = covariance_matrix_tolerance(N, scale);
297    for i in 0..N {
298        for j in (i + 1)..N {
299            if (m[i][j] - m[j][i]).abs() > tol {
300                return Err(FieldError::NotPositive { field });
301            }
302        }
303    }
304
305    let mut symmetric_part = [[0.0_f64; N]; N];
306    for i in 0..N {
307        symmetric_part[i][i] = m[i][i];
308        for j in (i + 1)..N {
309            let value = 0.5 * (m[i][j] + m[j][i]);
310            symmetric_part[i][j] = value;
311            symmetric_part[j][i] = value;
312        }
313    }
314
315    if symmetric_min_eigenvalue(&mut symmetric_part, tol) < -tol {
316        return Err(FieldError::NotPositive { field });
317    }
318
319    Ok(())
320}
321
322#[allow(clippy::needless_range_loop)]
323pub(crate) fn validate_covariance_psd_rows(
324    rows: &[&[f64]],
325    field: &'static str,
326) -> Result<(), FieldError> {
327    let n = rows.len();
328    for row in rows {
329        finite_slice(row, field)?;
330        debug_assert_eq!(row.len(), n);
331    }
332
333    let scale = matrix_rows_scale(rows);
334    let tol = covariance_matrix_tolerance(n, scale);
335    for i in 0..n {
336        for j in (i + 1)..n {
337            if (rows[i][j] - rows[j][i]).abs() > tol {
338                return Err(FieldError::NotPositive { field });
339            }
340        }
341    }
342
343    let mut symmetric_part = vec![vec![0.0_f64; n]; n];
344    for i in 0..n {
345        symmetric_part[i][i] = rows[i][i];
346        for j in (i + 1)..n {
347            let value = 0.5 * (rows[i][j] + rows[j][i]);
348            symmetric_part[i][j] = value;
349            symmetric_part[j][i] = value;
350        }
351    }
352
353    if symmetric_rows_min_eigenvalue(&mut symmetric_part, tol) < -tol {
354        return Err(FieldError::NotPositive { field });
355    }
356
357    Ok(())
358}
359
360fn matrix_scale<const N: usize>(m: &[[f64; N]; N]) -> f64 {
361    let mut scale = 1.0_f64;
362    for row in m {
363        for value in row {
364            scale = scale.max(value.abs());
365        }
366    }
367    scale
368}
369
370fn matrix_rows_scale(rows: &[&[f64]]) -> f64 {
371    let mut scale = 1.0_f64;
372    for row in rows {
373        for value in *row {
374            scale = scale.max(value.abs());
375        }
376    }
377    scale
378}
379
380fn covariance_matrix_tolerance(n: usize, scale: f64) -> f64 {
381    let scale = scale.max(1.0);
382    (128.0 * f64::EPSILON * (n.max(1) as f64) * scale).max(1.0e-9 * scale)
383}
384
385#[allow(clippy::needless_range_loop)]
386fn symmetric_min_eigenvalue<const N: usize>(a: &mut [[f64; N]; N], tol: f64) -> f64 {
387    let max_sweeps = (16 * N * N).max(32);
388    for _ in 0..max_sweeps {
389        let mut p = 0usize;
390        let mut q = 0usize;
391        let mut max_offdiag = 0.0_f64;
392        for i in 0..N {
393            for j in (i + 1)..N {
394                let offdiag = a[i][j].abs();
395                if offdiag > max_offdiag {
396                    max_offdiag = offdiag;
397                    p = i;
398                    q = j;
399                }
400            }
401        }
402
403        if max_offdiag <= tol {
404            break;
405        }
406
407        let app = a[p][p];
408        let aqq = a[q][q];
409        let apq = a[p][q];
410        if apq == 0.0 {
411            break;
412        }
413
414        let tau = (aqq - app) / (2.0 * apq);
415        let t = if tau >= 0.0 {
416            1.0 / (tau + (1.0 + tau * tau).sqrt())
417        } else {
418            -1.0 / (-tau + (1.0 + tau * tau).sqrt())
419        };
420        let c = 1.0 / (1.0 + t * t).sqrt();
421        let s = t * c;
422
423        for k in 0..N {
424            if k != p && k != q {
425                let akp = a[k][p];
426                let akq = a[k][q];
427                let new_kp = c * akp - s * akq;
428                let new_kq = s * akp + c * akq;
429                a[k][p] = new_kp;
430                a[p][k] = new_kp;
431                a[k][q] = new_kq;
432                a[q][k] = new_kq;
433            }
434        }
435
436        a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
437        a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
438        a[p][q] = 0.0;
439        a[q][p] = 0.0;
440    }
441
442    let mut min = f64::INFINITY;
443    for (i, row) in a.iter().enumerate() {
444        min = min.min(row[i]);
445    }
446    min
447}
448
449#[allow(clippy::needless_range_loop)]
450fn symmetric_rows_min_eigenvalue(a: &mut [Vec<f64>], tol: f64) -> f64 {
451    let n = a.len();
452    let max_sweeps = (16 * n * n).max(32);
453    for _ in 0..max_sweeps {
454        let mut p = 0usize;
455        let mut q = 0usize;
456        let mut max_offdiag = 0.0_f64;
457        for (i, row) in a.iter().enumerate() {
458            for (j, value) in row.iter().enumerate().skip(i + 1) {
459                let offdiag = value.abs();
460                if offdiag > max_offdiag {
461                    max_offdiag = offdiag;
462                    p = i;
463                    q = j;
464                }
465            }
466        }
467
468        if max_offdiag <= tol {
469            break;
470        }
471
472        let app = a[p][p];
473        let aqq = a[q][q];
474        let apq = a[p][q];
475        if apq == 0.0 {
476            break;
477        }
478
479        let tau = (aqq - app) / (2.0 * apq);
480        let t = if tau >= 0.0 {
481            1.0 / (tau + (1.0 + tau * tau).sqrt())
482        } else {
483            -1.0 / (-tau + (1.0 + tau * tau).sqrt())
484        };
485        let c = 1.0 / (1.0 + t * t).sqrt();
486        let s = t * c;
487
488        for k in 0..n {
489            if k != p && k != q {
490                let akp = a[k][p];
491                let akq = a[k][q];
492                let new_kp = c * akp - s * akq;
493                let new_kq = s * akp + c * akq;
494                a[k][p] = new_kp;
495                a[p][k] = new_kp;
496                a[k][q] = new_kq;
497                a[q][k] = new_kq;
498            }
499        }
500
501        a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
502        a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
503        a[p][q] = 0.0;
504        a[q][p] = 0.0;
505    }
506
507    let mut min = f64::INFINITY;
508    for (i, row) in a.iter().enumerate() {
509        min = min.min(row[i]);
510    }
511    min
512}
513
514pub(crate) fn present<T>(value: Option<T>, field: &'static str) -> Result<T, FieldError> {
515    value.ok_or(FieldError::Missing { field })
516}
517
518pub(crate) fn exact_len<T>(
519    xs: &[T],
520    expected: usize,
521    field: &'static str,
522) -> Result<(), LengthError> {
523    if xs.len() == expected {
524        Ok(())
525    } else {
526        Err(LengthError {
527            field,
528            expected,
529            actual: xs.len(),
530        })
531    }
532}
533
534pub(crate) fn checked_i64_add(
535    lhs: i64,
536    rhs: i64,
537    field: &'static str,
538) -> Result<i64, ArithmeticError> {
539    lhs.checked_add(rhs).ok_or(ArithmeticError { field })
540}
541
542pub(crate) fn checked_i64_sub(
543    lhs: i64,
544    rhs: i64,
545    field: &'static str,
546) -> Result<i64, ArithmeticError> {
547    lhs.checked_sub(rhs).ok_or(ArithmeticError { field })
548}
549
550pub(crate) fn checked_i64_mul(
551    lhs: i64,
552    rhs: i64,
553    field: &'static str,
554) -> Result<i64, ArithmeticError> {
555    lhs.checked_mul(rhs).ok_or(ArithmeticError { field })
556}
557
558pub(crate) fn strict_f64(s: &str, field: &'static str) -> Result<f64, FieldError> {
559    let value = s.trim();
560    if value.is_empty() {
561        return Err(FieldError::Missing { field });
562    }
563    let normalized = value.replace(['D', 'd'], "e");
564    let parsed = normalized
565        .parse::<f64>()
566        .map_err(|_| FieldError::FloatParse {
567            field,
568            value: value.to_string(),
569        })?;
570    finite(parsed, field)
571}
572
573pub(crate) fn strict_int<T>(s: &str, field: &'static str) -> Result<T, FieldError>
574where
575    T: FromStr,
576{
577    let value = s.trim();
578    if value.is_empty() {
579        return Err(FieldError::Missing { field });
580    }
581    value.parse::<T>().map_err(|_| FieldError::IntParse {
582        field,
583        value: value.to_string(),
584    })
585}
586
587pub(crate) fn strict_gnss_satellite_id(
588    s: &str,
589    field: &'static str,
590) -> Result<GnssSatelliteId, FieldError> {
591    let value = s.trim();
592    if value.is_empty() {
593        return Err(FieldError::Missing { field });
594    }
595    value
596        .parse::<GnssSatelliteId>()
597        .map_err(|_| FieldError::IntParse {
598            field,
599            value: value.to_string(),
600        })
601}
602
603pub(crate) fn civil_datetime_with_second_policy(
604    year: i64,
605    month: i64,
606    day: i64,
607    hour: i64,
608    minute: i64,
609    second: f64,
610    second_policy: CivilSecondPolicy,
611) -> Result<ValidCivil, FieldError> {
612    const FIELD: &str = "civil datetime";
613    finite(second, FIELD)?;
614
615    validate_civil_year(year, month, day)?;
616    if !(1..=12).contains(&month) {
617        return Err(FieldError::InvalidCivilDate {
618            field: FIELD,
619            year,
620            month,
621            day,
622        });
623    }
624    let last_day = crate::astro::time::civil::days_in_month(year, month);
625    if !(1..=last_day).contains(&day) {
626        return Err(FieldError::InvalidCivilDate {
627            field: FIELD,
628            year,
629            month,
630            day,
631        });
632    }
633    let time_fields_valid = (0..=23).contains(&hour) && (0..=59).contains(&minute);
634    let seconds_valid = (0.0..60.0).contains(&second)
635        || (second_policy.allows_leap_second_label()
636            && (60.0..61.0).contains(&second)
637            && time_fields_valid
638            && is_positive_utc_leap_second_label(year, month, day, hour, minute));
639    if !time_fields_valid || !seconds_valid {
640        return Err(FieldError::InvalidCivilTime {
641            field: FIELD,
642            hour,
643            minute,
644            second,
645        });
646    }
647
648    Ok(ValidCivil {
649        year,
650        month: month as u32,
651        day: day as u32,
652        hour: hour as u32,
653        minute: minute as u32,
654        second,
655    })
656}
657
658fn validate_civil_year(year: i64, month: i64, day: i64) -> Result<(), FieldError> {
659    if (CIVIL_YEAR_MIN..=CIVIL_YEAR_MAX).contains(&year) {
660        Ok(())
661    } else {
662        Err(FieldError::InvalidCivilDate {
663            field: "civil datetime",
664            year,
665            month,
666            day,
667        })
668    }
669}
670
671pub(crate) fn civil_datetime_with_decimal_second_policy(
672    year: i64,
673    month: i64,
674    day: i64,
675    hour: i64,
676    minute: i64,
677    second: &str,
678    second_policy: CivilSecondPolicy,
679) -> Result<ValidCivilMicrosecond, FieldError> {
680    const FIELD: &str = "civil datetime";
681    let second = second.trim();
682    if second.is_empty() {
683        return Err(FieldError::Missing { field: FIELD });
684    }
685
686    let (whole_second, microsecond) = decimal_second_to_microseconds(second, FIELD)?;
687    civil_datetime_with_whole_microsecond_policy(
688        CivilMinute {
689            year,
690            month,
691            day,
692            hour,
693            minute,
694        },
695        whole_second,
696        microsecond,
697        second_policy,
698    )
699}
700
701pub(crate) fn civil_datetime_with_fractional_second_policy(
702    year: i64,
703    month: i64,
704    day: i64,
705    hour: i64,
706    minute: i64,
707    second: f64,
708    second_policy: CivilSecondPolicy,
709) -> Result<ValidCivilMicrosecond, FieldError> {
710    let civil =
711        civil_datetime_with_second_policy(year, month, day, hour, minute, second, second_policy)?;
712
713    let whole_second = civil.second.trunc() as i64;
714    let microsecond = ((civil.second - whole_second as f64) * 1_000_000.0).round() as i64;
715    civil_datetime_with_whole_microsecond_policy(
716        CivilMinute {
717            year: civil.year,
718            month: i64::from(civil.month),
719            day: i64::from(civil.day),
720            hour: i64::from(civil.hour),
721            minute: i64::from(civil.minute),
722        },
723        whole_second,
724        microsecond,
725        second_policy,
726    )
727}
728
729fn civil_datetime_with_whole_microsecond_policy(
730    minute_parts: CivilMinute,
731    whole_second: i64,
732    mut microsecond: i64,
733    second_policy: CivilSecondPolicy,
734) -> Result<ValidCivilMicrosecond, FieldError> {
735    const FIELD: &str = "civil datetime";
736    if !(0..=1_000_000).contains(&microsecond) {
737        return Err(FieldError::InvalidCivilTime {
738            field: FIELD,
739            hour: minute_parts.hour,
740            minute: minute_parts.minute,
741            second: whole_second as f64 + microsecond as f64 / 1_000_000.0,
742        });
743    }
744
745    let civil = civil_datetime_with_second_policy(
746        minute_parts.year,
747        minute_parts.month,
748        minute_parts.day,
749        minute_parts.hour,
750        minute_parts.minute,
751        whole_second as f64,
752        second_policy,
753    )?;
754
755    let mut rounded_second = whole_second;
756    let rounded_from_subsecond = microsecond == 1_000_000;
757    if rounded_from_subsecond {
758        rounded_second += 1;
759        microsecond = 0;
760    }
761
762    let leap_second_label_allowed = second_policy.allows_leap_second_label()
763        && is_positive_utc_leap_second_label(
764            minute_parts.year,
765            minute_parts.month,
766            minute_parts.day,
767            minute_parts.hour,
768            minute_parts.minute,
769        );
770    if rounded_second <= 59 || (rounded_second == 60 && leap_second_label_allowed) {
771        return Ok(ValidCivilMicrosecond {
772            year: civil.year,
773            month: civil.month,
774            day: civil.day,
775            hour: civil.hour,
776            minute: civil.minute,
777            second: rounded_second as u32,
778            microsecond: microsecond as u32,
779        });
780    }
781
782    if rounded_second == 60
783        || (rounded_second == 61 && rounded_from_subsecond && leap_second_label_allowed)
784    {
785        let (year, month, day, hour, minute) =
786            carry_to_next_minute(civil.year, civil.month, civil.day, civil.hour, civil.minute)?;
787        return Ok(ValidCivilMicrosecond {
788            year,
789            month,
790            day,
791            hour,
792            minute,
793            second: 0,
794            microsecond: microsecond as u32,
795        });
796    }
797
798    Err(FieldError::InvalidCivilTime {
799        field: FIELD,
800        hour: minute_parts.hour,
801        minute: minute_parts.minute,
802        second: rounded_second as f64 + microsecond as f64 / 1_000_000.0,
803    })
804}
805
806fn is_positive_utc_leap_second_label(
807    year: i64,
808    month: i64,
809    day: i64,
810    hour: i64,
811    minute: i64,
812) -> bool {
813    let (Ok(year), Ok(month), Ok(day), Ok(hour), Ok(minute)) = (
814        i32::try_from(year),
815        i32::try_from(month),
816        i32::try_from(day),
817        i32::try_from(hour),
818        i32::try_from(minute),
819    ) else {
820        return false;
821    };
822    crate::astro::time::scales::is_positive_leap_second_label(year, month, day, hour, minute)
823}
824
825fn decimal_second_to_microseconds(
826    second: &str,
827    field: &'static str,
828) -> Result<(i64, i64), FieldError> {
829    let (whole, fraction) = second
830        .split_once('.')
831        .map_or((second, None), |(whole, fraction)| (whole, Some(fraction)));
832    if whole.is_empty() {
833        return Err(FieldError::FloatParse {
834            field,
835            value: second.to_string(),
836        });
837    }
838    let negative_zero_whole = whole.starts_with('-');
839    let whole = whole.parse::<i64>().map_err(|_| FieldError::FloatParse {
840        field,
841        value: second.to_string(),
842    })?;
843    let negative_zero_whole = negative_zero_whole && whole == 0;
844
845    let Some(fraction) = fraction else {
846        return Ok((whole, 0));
847    };
848    if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) {
849        return Err(FieldError::FloatParse {
850            field,
851            value: second.to_string(),
852        });
853    }
854
855    let mut microsecond = 0i64;
856    for i in 0..6 {
857        microsecond *= 10;
858        microsecond += fraction
859            .as_bytes()
860            .get(i)
861            .map_or(0, |b| i64::from(b - b'0'));
862    }
863    if fraction.as_bytes().get(6).is_some_and(|b| b - b'0' >= 5) {
864        microsecond += 1;
865    }
866    if negative_zero_whole {
867        microsecond = -microsecond.max(1);
868    }
869    Ok((whole, microsecond))
870}
871
872fn carry_to_next_minute(
873    mut year: i64,
874    mut month: u32,
875    mut day: u32,
876    mut hour: u32,
877    mut minute: u32,
878) -> Result<(i64, u32, u32, u32, u32), FieldError> {
879    minute += 1;
880    if minute < 60 {
881        return Ok((year, month, day, hour, minute));
882    }
883    minute = 0;
884    hour += 1;
885    if hour < 24 {
886        return Ok((year, month, day, hour, minute));
887    }
888    hour = 0;
889    day += 1;
890    if i64::from(day) <= crate::astro::time::civil::days_in_month(year, i64::from(month)) {
891        return Ok((year, month, day, hour, minute));
892    }
893    day = 1;
894    month += 1;
895    if month <= 12 {
896        return Ok((year, month, day, hour, minute));
897    }
898    month = 1;
899    year = year
900        .checked_add(1)
901        .ok_or_else(|| FieldError::InvalidCivilDate {
902            field: "civil datetime",
903            year,
904            month: i64::from(month),
905            day: i64::from(day),
906        })?;
907    validate_civil_year(year, i64::from(month), i64::from(day))?;
908    Ok((year, month, day, hour, minute))
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn bounded_quantity_helpers_accept_valid_inputs() {
917        assert_eq!(finite_in_range(1.0, -1.0, 1.0, "bounded"), Ok(1.0));
918        assert_eq!(fraction(0.0, "fraction"), Ok(0.0));
919        assert_eq!(fraction(1.0, "fraction"), Ok(1.0));
920        assert_eq!(second_of_day(86_399.999, "second_of_day"), Ok(86_399.999));
921        assert_eq!(positive_step(0.25, "step"), Ok(0.25));
922        assert_eq!(range_order(-1.0, 1.0, "range"), Ok(()));
923        assert_eq!(range_order(1.0, 1.0, "range"), Ok(()));
924        assert_eq!(clamp_magnitude(3.0, 2.0), 2.0);
925        assert_eq!(clamp_magnitude(-3.0, 2.0), -2.0);
926        assert_eq!(clamp_magnitude(1.5, 2.0), 1.5);
927    }
928
929    #[test]
930    fn bounded_quantity_helpers_reject_bad_inputs() {
931        assert!(matches!(
932            fraction(50.0, "fraction"),
933            Err(FieldError::OutOfRange {
934                field: "fraction",
935                min: 0.0,
936                max: 1.0,
937                upper_inclusive: true
938            })
939        ));
940        assert!(matches!(
941            second_of_day(crate::constants::SECONDS_PER_DAY, "second_of_day"),
942            Err(FieldError::OutOfRange {
943                field: "second_of_day",
944                min: 0.0,
945                max: crate::constants::SECONDS_PER_DAY,
946                upper_inclusive: false
947            })
948        ));
949        assert!(matches!(
950            positive_step(0.0, "step"),
951            Err(FieldError::NotPositive { field: "step" })
952        ));
953        assert!(matches!(
954            range_order(2.0, 1.0, "range"),
955            Err(FieldError::OutOfRange {
956                field: "range",
957                min: 2.0,
958                max: 1.0,
959                upper_inclusive: true
960            })
961        ));
962        assert!(matches!(
963            finite_in_range(f64::NAN, -1.0, 1.0, "bounded"),
964            Err(FieldError::NonFinite { field: "bounded" })
965        ));
966    }
967
968    #[test]
969    fn covariance_psd_helper_accepts_positive_semidefinite_matrices() {
970        let semidefinite = [[1.0, 1.0], [1.0, 1.0]];
971        assert_eq!(validate_covariance_psd(&semidefinite, "covariance"), Ok(()));
972
973        let scaled = [
974            [4.0e12, 2.0e12, 0.0],
975            [2.0e12, 1.0e12, 0.0],
976            [0.0, 0.0, 3.0],
977        ];
978        assert_eq!(validate_covariance_psd(&scaled, "covariance"), Ok(()));
979    }
980
981    #[test]
982    fn covariance_psd_helper_rejects_malformed_matrices() {
983        let non_finite = [[1.0, f64::NAN], [f64::NAN, 1.0]];
984        assert!(matches!(
985            validate_covariance_psd(&non_finite, "covariance"),
986            Err(FieldError::NonFinite {
987                field: "covariance"
988            })
989        ));
990
991        let asymmetric = [[1.0, 1.0e-5], [0.0, 1.0]];
992        assert!(matches!(
993            validate_covariance_psd(&asymmetric, "covariance"),
994            Err(FieldError::NotPositive {
995                field: "covariance"
996            })
997        ));
998
999        let indefinite = [[1.0, 2.0], [2.0, 1.0]];
1000        assert!(matches!(
1001            validate_covariance_psd(&indefinite, "covariance"),
1002            Err(FieldError::NotPositive {
1003                field: "covariance"
1004            })
1005        ));
1006    }
1007
1008    #[test]
1009    fn covariance_psd_rows_helper_matches_array_helper() {
1010        let valid = [vec![2.0, 0.25], vec![0.25, 1.0]];
1011        let valid_rows: Vec<&[f64]> = valid.iter().map(Vec::as_slice).collect();
1012        assert_eq!(
1013            validate_covariance_psd_rows(&valid_rows, "covariance"),
1014            Ok(())
1015        );
1016
1017        let indefinite = [vec![1.0, 2.0], vec![2.0, 1.0]];
1018        let indefinite_rows: Vec<&[f64]> = indefinite.iter().map(Vec::as_slice).collect();
1019        assert!(matches!(
1020            validate_covariance_psd_rows(&indefinite_rows, "covariance"),
1021            Err(FieldError::NotPositive {
1022                field: "covariance"
1023            })
1024        ));
1025    }
1026
1027    #[test]
1028    fn strictly_increasing_helper_rejects_nonfinite_and_nonmonotonic_values() {
1029        assert_eq!(require_strictly_increasing([1.0, 2.0, 3.0], "time"), Ok(()));
1030        assert!(matches!(
1031            require_strictly_increasing([1.0, 1.0], "time"),
1032            Err(FieldError::OutOfRange {
1033                field: "time",
1034                min: 1.0,
1035                max: 1.0,
1036                upper_inclusive: false
1037            })
1038        ));
1039        assert!(matches!(
1040            require_strictly_increasing([1.0, f64::NAN], "time"),
1041            Err(FieldError::NonFinite { field: "time" })
1042        ));
1043    }
1044
1045    #[test]
1046    fn civil_datetime_uses_time_system_leap_second_policy() {
1047        let utc = civil_datetime_with_second_policy(
1048            2016,
1049            12,
1050            31,
1051            23,
1052            59,
1053            60.0,
1054            CivilSecondPolicy::UtcLike,
1055        )
1056        .expect("UTC-like civil time accepts a leap second label");
1057        assert_eq!(utc.second, 60.0);
1058
1059        let utc_ordinary = civil_datetime_with_second_policy(
1060            2016,
1061            12,
1062            30,
1063            23,
1064            59,
1065            59.0,
1066            CivilSecondPolicy::UtcLike,
1067        )
1068        .expect("UTC-like civil time accepts an ordinary final minute second");
1069        assert_eq!(utc_ordinary.second, 59.0);
1070
1071        assert!(matches!(
1072            civil_datetime_with_second_policy(
1073                2016,
1074                12,
1075                30,
1076                23,
1077                59,
1078                60.0,
1079                CivilSecondPolicy::UtcLike
1080            ),
1081            Err(FieldError::InvalidCivilTime {
1082                field: "civil datetime",
1083                hour: 23,
1084                minute: 59,
1085                second: 60.0
1086            })
1087        ));
1088
1089        let gps = civil_datetime_with_second_policy(
1090            2016,
1091            12,
1092            31,
1093            23,
1094            59,
1095            59.0,
1096            CivilSecondPolicy::Continuous,
1097        )
1098        .expect("GPS-like civil time accepts an ordinary final minute second");
1099        assert_eq!(gps.second, 59.0);
1100
1101        assert!(matches!(
1102            civil_datetime_with_second_policy(
1103                2016,
1104                12,
1105                31,
1106                23,
1107                59,
1108                60.0,
1109                CivilSecondPolicy::Continuous
1110            ),
1111            Err(FieldError::InvalidCivilTime {
1112                field: "civil datetime",
1113                hour: 23,
1114                minute: 59,
1115                second: 60.0
1116            })
1117        ));
1118        assert!(matches!(
1119            civil_datetime_with_second_policy(
1120                2016,
1121                12,
1122                31,
1123                23,
1124                59,
1125                61.0,
1126                CivilSecondPolicy::UtcLike
1127            ),
1128            Err(FieldError::InvalidCivilTime {
1129                field: "civil datetime",
1130                hour: 23,
1131                minute: 59,
1132                second: 61.0
1133            })
1134        ));
1135    }
1136
1137    #[test]
1138    fn decimal_second_parser_carries_rounded_microseconds() {
1139        let ordinary = civil_datetime_with_decimal_second_policy(
1140            2026,
1141            6,
1142            17,
1143            4,
1144            32,
1145            "52.9999995",
1146            CivilSecondPolicy::Continuous,
1147        )
1148        .expect("rounded fractional second carries into second");
1149        assert_eq!(
1150            ordinary,
1151            ValidCivilMicrosecond {
1152                year: 2026,
1153                month: 6,
1154                day: 17,
1155                hour: 4,
1156                minute: 32,
1157                second: 53,
1158                microsecond: 0,
1159            }
1160        );
1161
1162        let day_boundary = civil_datetime_with_decimal_second_policy(
1163            2026,
1164            6,
1165            17,
1166            23,
1167            59,
1168            "59.9999995",
1169            CivilSecondPolicy::Continuous,
1170        )
1171        .expect("rounded fractional second carries across day boundary");
1172        assert_eq!(
1173            day_boundary,
1174            ValidCivilMicrosecond {
1175                year: 2026,
1176                month: 6,
1177                day: 18,
1178                hour: 0,
1179                minute: 0,
1180                second: 0,
1181                microsecond: 0,
1182            }
1183        );
1184
1185        let utc_ordinary_boundary = civil_datetime_with_decimal_second_policy(
1186            2026,
1187            6,
1188            17,
1189            23,
1190            59,
1191            "59.9999995",
1192            CivilSecondPolicy::UtcLike,
1193        )
1194        .expect("rounded UTC ordinary second carries across day boundary");
1195        assert_eq!(
1196            utc_ordinary_boundary,
1197            ValidCivilMicrosecond {
1198                year: 2026,
1199                month: 6,
1200                day: 18,
1201                hour: 0,
1202                minute: 0,
1203                second: 0,
1204                microsecond: 0,
1205            }
1206        );
1207
1208        let utc_leap_boundary = civil_datetime_with_decimal_second_policy(
1209            2016,
1210            12,
1211            31,
1212            23,
1213            59,
1214            "59.9999995",
1215            CivilSecondPolicy::UtcLike,
1216        )
1217        .expect("rounded UTC leap-second label stays in the leap minute");
1218        assert_eq!(
1219            utc_leap_boundary,
1220            ValidCivilMicrosecond {
1221                year: 2016,
1222                month: 12,
1223                day: 31,
1224                hour: 23,
1225                minute: 59,
1226                second: 60,
1227                microsecond: 0,
1228            }
1229        );
1230    }
1231
1232    #[test]
1233    fn decimal_second_parser_rejects_bad_fraction() {
1234        assert!(matches!(
1235            civil_datetime_with_decimal_second_policy(
1236                2026,
1237                6,
1238                17,
1239                4,
1240                32,
1241                "52.x",
1242                CivilSecondPolicy::Continuous
1243            ),
1244            Err(FieldError::FloatParse {
1245                field: "civil datetime",
1246                ..
1247            })
1248        ));
1249    }
1250
1251    #[test]
1252    fn decimal_second_parser_rejects_negative_fractional_zero_second() {
1253        assert!(matches!(
1254            civil_datetime_with_decimal_second_policy(
1255                2026,
1256                6,
1257                17,
1258                4,
1259                32,
1260                "-0.1",
1261                CivilSecondPolicy::Continuous
1262            ),
1263            Err(FieldError::InvalidCivilTime {
1264                field: "civil datetime",
1265                hour: 4,
1266                minute: 32,
1267                second
1268            }) if (second + 0.1).abs() < f64::EPSILON
1269        ));
1270
1271        let fractional_zero_second = civil_datetime_with_decimal_second_policy(
1272            2026,
1273            6,
1274            17,
1275            4,
1276            32,
1277            "0.1",
1278            CivilSecondPolicy::Continuous,
1279        )
1280        .expect("positive fractional zero second parses");
1281        assert_eq!(
1282            fractional_zero_second,
1283            ValidCivilMicrosecond {
1284                year: 2026,
1285                month: 6,
1286                day: 17,
1287                hour: 4,
1288                minute: 32,
1289                second: 0,
1290                microsecond: 100_000,
1291            }
1292        );
1293
1294        let positive_second = civil_datetime_with_decimal_second_policy(
1295            2026,
1296            6,
1297            17,
1298            4,
1299            32,
1300            "52.1",
1301            CivilSecondPolicy::Continuous,
1302        )
1303        .expect("positive decimal second parses");
1304        assert_eq!(
1305            positive_second,
1306            ValidCivilMicrosecond {
1307                year: 2026,
1308                month: 6,
1309                day: 17,
1310                hour: 4,
1311                minute: 32,
1312                second: 52,
1313                microsecond: 100_000,
1314            }
1315        );
1316    }
1317}