1use crate::{
20 Beidou, Glonass, GnssTimeError, Gps, IntoScale, IntoScaleWith, LeapSecondsProvider, Tai, Time,
21 Utc,
22};
23
24pub const TAI_OFFSET_GPS_NS: i64 = 19 * 1_000_000_000;
26
27pub const TAI_OFFSET_GALILEO_NS: i64 = 19 * 1_000_000_000;
29
30pub const TAI_OFFSET_BEIDOU_NS: i64 = 33 * 1_000_000_000;
32
33pub const TAI_OFFSET_TAI_NS: i64 = 0;
35
36pub const GLONASS_UTC_EPOCH_SHIFT_NS: i64 = 757_371_600 * 1_000_000_000;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ConversionKind {
46 Fixed,
48
49 Identity,
51
52 EpochShift,
54
55 Contextual,
57
58 SameScale,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64#[non_exhaustive]
65pub enum ScaleId {
66 Glonass,
68
69 Gps,
71
72 Galileo,
74
75 Beidou,
77
78 Tai,
80
81 Utc,
83}
84
85pub struct ConversionMatrix;
106
107#[derive(Debug)]
110pub struct ConversionChain {
111 pub glonass: Time<Glonass>,
113
114 pub gps: Time<Gps>,
116
117 pub utc: Time<Utc>,
119
120 pub tai: Time<Tai>,
122}
123
124impl ScaleId {
125 pub const ALL: [ScaleId; 6] = [
127 ScaleId::Glonass,
128 ScaleId::Gps,
129 ScaleId::Galileo,
130 ScaleId::Beidou,
131 ScaleId::Tai,
132 ScaleId::Utc,
133 ];
134
135 #[inline]
137 #[must_use]
138 pub const fn name(self) -> &'static str {
139 match self {
140 ScaleId::Glonass => "GLO",
141 ScaleId::Gps => "GPS",
142 ScaleId::Galileo => "GAL",
143 ScaleId::Beidou => "BDT",
144 ScaleId::Tai => "TAI",
145 ScaleId::Utc => "UTC",
146 }
147 }
148
149 #[inline]
159 #[must_use]
160 pub const fn conversion_kind(
161 self,
162 target: ScaleId,
163 ) -> ConversionKind {
164 use ConversionKind::{Contextual, EpochShift, Fixed, Identity, SameScale};
165 use ScaleId::{Beidou, Galileo, Glonass, Gps, Tai, Utc};
166
167 match (self, target) {
168 (a, b) if a as u8 == b as u8 => SameScale,
169
170 (Gps, Galileo) | (Galileo, Gps) => Identity,
171
172 (Gps | Galileo | Beidou, Tai)
174 | (Tai, Gps | Galileo | Beidou)
175 | (Gps | Galileo, Beidou)
176 | (Beidou, Gps | Galileo) => Fixed,
177
178 (Glonass, Utc) | (Utc, Glonass) => EpochShift,
179
180 _ => Contextual,
181 }
182 }
183
184 #[inline]
187 #[must_use]
188 pub const fn is_fixed(
189 self,
190 target: ScaleId,
191 ) -> bool {
192 matches!(
193 self.conversion_kind(target),
194 ConversionKind::Fixed | ConversionKind::Identity | ConversionKind::EpochShift
195 )
196 }
197
198 #[inline]
200 #[must_use]
201 pub const fn needs_leap_seconds(
202 self,
203 target: ScaleId,
204 ) -> bool {
205 matches!(self.conversion_kind(target), ConversionKind::Contextual)
206 }
207}
208
209impl ConversionMatrix {
210 #[inline]
212 #[must_use]
213 pub const fn new() -> Self {
214 ConversionMatrix
215 }
216
217 #[must_use]
219 #[allow(clippy::unused_self)]
220 pub fn path_count(
221 &self,
222 contextual: bool,
223 ) -> usize {
224 let mut count = 0;
225
226 for &from in &ScaleId::ALL {
227 for &to in &ScaleId::ALL {
228 if from != to {
229 let kind = from.conversion_kind(to);
230 let is_ctx = matches!(kind, ConversionKind::Contextual);
231
232 if contextual == is_ctx {
233 count += 1;
234 }
235 }
236 }
237 }
238
239 count
240 }
241
242 #[inline]
244 #[must_use]
245 #[allow(clippy::unused_self)]
246 pub const fn kind(
247 &self,
248 from: ScaleId,
249 to: ScaleId,
250 ) -> ConversionKind {
251 from.conversion_kind(to)
252 }
253}
254
255impl Default for ConversionMatrix {
256 fn default() -> Self {
257 ConversionMatrix::new()
258 }
259}
260
261pub fn beidou_via_gps_to_glonass_via_utc<P: LeapSecondsProvider>(
273 bdt: Time<Beidou>,
274 ls: &P,
275) -> Result<ConversionChain, GnssTimeError> {
276 let gps: Time<Gps> = bdt.into_scale()?;
277 let glo: Time<Glonass> = gps.into_scale_with(ls)?;
278 let utc: Time<Utc> = glo.into_scale()?;
279 let tai: Time<Tai> = gps.into_scale()?;
280
281 Ok(ConversionChain {
282 gps,
283 glonass: glo,
284 utc,
285 tai,
286 })
287}
288
289#[cfg(test)]
294mod tests {
295 #[allow(unused_imports)]
296 use std::vec;
297
298 use super::*;
299
300 #[test]
301 fn test_scale_id_names_are_correct() {
302 assert_eq!(ScaleId::Glonass.name(), "GLO");
303 assert_eq!(ScaleId::Gps.name(), "GPS");
304 assert_eq!(ScaleId::Galileo.name(), "GAL");
305 assert_eq!(ScaleId::Beidou.name(), "BDT");
306 assert_eq!(ScaleId::Tai.name(), "TAI");
307 assert_eq!(ScaleId::Utc.name(), "UTC");
308 }
309
310 #[test]
311 fn test_same_scale_is_same_scale() {
312 for &s in &ScaleId::ALL {
313 assert_eq!(s.conversion_kind(s), ConversionKind::SameScale);
314 }
315 }
316
317 #[test]
318 fn test_gps_galileo_is_identity() {
319 assert_eq!(
321 ScaleId::Gps.conversion_kind(ScaleId::Galileo),
322 ConversionKind::Identity
323 );
324 assert_eq!(
325 ScaleId::Galileo.conversion_kind(ScaleId::Gps),
326 ConversionKind::Identity
327 );
328 }
329
330 #[test]
331 fn test_gps_tai_is_fixed() {
332 assert_eq!(
333 ScaleId::Gps.conversion_kind(ScaleId::Tai),
334 ConversionKind::Fixed
335 );
336 assert_eq!(
337 ScaleId::Tai.conversion_kind(ScaleId::Gps),
338 ConversionKind::Fixed
339 );
340 }
341
342 #[test]
343 fn test_gps_beidou_is_fixed() {
344 assert_eq!(
345 ScaleId::Gps.conversion_kind(ScaleId::Beidou),
346 ConversionKind::Fixed
347 );
348 assert_eq!(
349 ScaleId::Beidou.conversion_kind(ScaleId::Gps),
350 ConversionKind::Fixed
351 );
352 }
353
354 #[test]
355 fn test_glonass_utc_is_epoch_shift() {
356 assert_eq!(
357 ScaleId::Glonass.conversion_kind(ScaleId::Utc),
358 ConversionKind::EpochShift
359 );
360 assert_eq!(
361 ScaleId::Utc.conversion_kind(ScaleId::Glonass),
362 ConversionKind::EpochShift
363 );
364 }
365
366 #[test]
367 fn test_contextual_conversions_require_leap_seconds() {
368 let contextual_pairs = [
369 (ScaleId::Gps, ScaleId::Utc),
370 (ScaleId::Gps, ScaleId::Glonass),
371 (ScaleId::Galileo, ScaleId::Utc),
372 (ScaleId::Galileo, ScaleId::Glonass),
373 (ScaleId::Beidou, ScaleId::Utc),
374 (ScaleId::Beidou, ScaleId::Glonass),
375 ];
376 for (from, to) in contextual_pairs {
377 assert!(
378 from.needs_leap_seconds(to),
379 "{from:?} -> {to:?} should be contextual",
380 );
381 assert!(
382 to.needs_leap_seconds(from),
383 "{to:?} -> {from:?} should be contextual",
384 );
385 }
386 }
387
388 #[test]
389 fn test_fixed_conversions_dont_need_leap_seconds() {
390 let fixed_pairs = [
391 (ScaleId::Gps, ScaleId::Tai),
392 (ScaleId::Gps, ScaleId::Galileo),
393 (ScaleId::Gps, ScaleId::Beidou),
394 (ScaleId::Galileo, ScaleId::Beidou),
395 (ScaleId::Glonass, ScaleId::Utc),
396 ];
397 for (from, to) in fixed_pairs {
398 assert!(from.is_fixed(to), "{from:?} -> {to:?} should be fixed");
399 assert!(to.is_fixed(from), "{to:?} -> {from:?} should be fixed");
400 }
401 }
402
403 #[test]
404 fn test_tai_offset_constants_are_correct() {
405 assert_eq!(TAI_OFFSET_GPS_NS, 19_000_000_000);
406 assert_eq!(TAI_OFFSET_GALILEO_NS, 19_000_000_000);
407 assert_eq!(TAI_OFFSET_BEIDOU_NS, 33_000_000_000);
408 assert_eq!(TAI_OFFSET_TAI_NS, 0);
409 assert_eq!(GLONASS_UTC_EPOCH_SHIFT_NS, 757_371_600_000_000_000);
410 }
411
412 #[test]
413 fn test_matrix_counts_are_correct() {
414 let m = ConversionMatrix::new();
415 assert_eq!(m.path_count(false), 14, "14 fixed paths");
422 assert_eq!(m.path_count(true), 16, "16 contextual paths");
424 }
425
426 #[test]
427 fn test_all_off_diagonal_cells_are_classified() {
428 for &from in &ScaleId::ALL {
431 for &to in &ScaleId::ALL {
432 if from != to {
433 let kind = from.conversion_kind(to);
434 assert_ne!(
435 kind,
436 ConversionKind::SameScale,
437 "{from:?} -> {to:?} should not be SameScale",
438 );
439 }
440 }
441 }
442 }
443
444 #[test]
445 fn test_matrix_is_symmetric_in_kind_category() {
446 for &from in &ScaleId::ALL {
448 for &to in &ScaleId::ALL {
449 if from != to {
450 let fwd_fixed = from.is_fixed(to);
451 let rev_fixed = to.is_fixed(from);
452 assert_eq!(
453 fwd_fixed, rev_fixed,
454 "{from:?} <-> {to:?}: fixed classification must be symmetric",
455 );
456 }
457 }
458 }
459 }
460}