use crate::calendar::validate_solar_date;
use crate::convert::solar_to_lunar;
use crate::date::SolarDate;
use crate::error::LunarError;
use crate::julian_day::day_pillar_offset;
use crate::sexagenary::StemBranch;
use crate::solar_terms::{self, LI_CHUN, MONTH_BRANCH_BEFORE_FIRST_JIE, MONTH_BRANCH_OF_JIE};
use crate::stem_branch::{EarthlyBranch, HeavenlyStem};
const MAX_TIME_INDEX: u8 = 12;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum YearDivide {
Normal,
Exact,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum MonthDivide {
Normal,
Exact,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct StemBranchOptions {
pub year: YearDivide,
pub month: MonthDivide,
}
impl Default for StemBranchOptions {
fn default() -> Self {
Self {
year: YearDivide::Exact,
month: MonthDivide::Exact,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct FourPillars {
pub yearly: StemBranch,
pub monthly: StemBranch,
pub daily: StemBranch,
pub hourly: StemBranch,
}
pub type HeavenlyStemAndEarthlyBranchDate = FourPillars;
pub fn get_heavenly_stem_and_earthly_branch_by_solar_date(
solar: SolarDate,
time_index: u8,
) -> Result<FourPillars, LunarError> {
get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar,
time_index,
StemBranchOptions::default(),
)
}
pub fn get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar: SolarDate,
time_index: u8,
options: StemBranchOptions,
) -> Result<FourPillars, LunarError> {
validate_solar_date(solar)?;
if time_index > MAX_TIME_INDEX {
return Err(LunarError::InvalidTimeIndex { time_index });
}
if !(solar_terms::MIN_YEAR..=solar_terms::MAX_YEAR).contains(&solar.year) {
return Err(LunarError::SolarTermOutOfRange { year: solar.year });
}
let synth_hour = (time_index as i64 * 2 - 1).max(0);
let synth_second_of_day = synth_hour * 3600 + 30 * 60;
let yearly = year_pillar(solar, options.year)?;
let monthly = match options.month {
MonthDivide::Normal => month_pillar_normal(solar, yearly)?,
MonthDivide::Exact => month_pillar_exact(solar, synth_second_of_day)?,
};
let mut day_offset = day_pillar_offset(solar.year, solar.month, solar.day);
if time_index == MAX_TIME_INDEX {
day_offset += 1;
}
let daily = StemBranch::from_cycle_index(day_offset.rem_euclid(60) as usize);
let day_stem_index = day_offset.rem_euclid(10) as usize;
let hour_branch_index = (time_index % 12) as usize;
let hour_stem_index = (day_stem_index % 5 * 2 + hour_branch_index) % 10;
let hourly = pillar_from_indices(hour_stem_index, hour_branch_index);
Ok(FourPillars {
yearly,
monthly,
daily,
hourly,
})
}
pub fn four_pillars_from_solar_date(
solar: SolarDate,
time_index: u8,
) -> Result<FourPillars, LunarError> {
get_heavenly_stem_and_earthly_branch_by_solar_date(solar, time_index)
}
pub fn four_pillars_from_solar_date_with_options(
solar: SolarDate,
time_index: u8,
options: StemBranchOptions,
) -> Result<FourPillars, LunarError> {
get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(solar, time_index, options)
}
fn year_pillar(solar: SolarDate, divide: YearDivide) -> Result<StemBranch, LunarError> {
match divide {
YearDivide::Normal => Ok(StemBranch::from_lunar_year(solar_to_lunar(solar)?.year)),
YearDivide::Exact => {
let (li_chun_month, li_chun_day) = solar_terms::li_chun_date(solar.year)?;
let before_li_chun = (solar.month, solar.day) < (li_chun_month, li_chun_day);
let pillar_year = if before_li_chun {
solar.year - 1
} else {
solar.year
};
Ok(StemBranch::from_lunar_year(pillar_year))
}
}
}
fn month_pillar_normal(solar: SolarDate, yearly: StemBranch) -> Result<StemBranch, LunarError> {
let lunar = solar_to_lunar(solar)?;
let year_stem = yearly.stem().index();
let yin_stem = (year_stem % 5 * 2 + 2) % 10;
let fix_leap = usize::from(lunar.is_leap_month && lunar.day > 15);
let offset = (lunar.month as usize - 1) + fix_leap;
let stem = (yin_stem + offset) % 10;
let branch = (2 + offset) % 12; Ok(pillar_from_indices(stem, branch))
}
fn month_pillar_exact(
solar: SolarDate,
synth_second_of_day: i64,
) -> Result<StemBranch, LunarError> {
let instant = solar_terms::day_instant(solar.year, solar.month, solar.day, synth_second_of_day);
let jie = solar_terms::jie_instants(solar.year)?;
let mut branch = MONTH_BRANCH_BEFORE_FIRST_JIE;
for (k, &boundary) in jie.iter().enumerate() {
if boundary <= instant {
branch = MONTH_BRANCH_OF_JIE[k];
} else {
break;
}
}
let sui_year = if instant >= jie[LI_CHUN] {
solar.year
} else {
solar.year - 1
};
let sui_stem = (sui_year - 4).rem_euclid(10) as usize;
let yin_stem = (sui_stem % 5 * 2 + 2) % 10;
let offset_from_yin = (branch + 12 - 2) % 12;
let stem = (yin_stem + offset_from_yin) % 10;
Ok(pillar_from_indices(stem, branch))
}
fn pillar_from_indices(stem_index: usize, branch_index: usize) -> StemBranch {
StemBranch::try_new(
HeavenlyStem::from_index(stem_index),
EarthlyBranch::from_index(branch_index),
)
.expect("computed stem and branch share parity by construction")
}
#[cfg(test)]
mod tests {
use super::*;
fn sb(stem: HeavenlyStem, branch: EarthlyBranch) -> StemBranch {
StemBranch::try_new(stem, branch).unwrap()
}
fn solar(year: i32, month: u8, day: u8) -> SolarDate {
SolarDate { year, month, day }
}
const EXACT: StemBranchOptions = StemBranchOptions {
year: YearDivide::Exact,
month: MonthDivide::Exact,
};
const NORMAL: StemBranchOptions = StemBranchOptions {
year: YearDivide::Normal,
month: MonthDivide::Normal,
};
#[test]
fn spot_check_2000_08_16() {
let r = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2000, 8, 16),
2,
EXACT,
)
.unwrap();
assert_eq!(r.yearly, sb(HeavenlyStem::Geng, EarthlyBranch::Chen));
assert_eq!(r.monthly, sb(HeavenlyStem::Jia, EarthlyBranch::Shen));
assert_eq!(r.daily, sb(HeavenlyStem::Bing, EarthlyBranch::Wu));
assert_eq!(r.hourly, sb(HeavenlyStem::Geng, EarthlyBranch::Yin));
let n = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2000, 8, 16),
2,
NORMAL,
)
.unwrap();
assert_eq!(n, r);
}
#[test]
fn late_zi_rolls_day_and_hour() {
let early = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2000, 8, 16),
0,
EXACT,
)
.unwrap();
assert_eq!(early.daily, sb(HeavenlyStem::Bing, EarthlyBranch::Wu));
assert_eq!(early.hourly, sb(HeavenlyStem::Wu, EarthlyBranch::Zi));
let late = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2000, 8, 16),
12,
EXACT,
)
.unwrap();
assert_eq!(late.daily, sb(HeavenlyStem::Ding, EarthlyBranch::Wei));
assert_eq!(late.hourly, sb(HeavenlyStem::Geng, EarthlyBranch::Zi));
}
#[test]
fn all_time_indices_produce_expected_branches() {
for ti in 0..=12u8 {
let r = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2000, 8, 16),
ti,
EXACT,
)
.unwrap();
let expected = EarthlyBranch::from_index((ti % 12) as usize);
assert_eq!(r.hourly.branch(), expected, "time_index {ti}");
}
}
#[test]
fn default_function_equals_explicit_exact_exact() {
let default =
get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2024, 6, 1), 5).unwrap();
let explicit = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2024, 6, 1),
5,
EXACT,
)
.unwrap();
assert_eq!(default, explicit);
}
#[test]
fn aliases_match_primary_functions() {
assert_eq!(
four_pillars_from_solar_date(solar(2024, 6, 1), 5).unwrap(),
get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2024, 6, 1), 5).unwrap(),
);
assert_eq!(
four_pillars_from_solar_date_with_options(solar(2024, 6, 1), 5, NORMAL).unwrap(),
get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
solar(2024, 6, 1),
5,
NORMAL
)
.unwrap(),
);
}
#[test]
fn compatibility_alias_type_is_usable() {
let pillars: HeavenlyStemAndEarthlyBranchDate =
four_pillars_from_solar_date(solar(2000, 8, 16), 2).unwrap();
let native: FourPillars = pillars;
assert_eq!(native.yearly, sb(HeavenlyStem::Geng, EarthlyBranch::Chen));
}
#[test]
fn invalid_time_index_errors() {
assert_eq!(
get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2000, 1, 1), 13),
Err(LunarError::InvalidTimeIndex { time_index: 13 })
);
}
#[test]
fn year_out_of_range_errors() {
assert_eq!(
get_heavenly_stem_and_earthly_branch_by_solar_date(solar(1849, 6, 1), 0),
Err(LunarError::SolarTermOutOfRange { year: 1849 })
);
assert_eq!(
get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2151, 6, 1), 0),
Err(LunarError::SolarTermOutOfRange { year: 2151 })
);
}
#[test]
fn default_options_are_exact_exact() {
assert_eq!(
StemBranchOptions::default(),
StemBranchOptions {
year: YearDivide::Exact,
month: MonthDivide::Exact,
}
);
}
}