1#![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 UtcLike,
120 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(µsecond) {
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}