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)]
109pub struct ConversionChain {
110 pub glonass: Time<Glonass>,
112
113 pub gps: Time<Gps>,
115
116 pub utc: Time<Utc>,
118
119 pub tai: Time<Tai>,
121}
122
123impl ScaleId {
124 pub const ALL: [ScaleId; 6] = [
126 ScaleId::Glonass,
127 ScaleId::Gps,
128 ScaleId::Galileo,
129 ScaleId::Beidou,
130 ScaleId::Tai,
131 ScaleId::Utc,
132 ];
133
134 #[inline]
136 #[must_use]
137 pub const fn name(self) -> &'static str {
138 match self {
139 ScaleId::Glonass => "GLO",
140 ScaleId::Gps => "GPS",
141 ScaleId::Galileo => "GAL",
142 ScaleId::Beidou => "BDT",
143 ScaleId::Tai => "TAI",
144 ScaleId::Utc => "UTC",
145 }
146 }
147
148 #[inline]
158 #[must_use]
159 pub const fn conversion_kind(
160 self,
161 target: ScaleId,
162 ) -> ConversionKind {
163 use ConversionKind::*;
164 use ScaleId::*;
165 match (self, target) {
166 (a, b) if a as u8 == b as u8 => SameScale,
168 (Gps, Tai) | (Tai, Gps) => Fixed,
170 (Gps, Galileo) | (Galileo, Gps) => Identity,
172 (Gps, Beidou) | (Beidou, Gps) => Fixed,
174 (Galileo, Beidou) | (Beidou, Galileo) => Fixed,
176 (Galileo, Tai) | (Tai, Galileo) => Fixed,
178 (Beidou, Tai) | (Tai, Beidou) => Fixed,
179 (Glonass, Utc) | (Utc, Glonass) => EpochShift,
181 (Gps, Utc) | (Utc, Gps) => Contextual,
183 (Gps, Glonass) | (Glonass, Gps) => Contextual,
184 (Galileo, Utc) | (Utc, Galileo) => Contextual,
185 (Galileo, Glonass) | (Glonass, Galileo) => Contextual,
186 (Beidou, Utc) | (Utc, Beidou) => Contextual,
187 (Beidou, Glonass) | (Glonass, Beidou) => Contextual,
188 (Tai, Utc) | (Utc, Tai) => Contextual,
190 (Tai, Glonass) | (Glonass, Tai) => Contextual,
191 _ => Contextual,
193 }
194 }
195
196 #[inline]
199 #[must_use]
200 pub const fn is_fixed(
201 self,
202 target: ScaleId,
203 ) -> bool {
204 matches!(
205 self.conversion_kind(target),
206 ConversionKind::Fixed | ConversionKind::Identity | ConversionKind::EpochShift
207 )
208 }
209
210 #[inline]
212 #[must_use]
213 pub const fn needs_leap_seconds(
214 self,
215 target: ScaleId,
216 ) -> bool {
217 matches!(self.conversion_kind(target), ConversionKind::Contextual)
218 }
219}
220
221impl ConversionMatrix {
222 #[inline]
224 #[must_use]
225 pub fn new() -> Self {
226 ConversionMatrix
227 }
228
229 #[must_use]
231 pub fn path_count(
232 &self,
233 contextual: bool,
234 ) -> usize {
235 let mut count = 0;
236
237 for &from in &ScaleId::ALL {
238 for &to in &ScaleId::ALL {
239 if from != to {
240 let kind = from.conversion_kind(to);
241 let is_ctx = matches!(kind, ConversionKind::Contextual);
242
243 if contextual == is_ctx {
244 count += 1;
245 }
246 }
247 }
248 }
249
250 count
251 }
252
253 #[inline]
255 #[must_use]
256 pub fn kind(
257 &self,
258 from: ScaleId,
259 to: ScaleId,
260 ) -> ConversionKind {
261 from.conversion_kind(to)
262 }
263}
264
265impl Default for ConversionMatrix {
266 fn default() -> Self {
267 ConversionMatrix::new()
268 }
269}
270
271pub 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 "{:?} → {:?} should be contextual",
380 from,
381 to
382 );
383 assert!(
384 to.needs_leap_seconds(from),
385 "{:?} → {:?} should be contextual",
386 to,
387 from
388 );
389 }
390 }
391
392 #[test]
393 fn test_fixed_conversions_dont_need_leap_seconds() {
394 let fixed_pairs = [
395 (ScaleId::Gps, ScaleId::Tai),
396 (ScaleId::Gps, ScaleId::Galileo),
397 (ScaleId::Gps, ScaleId::Beidou),
398 (ScaleId::Galileo, ScaleId::Beidou),
399 (ScaleId::Glonass, ScaleId::Utc),
400 ];
401 for (from, to) in fixed_pairs {
402 assert!(from.is_fixed(to), "{:?} → {:?} should be fixed", from, to);
403 assert!(to.is_fixed(from), "{:?} → {:?} should be fixed", to, from);
404 }
405 }
406
407 #[test]
408 fn test_tai_offset_constants_are_correct() {
409 assert_eq!(TAI_OFFSET_GPS_NS, 19_000_000_000);
410 assert_eq!(TAI_OFFSET_GALILEO_NS, 19_000_000_000);
411 assert_eq!(TAI_OFFSET_BEIDOU_NS, 33_000_000_000);
412 assert_eq!(TAI_OFFSET_TAI_NS, 0);
413 assert_eq!(GLONASS_UTC_EPOCH_SHIFT_NS, 757_371_600_000_000_000);
414 }
415
416 #[test]
417 fn test_matrix_counts_are_correct() {
418 let m = ConversionMatrix::new();
419 assert_eq!(m.path_count(false), 14, "14 fixed paths");
426 assert_eq!(m.path_count(true), 16, "16 contextual paths");
428 }
429
430 #[test]
431 fn test_all_off_diagonal_cells_are_classified() {
432 for &from in &ScaleId::ALL {
435 for &to in &ScaleId::ALL {
436 if from != to {
437 let kind = from.conversion_kind(to);
438 assert_ne!(
439 kind,
440 ConversionKind::SameScale,
441 "{:?}→{:?} should not be SameScale",
442 from,
443 to
444 );
445 }
446 }
447 }
448 }
449
450 #[test]
451 fn test_matrix_is_symmetric_in_kind_category() {
452 for &from in &ScaleId::ALL {
454 for &to in &ScaleId::ALL {
455 if from != to {
456 let fwd_fixed = from.is_fixed(to);
457 let rev_fixed = to.is_fixed(from);
458 assert_eq!(
459 fwd_fixed, rev_fixed,
460 "{:?}↔{:?}: fixed classification must be symmetric",
461 from, to
462 );
463 }
464 }
465 }
466 }
467}