use serde::{Deserialize, Serialize};
use crate::error::{Result, SankhyaError};
pub const COPTIC_EPOCH_JDN: f64 = 1_825_029.5;
const COPTIC_MONTH_DAYS: [u8; 13] = [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 5];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CopticMonth {
Thout,
Paopi,
Hathor,
Koiak,
Tobi,
Meshir,
Paremhat,
Parmouti,
Pashons,
Paoni,
Epip,
Mesori,
Nasie,
}
const COPTIC_MONTHS: [CopticMonth; 13] = [
CopticMonth::Thout,
CopticMonth::Paopi,
CopticMonth::Hathor,
CopticMonth::Koiak,
CopticMonth::Tobi,
CopticMonth::Meshir,
CopticMonth::Paremhat,
CopticMonth::Parmouti,
CopticMonth::Pashons,
CopticMonth::Paoni,
CopticMonth::Epip,
CopticMonth::Mesori,
CopticMonth::Nasie,
];
impl core::fmt::Display for CopticMonth {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Thout => "Thout",
Self::Paopi => "Paopi",
Self::Hathor => "Hathor",
Self::Koiak => "Koiak",
Self::Tobi => "Tobi",
Self::Meshir => "Meshir",
Self::Paremhat => "Paremhat",
Self::Parmouti => "Parmouti",
Self::Pashons => "Pashons",
Self::Paoni => "Paoni",
Self::Epip => "Epip",
Self::Mesori => "Mesori",
Self::Nasie => "Nasie",
};
write!(f, "{name}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CopticDate {
pub year: i64,
pub month: CopticMonth,
pub day: u8,
}
impl core::fmt::Display for CopticDate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{} {} {} AM", self.day, self.month, self.year)
}
}
#[must_use]
#[inline]
pub fn coptic_is_leap(year: i64) -> bool {
year.rem_euclid(4) == 3
}
#[must_use]
#[inline]
pub fn coptic_year_days(year: i64) -> u16 {
if coptic_is_leap(year) { 366 } else { 365 }
}
#[must_use]
pub fn jdn_to_coptic(jdn: f64) -> CopticDate {
tracing::trace!(jdn, "JDN to Coptic");
let days_since_epoch = (jdn - COPTIC_EPOCH_JDN).floor() as i64;
let year_start = |year: i64| -> i64 {
let y0 = year - 1;
365 * y0 + year.div_euclid(4)
};
let mut year = days_since_epoch.div_euclid(366) + 1;
while year_start(year + 1) <= days_since_epoch {
year += 1;
}
while year_start(year) > days_since_epoch {
year -= 1;
}
let day_of_year = days_since_epoch - year_start(year);
let month_idx = (day_of_year / 30).min(12) as usize;
let day = (day_of_year - (month_idx as i64) * 30 + 1) as u8;
CopticDate {
year,
month: COPTIC_MONTHS[month_idx],
day,
}
}
#[must_use = "returns the JDN or an error"]
pub fn coptic_to_jdn(date: &CopticDate) -> Result<f64> {
tracing::trace!(year = date.year, ?date.month, day = date.day, "Coptic to JDN");
let month_idx = COPTIC_MONTHS
.iter()
.position(|&m| m == date.month)
.unwrap_or(0);
let max_day = if month_idx == 12 && coptic_is_leap(date.year) {
6
} else {
COPTIC_MONTH_DAYS[month_idx]
};
if date.day == 0 || date.day > max_day {
return Err(SankhyaError::InvalidDate(format!(
"day {} out of range for {} in year {} AM (max {max_day})",
date.day, date.month, date.year
)));
}
let y = date.year - 1; let days = 365 * y + date.year.div_euclid(4) + 30 * month_idx as i64 + i64::from(date.day) - 1;
Ok(COPTIC_EPOCH_JDN + days as f64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_roundtrip() {
let date = jdn_to_coptic(COPTIC_EPOCH_JDN);
assert_eq!(date.year, 1);
assert_eq!(date.month, CopticMonth::Thout);
assert_eq!(date.day, 1);
let jdn = coptic_to_jdn(&date).unwrap();
assert!((jdn - COPTIC_EPOCH_JDN).abs() < f64::EPSILON);
}
#[test]
fn known_date_cross_check() {
let date = jdn_to_coptic(2_460_564.5);
assert_eq!(date.year, 1741);
assert_eq!(date.month, CopticMonth::Thout);
assert_eq!(date.day, 1);
}
#[test]
fn leap_year_rules() {
assert!(coptic_is_leap(3)); assert!(coptic_is_leap(7));
assert!(!coptic_is_leap(1));
assert!(!coptic_is_leap(2));
assert!(!coptic_is_leap(4));
}
#[test]
fn year_days() {
assert_eq!(coptic_year_days(1), 365);
assert_eq!(coptic_year_days(3), 366);
assert_eq!(coptic_year_days(4), 365);
assert_eq!(coptic_year_days(7), 366);
}
#[test]
fn nasie_leap_6_days() {
let date = CopticDate {
year: 3,
month: CopticMonth::Nasie,
day: 6,
};
assert!(coptic_to_jdn(&date).is_ok());
}
#[test]
fn nasie_common_rejects_6() {
let date = CopticDate {
year: 1,
month: CopticMonth::Nasie,
day: 6,
};
assert!(coptic_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_zero() {
let date = CopticDate {
year: 1,
month: CopticMonth::Thout,
day: 0,
};
assert!(coptic_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_31() {
let date = CopticDate {
year: 1,
month: CopticMonth::Thout,
day: 31,
};
assert!(coptic_to_jdn(&date).is_err());
}
#[test]
fn roundtrip_sequential_days() {
for offset in 0..1500 {
let jdn = COPTIC_EPOCH_JDN + f64::from(offset);
let date = jdn_to_coptic(jdn);
let back = coptic_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: {date}"
);
}
}
#[test]
fn roundtrip_wide_range() {
for jdn_int in (1_000_000..3_000_000).step_by(7_777) {
let jdn = jdn_int as f64 + 0.5;
let date = jdn_to_coptic(jdn);
let back = coptic_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: {date}"
);
}
}
#[test]
fn display_format() {
let date = CopticDate {
year: 1741,
month: CopticMonth::Thout,
day: 1,
};
assert_eq!(date.to_string(), "1 Thout 1741 AM");
}
#[test]
fn serde_roundtrip() {
let date = jdn_to_coptic(COPTIC_EPOCH_JDN + 500.0);
let json = serde_json::to_string(&date).unwrap();
let back: CopticDate = serde_json::from_str(&json).unwrap();
assert_eq!(date, back);
}
#[test]
fn month_display() {
assert_eq!(CopticMonth::Thout.to_string(), "Thout");
assert_eq!(CopticMonth::Nasie.to_string(), "Nasie");
assert_eq!(CopticMonth::Mesori.to_string(), "Mesori");
}
}