use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::error::{Result, SankhyaError};
pub const GREAT_YEAR_YEARS: f64 = 25_920.0;
pub const GREAT_YEAR_DAYS: f64 = 25_920.0 * 365.25;
pub const PRECESSION_RATE_DEG_PER_YEAR: f64 = 1.0 / 72.0;
pub const PRECESSIONAL_AGE_YEARS: f64 = 2_160.0;
pub const PRECESSIONAL_AGE_DAYS: f64 = 2_160.0 * 365.25;
pub const YOUNGER_DRYAS_JDN: f64 = 2_451_545.0 - 12_800.0 * 365.25;
pub const YOUNGER_DRYAS_BP: f64 = 12_800.0;
pub const J2000_JDN: f64 = 2_451_545.0;
pub const JULIAN_YEAR_DAYS: f64 = 365.25;
pub const BP_REFERENCE_JDN: f64 = 2_433_282.5;
pub const SYNODIC_MONTH_DAYS: f64 = 29.530_588_86;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PrecessionalAge {
Leo,
Cancer,
Gemini,
Taurus,
Aries,
Pisces,
Aquarius,
Capricorn,
Sagittarius,
Scorpio,
Libra,
Virgo,
}
const AGE_ORDER: [PrecessionalAge; 12] = [
PrecessionalAge::Leo,
PrecessionalAge::Cancer,
PrecessionalAge::Gemini,
PrecessionalAge::Taurus,
PrecessionalAge::Aries,
PrecessionalAge::Pisces,
PrecessionalAge::Aquarius,
PrecessionalAge::Capricorn,
PrecessionalAge::Sagittarius,
PrecessionalAge::Scorpio,
PrecessionalAge::Libra,
PrecessionalAge::Virgo,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Civilization {
Vedic,
Babylonian,
Egyptian,
Mayan,
Greek,
Chinese,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CycleName {
Precession,
Sothic,
Saros,
VenusSynodic,
CalendarRound,
Metonic,
PrecessionalAge,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagesTradition {
pub civilization: Civilization,
pub group_name: Cow<'static, str>,
pub sage_names: Vec<Cow<'static, str>>,
pub source_texts: Vec<Cow<'static, str>>,
pub associated_stars: Cow<'static, str>,
pub catastrophe_narrative: Cow<'static, str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AgePosition {
pub age: PrecessionalAge,
pub years_into_age: f64,
pub fraction: f64,
pub vernal_point_longitude: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MultiCalendarDate {
pub jdn: f64,
pub mayan_long_count: Option<crate::mayan::LongCount>,
pub tzolkin: Option<crate::mayan::Tzolkin>,
pub haab: Option<crate::mayan::Haab>,
pub sothic_position: (i32, u32, f64),
pub precessional_age: AgePosition,
pub julian_year: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CycleAlignment {
pub jdn: f64,
pub cycles: Vec<CycleName>,
pub max_residual_days: f64,
}
#[must_use]
pub fn seven_sages() -> Vec<SagesTradition> {
use Cow::Borrowed as B;
vec![
SagesTradition {
civilization: Civilization::Vedic,
group_name: B("Saptarishi"),
sage_names: vec![
B("Marichi"),
B("Atri"),
B("Angiras"),
B("Pulastya"),
B("Pulaha"),
B("Kratu"),
B("Vasishtha"),
],
source_texts: vec![
B("Shatapatha Brahmana"),
B("Matsya Purana"),
B("Vishnu Purana"),
],
associated_stars: B("Ursa Major (Saptarishi Mandala)"),
catastrophe_narrative: B(
"Survived the pralaya (great dissolution) guided by Matsya (fish avatar of Vishnu); restarted the Vedic tradition after the flood.",
),
},
SagesTradition {
civilization: Civilization::Babylonian,
group_name: B("Apkallu"),
sage_names: vec![
B("Adapa (Uanna)"),
B("Uannedugga"),
B("Enmedugga"),
B("Enmegalamma"),
B("Enmebulugga"),
B("An-Enlilda"),
B("Utuabzu"),
],
source_texts: vec![
B("Bit Meseri incantations"),
B("Berossus Babyloniaca"),
B("Eridu Genesis"),
],
associated_stars: B("Associated with Ea/Enki and the Abzu (cosmic waters)"),
catastrophe_narrative: B(
"Seven antediluvian sages sent by Ea before the Great Flood; taught the arts of civilization to humanity.",
),
},
SagesTradition {
civilization: Civilization::Egyptian,
group_name: B("Shemsu Hor"),
sage_names: vec![B(
"The Seven Builder Gods (individual names partially lost)",
)],
source_texts: vec![
B("Edfu Building Texts"),
B("Turin King List (Zep Tepi rulers)"),
],
associated_stars: B("Sirius (Sopdet) and Orion (Sah)"),
catastrophe_narrative: B(
"The Edfu texts describe seven sages arriving from an island destroyed by flood in Zep Tepi (the First Time), who established the sacred mounds.",
),
},
SagesTradition {
civilization: Civilization::Mayan,
group_name: B("Popol Vuh Creators"),
sage_names: vec![
B("Tepeu"),
B("Gucumatz"),
B("Huracan"),
B("Chipi-Caculha"),
B("Raxa-Caculha"),
B("Ixpiyacoc"),
B("Ixmucane"),
],
source_texts: vec![B("Popol Vuh"), B("Chilam Balam")],
associated_stars: B("Pleiades (Tzab-ek) and Orion"),
catastrophe_narrative: B(
"Multiple cycles of creation and destruction; the current (fourth) world was preceded by a great flood that destroyed the wooden people.",
),
},
SagesTradition {
civilization: Civilization::Greek,
group_name: B("Seven Sages of Greece"),
sage_names: vec![
B("Thales"),
B("Solon"),
B("Chilon"),
B("Bias"),
B("Cleobulus"),
B("Pittacus"),
B("Periander"),
],
source_texts: vec![
B("Plato Timaeus"),
B("Ovid Metamorphoses"),
B("Diogenes Laertius"),
],
associated_stars: B("Ursa Major (Arktos) and Pleiades"),
catastrophe_narrative: B(
"Deucalion and Pyrrha survived the Great Flood sent by Zeus; Plato's Timaeus describes the destruction of Atlantis and cyclical catastrophes.",
),
},
SagesTradition {
civilization: Civilization::Chinese,
group_name: B("Fuxi and Nuwa"),
sage_names: vec![
B("Fuxi"),
B("Nuwa"),
B("Shennong"),
B("Huangdi"),
B("Yao"),
B("Shun"),
B("Yu the Great"),
],
source_texts: vec![
B("Huainanzi"),
B("Shanhaijing"),
B("Shujing (Book of Documents)"),
],
associated_stars: B("Beidou (Northern Dipper / Ursa Major)"),
catastrophe_narrative: B(
"Fuxi and Nuwa survived the Great Flood; Nuwa repaired the broken sky. Yu the Great later tamed the floodwaters and founded civilization.",
),
},
]
}
#[must_use]
pub fn sages_tradition(civ: Civilization) -> Option<SagesTradition> {
seven_sages().into_iter().find(|s| s.civilization == civ)
}
#[must_use]
pub fn all_sages_traditions() -> Vec<SagesTradition> {
seven_sages()
}
#[must_use]
#[inline]
pub fn bp_to_jdn(years_bp: f64) -> f64 {
BP_REFERENCE_JDN - years_bp * JULIAN_YEAR_DAYS
}
#[must_use]
#[inline]
pub fn jdn_to_bp(jdn: f64) -> f64 {
(BP_REFERENCE_JDN - jdn) / JULIAN_YEAR_DAYS
}
#[must_use]
#[inline]
pub fn julian_year_to_jdn(year: i64) -> f64 {
J2000_JDN + (year as f64 - 2000.0) * JULIAN_YEAR_DAYS
}
#[must_use]
#[inline]
pub fn jdn_to_julian_year(jdn: f64) -> f64 {
2000.0 + (jdn - J2000_JDN) / JULIAN_YEAR_DAYS
}
#[must_use]
pub fn vernal_point_longitude(jdn: f64) -> f64 {
let years_since_yd = (jdn - YOUNGER_DRYAS_JDN) / JULIAN_YEAR_DAYS;
let longitude = 150.0 - years_since_yd * PRECESSION_RATE_DEG_PER_YEAR;
((longitude % 360.0) + 360.0) % 360.0
}
#[must_use]
pub fn precessional_age(jdn: f64) -> AgePosition {
let years_since_yd = (jdn - YOUNGER_DRYAS_JDN) / JULIAN_YEAR_DAYS;
let cycle_years = ((years_since_yd % GREAT_YEAR_YEARS) + GREAT_YEAR_YEARS) % GREAT_YEAR_YEARS;
let age_index = (cycle_years / PRECESSIONAL_AGE_YEARS) as usize;
let age_index = if age_index >= 12 { 11 } else { age_index };
let years_into_age = cycle_years - (age_index as f64) * PRECESSIONAL_AGE_YEARS;
AgePosition {
age: AGE_ORDER[age_index],
years_into_age,
fraction: years_into_age / PRECESSIONAL_AGE_YEARS,
vernal_point_longitude: vernal_point_longitude(jdn),
}
}
#[must_use]
pub fn age_start_jdn(age: PrecessionalAge) -> f64 {
let index = AGE_ORDER.iter().position(|&a| a == age).unwrap_or(0);
YOUNGER_DRYAS_JDN + (index as f64) * PRECESSIONAL_AGE_DAYS
}
#[must_use]
pub fn cycle_period(cycle: CycleName) -> f64 {
match cycle {
CycleName::Precession => GREAT_YEAR_DAYS,
CycleName::Sothic => crate::egyptian::SOTHIC_CYCLE_DAYS as f64,
CycleName::Saros => crate::babylonian::SAROS_DAYS,
CycleName::VenusSynodic => crate::mayan::VENUS_SYNODIC_PERIOD,
CycleName::CalendarRound => crate::mayan::CALENDAR_ROUND_DAYS as f64,
CycleName::Metonic => 19.0 * JULIAN_YEAR_DAYS,
CycleName::PrecessionalAge => PRECESSIONAL_AGE_DAYS,
}
}
#[must_use]
pub fn cycles_elapsed(
jdn: f64,
reference_jdn: f64,
cycles: &[CycleName],
) -> Vec<(CycleName, u64, f64)> {
let elapsed_days = jdn - reference_jdn;
cycles
.iter()
.map(|&cycle| {
let period = cycle_period(cycle);
if period <= 0.0 {
return (cycle, 0, elapsed_days);
}
let complete = (elapsed_days / period).floor();
let remainder = elapsed_days - complete * period;
let complete_u64 = if complete < 0.0 { 0 } else { complete as u64 };
(cycle, complete_u64, remainder)
})
.collect()
}
pub fn find_cycle_alignments(
cycles: &[CycleName],
start_jdn: f64,
end_jdn: f64,
tolerance_days: f64,
) -> Result<Vec<CycleAlignment>> {
if start_jdn >= end_jdn {
return Err(SankhyaError::InvalidDate(
"start_jdn must be before end_jdn".into(),
));
}
if cycles.len() < 2 {
return Err(SankhyaError::ComputationError(
"need at least 2 cycles for alignment search".into(),
));
}
let periods: Vec<f64> = cycles.iter().map(|&c| cycle_period(c)).collect();
let step = periods
.iter()
.copied()
.fold(f64::INFINITY, f64::min)
.max(1.0);
let max_steps = 10_000_000u64;
let num_steps = ((end_jdn - start_jdn) / step).ceil() as u64;
if num_steps > max_steps {
return Err(SankhyaError::ComputationError(format!(
"search space too large: {num_steps} steps (max {max_steps})"
)));
}
let mut alignments = Vec::new();
let mut jdn = start_jdn;
while jdn < end_jdn {
let mut max_residual = 0.0_f64;
let mut all_aligned = true;
for &period in &periods {
let elapsed = jdn - start_jdn;
let ratio = elapsed / period;
let residual = (ratio - ratio.round()).abs() * period;
if residual > tolerance_days {
all_aligned = false;
break;
}
max_residual = max_residual.max(residual);
}
if all_aligned {
alignments.push(CycleAlignment {
jdn,
cycles: cycles.to_vec(),
max_residual_days: max_residual,
});
}
jdn += step;
}
Ok(alignments)
}
pub fn correlate(jdn: f64) -> Result<MultiCalendarDate> {
let mayan_epoch = crate::mayan::EPOCH_JDN as f64;
let (mayan_lc, tzolkin, haab) = if jdn >= mayan_epoch {
let jdn_u64 = jdn as u64;
let days = jdn_u64 - crate::mayan::EPOCH_JDN;
let lc = crate::mayan::LongCount::from_days(days)
.map_err(|e| SankhyaError::ComputationError(format!("Mayan Long Count: {e}")))?;
let tz = crate::mayan::Tzolkin::from_days(days);
let hb = crate::mayan::Haab::from_days(days);
(Some(lc), Some(tz), Some(hb))
} else {
(None, None, None)
};
let sothic = crate::egyptian::sothic_position(jdn);
let age = precessional_age(jdn);
let year = jdn_to_julian_year(jdn);
Ok(MultiCalendarDate {
jdn,
mayan_long_count: mayan_lc,
tzolkin,
haab,
sothic_position: sothic,
precessional_age: age,
julian_year: year,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn great_year_days_consistent() {
assert!((GREAT_YEAR_DAYS - GREAT_YEAR_YEARS * JULIAN_YEAR_DAYS).abs() < 0.01);
}
#[test]
fn twelve_ages_equal_great_year() {
assert!((PRECESSIONAL_AGE_YEARS * 12.0 - GREAT_YEAR_YEARS).abs() < f64::EPSILON);
}
#[test]
fn younger_dryas_jdn_value() {
let expected = 2_451_545.0 - 12_800.0 * 365.25;
assert!((YOUNGER_DRYAS_JDN - expected).abs() < 0.01);
}
#[test]
fn bp_roundtrip() {
let bp = 12_800.0;
let jdn = bp_to_jdn(bp);
let back = jdn_to_bp(jdn);
assert!((back - bp).abs() < 1e-10);
}
#[test]
fn bp_zero_is_1950() {
let jdn = bp_to_jdn(0.0);
assert!((jdn - BP_REFERENCE_JDN).abs() < f64::EPSILON);
}
#[test]
fn julian_year_roundtrip() {
for year in [-3113i64, -753, 0, 139, 2000, 2026] {
let jdn = julian_year_to_jdn(year);
let back = jdn_to_julian_year(jdn);
assert!(
(back - year as f64).abs() < 0.01,
"roundtrip failed for {year}"
);
}
}
#[test]
fn age_at_younger_dryas_is_leo() {
let pos = precessional_age(YOUNGER_DRYAS_JDN);
assert_eq!(pos.age, PrecessionalAge::Leo);
assert!(pos.years_into_age < 1.0);
}
#[test]
fn age_at_0_ce_is_pisces() {
let jdn = julian_year_to_jdn(1); let pos = precessional_age(jdn);
assert_eq!(pos.age, PrecessionalAge::Pisces);
}
#[test]
fn age_at_2500_bce_is_taurus() {
let jdn = julian_year_to_jdn(-2499); let pos = precessional_age(jdn);
assert_eq!(pos.age, PrecessionalAge::Taurus);
}
#[test]
fn age_fraction_bounds() {
let pos = precessional_age(J2000_JDN);
assert!(pos.fraction >= 0.0);
assert!(pos.fraction <= 1.0);
}
#[test]
fn vernal_longitude_wraps() {
let lon = vernal_point_longitude(J2000_JDN);
assert!(lon >= 0.0);
assert!(lon < 360.0);
}
#[test]
fn full_precession_cycle_returns_to_same_age() {
let jdn = J2000_JDN;
let age1 = precessional_age(jdn);
let age2 = precessional_age(jdn + GREAT_YEAR_DAYS);
assert_eq!(age1.age, age2.age);
}
#[test]
fn all_sages_count() {
assert_eq!(all_sages_traditions().len(), 6);
}
#[test]
fn sages_vedic_saptarishi() {
let vedic = sages_tradition(Civilization::Vedic).unwrap();
assert_eq!(vedic.group_name, "Saptarishi");
assert_eq!(vedic.sage_names.len(), 7);
}
#[test]
fn sages_babylonian_apkallu() {
let bab = sages_tradition(Civilization::Babylonian).unwrap();
assert_eq!(bab.group_name, "Apkallu");
assert_eq!(bab.sage_names.len(), 7);
}
#[test]
fn sages_egyptian_shemsu_hor() {
let egy = sages_tradition(Civilization::Egyptian).unwrap();
assert_eq!(egy.group_name, "Shemsu Hor");
}
#[test]
fn sages_all_have_nonempty_names() {
for sage in all_sages_traditions() {
assert!(!sage.group_name.is_empty());
assert!(!sage.sage_names.is_empty());
assert!(!sage.source_texts.is_empty());
}
}
#[test]
fn cycle_period_saros_matches_babylonian() {
assert!(
(cycle_period(CycleName::Saros) - crate::babylonian::SAROS_DAYS).abs() < f64::EPSILON
);
}
#[test]
fn cycle_period_sothic_matches_egyptian() {
assert!(
(cycle_period(CycleName::Sothic) - crate::egyptian::SOTHIC_CYCLE_DAYS as f64).abs()
< f64::EPSILON
);
}
#[test]
fn cycles_elapsed_basic() {
let result = cycles_elapsed(
J2000_JDN + 365.25 * 19.0, J2000_JDN,
&[CycleName::Metonic],
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].1, 1); assert!(result[0].2.abs() < 1.0); }
#[test]
fn correlate_at_mayan_creation() {
let date = correlate(crate::mayan::EPOCH_JDN as f64).unwrap();
let lc = date.mayan_long_count.unwrap();
assert_eq!(lc.baktun, 0);
assert_eq!(lc.katun, 0);
assert_eq!(lc.kin, 0);
}
#[test]
fn correlate_before_mayan_epoch() {
let date = correlate(0.0).unwrap();
assert!(date.mayan_long_count.is_none());
assert!(date.tzolkin.is_none());
assert!(date.haab.is_none());
}
#[test]
fn correlate_at_younger_dryas() {
let date = correlate(YOUNGER_DRYAS_JDN).unwrap();
assert_eq!(date.precessional_age.age, PrecessionalAge::Leo);
assert!(date.mayan_long_count.is_none()); }
#[test]
fn serde_roundtrip_age_position() {
let pos = precessional_age(J2000_JDN);
let json = serde_json::to_string(&pos).unwrap();
let back: AgePosition = serde_json::from_str(&json).unwrap();
assert_eq!(pos.age, back.age);
assert!((pos.years_into_age - back.years_into_age).abs() < 1e-10);
assert!((pos.fraction - back.fraction).abs() < 1e-10);
}
#[test]
fn serde_roundtrip_sages_tradition() {
let vedic = sages_tradition(Civilization::Vedic).unwrap();
let json = serde_json::to_string(&vedic).unwrap();
let back: SagesTradition = serde_json::from_str(&json).unwrap();
assert_eq!(vedic, back);
}
#[test]
fn serde_roundtrip_multi_calendar_date() {
let date = correlate(crate::mayan::EPOCH_JDN as f64).unwrap();
let json = serde_json::to_string(&date).unwrap();
let back: MultiCalendarDate = serde_json::from_str(&json).unwrap();
assert_eq!(date, back);
}
}