use crate::bcd::{from_bcd_byte, to_bcd_byte};
use core::time::Duration;
const MJD_16_MSB: u16 = 0x8000;
const MJD_WRAP_OFFSET: u32 = 0x1_0000;
fn full_mjd(field: u16) -> u32 {
if field & MJD_16_MSB != 0 {
u32::from(field)
} else {
MJD_WRAP_OFFSET + u32::from(field)
}
}
fn mjd_field(full: u32) -> Option<u16> {
let field = if full >= MJD_WRAP_OFFSET {
full - MJD_WRAP_OFFSET
} else {
full
};
let field = u16::try_from(field).ok()?;
(full_mjd(field) == full).then_some(field)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MjdBcdDateTime {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
#[must_use]
pub fn decode_mjd_bcd(raw: [u8; 5]) -> Option<MjdBcdDateTime> {
let (mjd_bytes, _) = raw.split_first_chunk::<2>().unwrap();
let mjd = u16::from_be_bytes(*mjd_bytes);
let h = from_bcd_byte(raw[2])?;
let mi = from_bcd_byte(raw[3])?;
let s = from_bcd_byte(raw[4])?;
if mi > 59 || s > 59 || h > 23 {
return None;
}
let (year, month, day) = mjd_to_ymd_nogate(mjd)?;
Some(MjdBcdDateTime {
year,
month,
day,
hour: h,
minute: mi,
second: s,
})
}
#[must_use]
pub fn encode_mjd_bcd(dt: MjdBcdDateTime) -> Option<[u8; 5]> {
if dt.hour > 23 || dt.minute > 59 || dt.second > 59 {
return None;
}
let mjd = ymd_to_mjd_nogate(i32::from(dt.year), u32::from(dt.month), u32::from(dt.day))?;
let [m0, m1] = mjd.to_be_bytes();
Some([
m0,
m1,
to_bcd_byte(dt.hour)?,
to_bcd_byte(dt.minute)?,
to_bcd_byte(dt.second)?,
])
}
const JDN_OF_MJD_0: i64 = 2_400_001;
fn mjd_to_ymd_core(field: u16) -> (i64, i64, i64) {
const Y_BASE: i64 = 4716;
const J_OFFSET: i64 = 1401;
const B_OFFSET: i64 = 274_277;
const C_CORR: i64 = -38;
let jdn = i64::from(full_mjd(field)) + JDN_OF_MJD_0;
let f = jdn + J_OFFSET + (((4 * jdn + B_OFFSET) / 146_097) * 3) / 4 + C_CORR;
let e = 4 * f + 3;
let g = (e % 1461) / 4;
let h = 5 * g + 2;
let d = (h % 153) / 5 + 1;
let m = (h / 153 + 2) % 12 + 1;
let y = e / 1461 - Y_BASE + (12 + 2 - m) / 12;
(y, m, d)
}
fn mjd_to_ymd_nogate(mjd: u16) -> Option<(u16, u8, u8)> {
let (y, m, d) = mjd_to_ymd_core(mjd);
let y_u16 = u16::try_from(y).ok()?;
let m_u8 = u8::try_from(m).ok()?;
let d_u8 = u8::try_from(d).ok()?;
if !(1..=12).contains(&m_u8) || !(1..=31).contains(&d_u8) {
return None;
}
Some((y_u16, m_u8, d_u8))
}
fn ymd_to_mjd_core(year: i32, month: u32, day: u32) -> i64 {
let a = (14 - i64::from(month)) / 12;
let y = i64::from(year) + 4800 - a;
let m = i64::from(month) + 12 * a - 3;
let jdn = i64::from(day) + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32_045;
jdn - JDN_OF_MJD_0
}
fn ymd_to_mjd_nogate(year: i32, month: u32, day: u32) -> Option<u16> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let full = ymd_to_mjd_core(year, month, day);
let mjd = mjd_field(u32::try_from(full).ok()?)?;
let (y, m, d) = mjd_to_ymd_nogate(mjd)?;
if i32::from(y) == year && u32::from(m) == month && u32::from(d) == day {
Some(mjd)
} else {
None
}
}
#[must_use]
pub fn decode_bcd_duration(raw: [u8; 3]) -> Option<Duration> {
let h = u64::from(from_bcd_byte(raw[0])?);
let m = u64::from(from_bcd_byte(raw[1])?);
let s = u64::from(from_bcd_byte(raw[2])?);
if m > 59 || s > 59 {
return None;
}
Some(Duration::from_secs(h * 3600 + m * 60 + s))
}
#[must_use]
pub fn encode_bcd_duration(duration: Duration) -> Option<[u8; 3]> {
let secs = duration.as_secs();
let h = secs / 3600;
if h > 99 {
return None;
}
let m = (secs % 3600) / 60;
let s = secs % 60;
Some([
to_bcd_byte(h as u8)?,
to_bcd_byte(m as u8)?,
to_bcd_byte(s as u8)?,
])
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn mjd_to_ymd(mjd: u16) -> (i32, u32, u32) {
let (y, m, d) = mjd_to_ymd_core(mjd);
(y as i32, m as u32, d as u32)
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn ymd_to_mjd(year: i32, month: u32, day: u32) -> Option<u16> {
ymd_to_mjd_nogate(year, month, day)
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn decode_mjd_bcd_utc(raw: [u8; 5]) -> Option<chrono::DateTime<chrono::Utc>> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
let (mjd_bytes, _) = raw.split_first_chunk::<2>().unwrap();
let mjd = u16::from_be_bytes(*mjd_bytes);
let (y, m, d) = mjd_to_ymd(mjd);
let h = from_bcd_byte(raw[2])?;
let mi = from_bcd_byte(raw[3])?;
let s = from_bcd_byte(raw[4])?;
let date = NaiveDate::from_ymd_opt(y, m, d)?;
let time = NaiveTime::from_hms_opt(u32::from(h), u32::from(mi), u32::from(s))?;
chrono::Utc
.from_local_datetime(&NaiveDateTime::new(date, time))
.single()
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn encode_mjd_bcd_utc(dt: chrono::DateTime<chrono::Utc>) -> Option<[u8; 5]> {
use chrono::{Datelike, Timelike};
let naive = dt.naive_utc();
let mjd = ymd_to_mjd(naive.year(), naive.month(), naive.day())?;
let [m0, m1] = mjd.to_be_bytes();
Some([
m0,
m1,
to_bcd_byte(naive.hour() as u8)?,
to_bcd_byte(naive.minute() as u8)?,
to_bcd_byte(naive.second() as u8)?,
])
}
pub const SECS_2000_EPOCH: i64 = 946_684_800;
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn decode_seconds_since_2000_utc(
seconds_since_2000: u64,
subsec_nanos: u32,
utco: u16,
) -> Option<chrono::DateTime<chrono::Utc>> {
use chrono::TimeZone;
let unix_secs = SECS_2000_EPOCH
.checked_add(i64::try_from(seconds_since_2000).ok()?)?
.checked_sub(i64::from(utco))?;
chrono::Utc.timestamp_opt(unix_secs, subsec_nanos).single()
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[must_use]
pub fn encode_seconds_since_2000_utc(
dt: chrono::DateTime<chrono::Utc>,
utco: u16,
) -> Option<(u64, u32)> {
use chrono::Timelike;
let unix_secs = dt.timestamp();
let raw = unix_secs
.checked_sub(SECS_2000_EPOCH)?
.checked_add(i64::from(utco))?;
let secs = u64::try_from(raw).ok()?;
if secs > 0xFF_FFFF_FFFF {
return None;
}
Some((secs, dt.nanosecond()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_round_trips() {
for &(h, m, s) in &[(0u64, 0u64, 0u64), (1, 30, 45), (99, 59, 59), (2, 0, 0)] {
let secs = h * 3600 + m * 60 + s;
let raw = encode_bcd_duration(Duration::from_secs(secs)).expect("encodes");
assert_eq!(decode_bcd_duration(raw), Some(Duration::from_secs(secs)));
}
}
#[test]
fn duration_decode_known_vector() {
assert_eq!(
decode_bcd_duration([0x01, 0x30, 0x45]),
Some(Duration::from_secs(5445))
);
}
#[test]
fn duration_rejects_over_99h_and_bad_fields() {
assert_eq!(encode_bcd_duration(Duration::from_secs(100 * 3600)), None);
assert_eq!(decode_bcd_duration([0x01, 0x75, 0x00]), None); assert_eq!(decode_bcd_duration([0x01, 0x00, 0x1A]), None); }
#[cfg(feature = "chrono")]
#[test]
fn ymd_to_mjd_matches_chrono_epoch_arithmetic() {
use chrono::NaiveDate;
let epoch = NaiveDate::from_ymd_opt(1858, 11, 17).unwrap();
for &(y, m, d) in &[(1993, 10, 13), (2000, 1, 1), (2023, 6, 8), (1949, 1, 23)] {
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
let full = (date - epoch).num_days() as u32;
let expected = u16::try_from(if full >= 0x1_0000 {
full - 0x1_0000
} else {
full
})
.unwrap();
assert_eq!(ymd_to_mjd(y, m, d), Some(expected), "{y}-{m}-{d}");
}
}
#[cfg(feature = "chrono")]
#[test]
fn mjd_ymd_round_trips() {
for mjd in [40_587u16, 49_273, 51_544, 59_945, 60_000] {
let (y, m, d) = mjd_to_ymd(mjd);
assert_eq!(ymd_to_mjd(y, m, d), Some(mjd), "mjd {mjd}");
}
}
#[cfg(feature = "chrono")]
#[test]
fn utc_round_trips() {
let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
let dt = decode_mjd_bcd_utc(raw).expect("decodes");
assert_eq!(encode_mjd_bcd_utc(dt), Some(raw));
}
#[cfg(feature = "chrono")]
#[test]
fn utc_decode_known_vector() {
use chrono::{Datelike, Timelike};
let dt = decode_mjd_bcd_utc([0xE4, 0x09, 0x12, 0x34, 0x56]).expect("decodes");
assert_eq!((dt.hour(), dt.minute(), dt.second()), (12, 34, 56));
assert_eq!(dt.year(), 2018);
}
#[test]
fn spec_v1_20_1_2040_example() {
let dt = MjdBcdDateTime {
year: 2040,
month: 12,
day: 25,
hour: 12,
minute: 34,
second: 56,
};
let raw = [0x03, 0xD1, 0x12, 0x34, 0x56];
assert_eq!(decode_mjd_bcd(raw), Some(dt));
assert_eq!(encode_mjd_bcd(dt), Some(raw));
let old = MjdBcdDateTime {
year: 1993,
month: 10,
day: 13,
hour: 12,
minute: 45,
second: 0,
};
assert_eq!(decode_mjd_bcd([0xC0, 0x79, 0x12, 0x45, 0x00]), Some(old));
let a = decode_mjd_bcd([0xFF, 0xFF, 0x00, 0x00, 0x00]).unwrap();
let b = decode_mjd_bcd([0x00, 0x00, 0x00, 0x00, 0x00]).unwrap();
assert_eq!((a.year, a.month, a.day), (2038, 4, 22));
assert_eq!((b.year, b.month, b.day), (2038, 4, 23));
assert_eq!(
encode_mjd_bcd(MjdBcdDateTime {
year: 1900,
month: 3,
day: 1,
hour: 0,
minute: 0,
second: 0
}),
None
);
}
#[test]
fn encode_rejects_calendar_impossible_days() {
let mk = |year, month, day| MjdBcdDateTime {
year,
month,
day,
hour: 0,
minute: 0,
second: 0,
};
assert_eq!(encode_mjd_bcd(mk(2023, 2, 31)), None);
assert_eq!(encode_mjd_bcd(mk(2023, 4, 31)), None);
assert_eq!(encode_mjd_bcd(mk(2023, 2, 29)), None);
assert!(encode_mjd_bcd(mk(2024, 2, 29)).is_some());
assert!(encode_mjd_bcd(mk(2023, 1, 31)).is_some());
}
#[test]
fn mjd_bcd_round_trips() {
for &(y, m, d, h, mi, s) in &[
(2023u16, 1u8, 1u8, 12u8, 34u8, 56u8),
(2000, 1, 1, 0, 0, 0),
(2023, 6, 8, 23, 59, 59),
] {
let dt = MjdBcdDateTime {
year: y,
month: m,
day: d,
hour: h,
minute: mi,
second: s,
};
let raw = encode_mjd_bcd(dt).expect("encodes");
let re = decode_mjd_bcd(raw).expect("decodes");
assert_eq!(re, dt);
}
}
#[test]
fn mjd_bcd_rejects_invalid_bcd() {
assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x1A, 0x34, 0x56]), None);
assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x12, 0x75, 0x56]), None);
}
#[test]
fn mjd_bcd_encode_rejects_out_of_range_time_fields() {
let base = MjdBcdDateTime {
year: 2023,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
};
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { hour: 90, ..base }), None);
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { hour: 24, ..base }), None);
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { minute: 90, ..base }), None);
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { minute: 60, ..base }), None);
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { second: 90, ..base }), None);
assert_eq!(encode_mjd_bcd(MjdBcdDateTime { second: 60, ..base }), None);
assert!(encode_mjd_bcd(MjdBcdDateTime { hour: 23, ..base }).is_some());
assert!(encode_mjd_bcd(MjdBcdDateTime { minute: 59, ..base }).is_some());
assert!(encode_mjd_bcd(MjdBcdDateTime { second: 59, ..base }).is_some());
}
#[test]
fn mjd_bcd_matches_chrono_when_available() {
let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
let plain = decode_mjd_bcd(raw).expect("decodes");
#[cfg(feature = "chrono")]
{
use chrono::{Datelike, Timelike};
let chrono_dt = decode_mjd_bcd_utc(raw).expect("decodes");
assert_eq!(plain.year as i32, chrono_dt.year());
assert_eq!(plain.month as u32, chrono_dt.month());
assert_eq!(plain.day as u32, chrono_dt.day());
assert_eq!(plain.hour as u32, chrono_dt.hour());
assert_eq!(plain.minute as u32, chrono_dt.minute());
assert_eq!(plain.second as u32, chrono_dt.second());
}
assert_eq!(plain.year, 2018);
assert_eq!(plain.month, 9);
assert_eq!(plain.day, 16);
}
#[cfg(feature = "chrono")]
#[test]
fn secs_2000_epoch_is_correct() {
use chrono::{Datelike, TimeZone, Timelike};
let dt = chrono::Utc.timestamp_opt(SECS_2000_EPOCH, 0).unwrap();
assert_eq!((dt.year(), dt.month(), dt.day()), (2000, 1, 1));
assert_eq!((dt.hour(), dt.minute(), dt.second()), (0, 0, 0));
}
#[cfg(feature = "chrono")]
#[test]
fn decode_seconds_since_2000_utc_known_value() {
use chrono::{Datelike, Timelike};
let dt = decode_seconds_since_2000_utc(0, 0, 0).expect("decodes");
assert_eq!((dt.year(), dt.month(), dt.day()), (2000, 1, 1));
assert_eq!((dt.hour(), dt.minute(), dt.second()), (0, 0, 0));
}
#[cfg(feature = "chrono")]
#[test]
fn encode_decode_seconds_since_2000_utc_round_trips() {
use chrono::TimeZone;
let dt = chrono::Utc
.with_ymd_and_hms(2023, 6, 8, 12, 34, 56)
.unwrap();
let (secs, nanos) = encode_seconds_since_2000_utc(dt, 37).expect("encodes");
let decoded = decode_seconds_since_2000_utc(secs, nanos, 37).expect("decodes");
assert_eq!(decoded, dt);
}
}