use lunar_rust::lunar::LunarRefHelper;
use lunar_rust::lunar_month::LunarMonthRefHelper;
use lunar_rust::lunar_year::{self, LunarYearRefHelper};
use lunar_rust::solar::SolarRefHelper;
use lunar_rust::{lunar, solar};
use crate::astro::context::{self, AstroContext};
use crate::astro::palace::{get_decadals_and_ages, get_palace_names};
use crate::data::constants::{TIGER_RULE, TIME_RANGES};
use crate::data::earthly_branches::get_earthly_branch_info;
use crate::data::stars::StarKey;
use crate::data::types::*;
use crate::error::IztroError;
use crate::i18n::{translate_sign, translate_time, translate_zodiac};
use crate::models::astrolabe::{Astrolabe, RawChineseDate, RawDates, RawLunarDate};
use crate::models::palace::PalaceData;
use crate::models::star::Star;
use crate::star::adjective::get_adjective_stars;
use crate::star::decorative::{get_boshi12, get_changsheng12, get_yearly12};
use crate::star::location::{
get_chang_qu_index, get_daily_star_index, get_huo_ling_index, get_kong_jie_index,
get_kui_yue_index, get_lu_yang_tuo_ma_index, get_luan_xi_index, get_monthly_star_index,
get_start_index, get_timely_star_index, get_yearly_star_index, get_zuo_you_index,
};
use crate::star::major::get_major_stars;
use crate::star::minor::get_minor_stars;
use crate::utils::{fix_index, translate_chinese_date};
fn parse_sign_index(name: &str) -> Result<usize, IztroError> {
const XING_ZUO: [&str; 12] = [
"白羊", "金牛", "双子", "巨蟹", "狮子", "处女", "天秤", "天蝎", "射手", "摩羯", "水瓶",
"双鱼",
];
XING_ZUO
.iter()
.position(|s| *s == name)
.ok_or_else(|| lunar_defect("sign", name))
}
fn lunar_defect(what: &str, value: &str) -> IztroError {
IztroError::Internal(format!("lunar_rust returned an unknown {what}: '{value}'"))
}
pub(crate) fn stem_of(value: &str, what: &str) -> Result<HeavenlyStem, IztroError> {
parse_heavenly_stem(value).ok_or_else(|| lunar_defect(&format!("{what} heavenly stem"), value))
}
pub(crate) fn branch_of(value: &str, what: &str) -> Result<EarthlyBranch, IztroError> {
parse_earthly_branch(value)
.ok_or_else(|| lunar_defect(&format!("{what} earthly branch"), value))
}
const SUPPORTED_YEARS: std::ops::RangeInclusive<i64> = 1583..=9999;
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => 0,
}
}
pub(crate) fn validate_time_index(time_index: u8) -> Result<(), IztroError> {
if time_index > 12 {
return Err(IztroError::InvalidTimeIndex(time_index));
}
Ok(())
}
pub(crate) fn parse_solar_date(solar_date: &str) -> Result<(i64, i64, i64), IztroError> {
let err = |detail: &str| {
IztroError::InvalidDate(format!("invalid solar date '{solar_date}': {detail}"))
};
let parts: Vec<&str> = solar_date.split('-').collect();
if parts.len() != 3 {
return Err(err("expected 'YYYY-M-D'"));
}
let year: i64 = parts[0].parse().map_err(|_| err("year is not a number"))?;
let month: i64 = parts[1].parse().map_err(|_| err("month is not a number"))?;
let day: i64 = parts[2].parse().map_err(|_| err("day is not a number"))?;
if !SUPPORTED_YEARS.contains(&year) {
return Err(err(&format!(
"year must be within {}-{}",
SUPPORTED_YEARS.start(),
SUPPORTED_YEARS.end()
)));
}
if !(1..=12).contains(&month) {
return Err(err("month must be within 1-12"));
}
if day < 1 || day > days_in_month(year, month) {
return Err(err("day is out of range for that month"));
}
Ok((year, month, day))
}
fn parse_lunar_date(lunar_date: &str, is_leap_month: bool) -> Result<(i64, i64, i64), IztroError> {
let err = |detail: &str| {
IztroError::InvalidDate(format!("invalid lunar date '{lunar_date}': {detail}"))
};
let parts: Vec<&str> = lunar_date.split('-').collect();
if parts.len() != 3 {
return Err(err("expected 'YYYY-M-D'"));
}
let year: i64 = parts[0].parse().map_err(|_| err("year is not a number"))?;
let month: i64 = parts[1].parse().map_err(|_| err("month is not a number"))?;
let day: i64 = parts[2].parse().map_err(|_| err("day is not a number"))?;
if !SUPPORTED_YEARS.contains(&year) {
return Err(err(&format!(
"year must be within {}-{}",
SUPPORTED_YEARS.start(),
SUPPORTED_YEARS.end()
)));
}
if !(1..=12).contains(&month) {
return Err(err("month must be within 1-12"));
}
let year_has_this_leap = lunar_year::LunarYear::from_lunar_year(year)
.get_leap_months()
.abs()
== month;
let signed_month = if is_leap_month && year_has_this_leap {
-month
} else {
month
};
let day_count = lunar_year::LunarYear::from_lunar_year(year)
.get_month(signed_month)
.map(|m| m.get_day_count())
.ok_or_else(|| err("month does not exist in that lunar year"))?;
if day < 1 || day > day_count {
return Err(err("day is out of range for that lunar month"));
}
Ok((year, signed_month, day))
}
pub fn parse_heavenly_stem(s: &str) -> Option<HeavenlyStem> {
match s {
"甲" => Some(HeavenlyStem::Jia),
"乙" => Some(HeavenlyStem::Yi),
"丙" => Some(HeavenlyStem::Bing),
"丁" => Some(HeavenlyStem::Ding),
"戊" => Some(HeavenlyStem::Wu),
"己" => Some(HeavenlyStem::Ji),
"庚" => Some(HeavenlyStem::Geng),
"辛" => Some(HeavenlyStem::Xin),
"壬" => Some(HeavenlyStem::Ren),
"癸" => Some(HeavenlyStem::Gui),
_ => None,
}
}
pub fn parse_earthly_branch(s: &str) -> Option<EarthlyBranch> {
match s {
"子" => Some(EarthlyBranch::Zi),
"丑" => Some(EarthlyBranch::Chou),
"寅" => Some(EarthlyBranch::Yin),
"卯" => Some(EarthlyBranch::Mao),
"辰" => Some(EarthlyBranch::Chen),
"巳" => Some(EarthlyBranch::Si),
"午" => Some(EarthlyBranch::Wu),
"未" => Some(EarthlyBranch::Wei),
"申" => Some(EarthlyBranch::Shen),
"酉" => Some(EarthlyBranch::You),
"戌" => Some(EarthlyBranch::Xu),
"亥" => Some(EarthlyBranch::Hai),
_ => None,
}
}
pub fn fix_lunar_month_index(
lunar_month: u32,
lunar_day: u32,
is_leap: bool,
time_index: u8,
fix_leap: bool,
) -> usize {
let need_to_add = is_leap && fix_leap && lunar_day > 15 && time_index != 12;
fix_index(lunar_month as i32 - 1 + if need_to_add { 1 } else { 0 }, 12)
}
pub fn fix_lunar_day_index(lunar_day: u32, time_index: u8) -> u32 {
if time_index >= 12 {
lunar_day
} else {
lunar_day.saturating_sub(1)
}
}
pub(crate) fn time_index_to_hour(time_index: u8) -> i64 {
match time_index {
0 => 0,
12 => 23,
i => (i as i64) * 2 - 1,
}
}
struct PlacedStars {
major: [Vec<Star>; 12],
minor: [Vec<Star>; 12],
adjective: [Vec<Star>; 12],
changsheng12: [StarKey; 12],
boshi12: [StarKey; 12],
suiqian12: [StarKey; 12],
jiangqian12: [StarKey; 12],
}
struct Pillars {
monthly: (HeavenlyStem, EarthlyBranch),
daily: (HeavenlyStem, EarthlyBranch),
hourly: (HeavenlyStem, EarthlyBranch),
}
fn place_stars(
ctx: &AstroContext,
gender: Gender,
language: Language,
config: &Config,
) -> PlacedStars {
let effective_ti = ctx.effective_time_index;
let start_idx = get_start_index(
ctx.lunar_day,
effective_ti,
ctx.month_day_count,
ctx.five_elements_class.value() as u32,
);
let lu_yang_tuo_ma = get_lu_yang_tuo_ma_index(ctx.yearly_stem, ctx.yearly_branch);
let kui_yue = get_kui_yue_index(ctx.yearly_stem);
let zuo_you = get_zuo_you_index(ctx.month_index as u32 + 1);
let chang_qu = get_chang_qu_index(effective_ti);
let kong_jie = get_kong_jie_index(effective_ti);
let huo_ling = get_huo_ling_index(ctx.yearly_branch, effective_ti);
let daily_stars = get_daily_star_index(
ctx.lunar_day,
effective_ti,
zuo_you.zuo,
zuo_you.you,
chang_qu.chang,
chang_qu.qu,
);
let timely_stars = get_timely_star_index(effective_ti);
let yearly_stars = get_yearly_star_index(
ctx.soul_body.soul_index,
ctx.soul_body.body_index,
ctx.flow_yearly_stem,
ctx.flow_yearly_branch,
gender,
config.algorithm,
);
let monthly_stars = get_monthly_star_index(ctx.month_index);
let major = get_major_stars(
start_idx.ziwei,
start_idx.tianfu,
ctx.yearly_stem,
language,
config,
);
let minor = get_minor_stars(
zuo_you.zuo,
zuo_you.you,
chang_qu.chang,
chang_qu.qu,
kui_yue.kui,
kui_yue.yue,
lu_yang_tuo_ma.lu,
lu_yang_tuo_ma.yang,
lu_yang_tuo_ma.tuo,
lu_yang_tuo_ma.ma,
kong_jie.kong,
kong_jie.jie,
huo_ling.huo,
huo_ling.ling,
ctx.yearly_stem,
language,
config,
);
let changsheng12 = get_changsheng12(ctx.five_elements_class, gender, ctx.yearly_branch);
let boshi12 = get_boshi12(lu_yang_tuo_ma.lu, gender, ctx.yearly_branch);
let (suiqian12, jiangqian12) = get_yearly12(ctx.flow_yearly_branch, config.algorithm);
let luan_xi = get_luan_xi_index(ctx.yearly_branch);
let adjective = get_adjective_stars(
&yearly_stars,
&monthly_stars,
&daily_stars,
timely_stars.taifu,
timely_stars.fenggao,
luan_xi.hongluan,
luan_xi.tianxi,
yearly_stars.xianchi,
&suiqian12,
config.algorithm,
language,
);
PlacedStars {
major,
minor,
adjective,
changsheng12,
boshi12,
suiqian12,
jiangqian12,
}
}
fn build_palaces(
ctx: &AstroContext,
stars: &PlacedStars,
gender: Gender,
config: &Config,
) -> [PalaceData; 12] {
let (decadals, ages) = get_decadals_and_ages(
ctx.soul_body.soul_index,
ctx.five_elements_class,
gender,
ctx.yearly_stem,
ctx.yearly_branch,
);
let palace_names = get_palace_names(ctx.soul_body.soul_index);
let start_stem = TIGER_RULE[ctx.yearly_stem.index()];
std::array::from_fn(|i| {
let palace_stem =
HeavenlyStem::from_index(fix_index(start_stem.index() as i32 + i as i32, 10));
let palace_branch = EarthlyBranch::from_index(fix_index(2 + i as i32, 12));
PalaceData {
index: i,
name: palace_names[i],
is_body_palace: ctx.soul_body.body_index == i,
is_original_palace: palace_branch != EarthlyBranch::Zi
&& palace_branch != EarthlyBranch::Chou
&& palace_stem == ctx.yearly_stem,
heavenly_stem: palace_stem,
earthly_branch: palace_branch,
major_stars: stars.major[i].clone(),
minor_stars: stars.minor[i].clone(),
adjective_stars: stars.adjective[i].clone(),
changsheng12: stars.changsheng12[i],
boshi12: stars.boshi12[i],
jiangqian12: stars.jiangqian12[i],
suiqian12: stars.suiqian12[i],
decadal: decadals[i].clone(),
ages: ages[i].clone(),
overrides: config.overrides.clone(),
}
})
}
fn build_pillars(
ctx: &AstroContext,
lunar_ref: &lunar::LunarRef,
config: &Config,
) -> Result<Pillars, IztroError> {
let monthly = match config.horoscope_divide {
HoroscopeDivide::Normal => {
let month_fix: i32 = if ctx.is_leap && ctx.lunar_day > 15 {
1
} else {
0
};
(
HeavenlyStem::from_index(fix_index(
TIGER_RULE[ctx.yearly_stem.index()].index() as i32 + ctx.lunar_month as i32 - 1
+ month_fix,
10,
)),
EarthlyBranch::from_index(fix_index(
2 + ctx.lunar_month as i32 - 1 + month_fix,
12,
)),
)
}
HoroscopeDivide::Exact => {
let gan = lunar_ref.get_month_gan_exact();
let zhi = lunar_ref.get_month_zhi_exact();
(stem_of(&gan, "month")?, branch_of(&zhi, "month")?)
}
};
let day_gan = lunar_ref.get_day_gan_exact();
let day_zhi = lunar_ref.get_day_zhi_exact();
let time_gan = lunar_ref.get_time_gan();
let time_zhi = lunar_ref.get_time_zhi();
Ok(Pillars {
monthly,
daily: (stem_of(&day_gan, "day")?, branch_of(&day_zhi, "day")?),
hourly: (stem_of(&time_gan, "time")?, branch_of(&time_zhi, "time")?),
})
}
pub fn by_solar(
solar_date: &str,
time_index: u8,
gender: Gender,
fix_leap: bool,
language: Language,
config: Config,
) -> Result<Astrolabe, IztroError> {
let ctx = context::derive(solar_date, time_index, fix_leap, &config)?;
let (year, month, day) = parse_solar_date(solar_date)?;
let solar_ref = solar::from_ymd(year, month, day);
let lunar_ref = lunar::from_solar(&solar::from_ymdhms(
year,
month,
day,
time_index_to_hour(ctx.effective_time_index),
0,
0,
));
let stars = place_stars(&ctx, gender, language, &config);
let palaces = build_palaces(&ctx, &stars, gender, &config);
let soul_palace_branch =
EarthlyBranch::from_index(fix_index(ctx.soul_body.soul_index as i32 + 2, 12));
let body_palace_branch =
EarthlyBranch::from_index(fix_index(ctx.soul_body.body_index as i32 + 2, 12));
let soul_star = if config.algorithm == Algorithm::Zhongzhou {
get_earthly_branch_info(ctx.yearly_branch).soul
} else {
get_earthly_branch_info(soul_palace_branch).soul
};
let body_star = get_earthly_branch_info(ctx.yearly_branch).body;
let pillars = build_pillars(&ctx, &lunar_ref, &config)?;
let chinese_date = translate_chinese_date(
[
(ctx.yearly_stem, ctx.yearly_branch),
pillars.monthly,
pillars.daily,
pillars.hourly,
],
language,
);
let chart = Astrolabe {
gender,
solar_date: solar_date.to_string(),
lunar_date: format!(
"{}年{}月{}",
lunar_ref.get_year_in_chinese(),
lunar_ref.get_month_in_chinese(),
lunar_ref.get_day_in_chinese(),
),
chinese_date,
time: translate_time(time_index, language).to_string(),
time_range: TIME_RANGES[time_index as usize].to_string(),
sign: translate_sign(parse_sign_index(&solar_ref.get_xing_zuo())?, language).to_string(),
zodiac: translate_zodiac(ctx.yearly_branch, language).to_string(),
earthly_branch_of_soul_palace: soul_palace_branch,
earthly_branch_of_body_palace: body_palace_branch,
soul: soul_star,
body: body_star,
five_elements_class: ctx.five_elements_class,
palaces,
raw_dates: RawDates {
lunar_date: RawLunarDate {
lunar_year: lunar_ref.get_year(),
lunar_month: ctx.lunar_month,
lunar_day: ctx.lunar_day,
is_leap: ctx.is_leap,
},
chinese_date: RawChineseDate {
yearly: (ctx.yearly_stem, ctx.yearly_branch),
monthly: pillars.monthly,
daily: pillars.daily,
hourly: pillars.hourly,
},
},
time_index,
fix_leap,
language,
config,
};
Ok(match chart.config.astro_type {
AstroType::Heaven => chart,
AstroType::Earth => rearrange_from_palace(&chart, |p| p.is_body_palace),
AstroType::Human => rearrange_from_palace(&chart, |p| p.name == Palace::Spirit),
})
}
fn rearrange_from_palace(chart: &Astrolabe, pick: impl Fn(&PalaceData) -> bool) -> Astrolabe {
let from = chart
.palaces
.iter()
.find(|p| pick(p))
.expect("十二宫必然包含身宫与福德宫");
chart.rearranged(from.heavenly_stem, from.earthly_branch)
}
pub fn by_lunar(
lunar_date: &str,
time_index: u8,
gender: Gender,
leap: LeapMonth,
language: Language,
config: Config,
) -> Result<Astrolabe, IztroError> {
validate_time_index(time_index)?;
let (year, lunar_month, day) = parse_lunar_date(lunar_date, leap.is_leap_month())?;
let lunar_ref = lunar::from_ymd(year, lunar_month, day);
let solar_ref = lunar_ref.get_solar();
let solar_date = format!(
"{}-{}-{}",
solar_ref.get_year(),
solar_ref.get_month(),
solar_ref.get_day(),
);
by_solar(
&solar_date,
time_index,
gender,
leap.fix_leap(),
language,
config,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_heavenly_stem() {
assert_eq!(parse_heavenly_stem("甲"), Some(HeavenlyStem::Jia));
assert_eq!(parse_heavenly_stem("癸"), Some(HeavenlyStem::Gui));
assert_eq!(parse_heavenly_stem("X"), None);
}
#[test]
fn test_parse_earthly_branch() {
assert_eq!(parse_earthly_branch("子"), Some(EarthlyBranch::Zi));
assert_eq!(parse_earthly_branch("亥"), Some(EarthlyBranch::Hai));
assert_eq!(parse_earthly_branch("X"), None);
}
#[test]
fn test_fix_lunar_month_index() {
assert_eq!(fix_lunar_month_index(1, 15, false, 0, true), 0);
assert_eq!(fix_lunar_month_index(7, 17, false, 0, true), 6);
assert_eq!(fix_lunar_month_index(4, 16, true, 0, true), 4);
assert_eq!(fix_lunar_month_index(4, 15, true, 0, true), 3);
assert_eq!(fix_lunar_month_index(4, 16, true, 0, false), 3);
}
#[test]
fn test_fix_lunar_day_index() {
assert_eq!(fix_lunar_day_index(15, 0), 14);
assert_eq!(fix_lunar_day_index(15, 12), 15);
assert_eq!(fix_lunar_day_index(1, 0), 0);
}
#[test]
fn test_time_index_to_hour() {
assert_eq!(time_index_to_hour(0), 0);
assert_eq!(time_index_to_hour(1), 1);
assert_eq!(time_index_to_hour(2), 3);
assert_eq!(time_index_to_hour(6), 11);
assert_eq!(time_index_to_hour(12), 23);
}
#[test]
fn test_by_solar_basic() {
let astrolabe = by_solar(
"2000-8-16",
0,
Gender::Male,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
assert_eq!(astrolabe.gender, Gender::Male);
assert_eq!(astrolabe.solar_date, "2000-8-16");
assert_eq!(astrolabe.palaces.len(), 12);
for p in &astrolabe.palaces {
assert!(p.index < 12);
}
}
#[test]
fn test_by_solar_has_all_stars() {
let astrolabe = by_solar(
"2000-8-16",
0,
Gender::Male,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
let total_major: usize = astrolabe.palaces.iter().map(|p| p.major_stars.len()).sum();
assert_eq!(total_major, 14);
let total_minor: usize = astrolabe.palaces.iter().map(|p| p.minor_stars.len()).sum();
assert_eq!(total_minor, 14);
}
#[test]
fn test_by_lunar_basic() {
let astrolabe = by_lunar(
"2000-7-17",
0,
Gender::Male,
LeapMonth::NotLeap,
Language::ZhCN,
Config::default(),
)
.unwrap();
assert_eq!(astrolabe.gender, Gender::Male);
assert_eq!(astrolabe.palaces.len(), 12);
}
#[test]
fn test_by_solar_late_zi() {
let astrolabe = by_solar(
"2000-8-16",
12,
Gender::Female,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
assert_eq!(astrolabe.time, "晚子时");
assert_eq!(astrolabe.time_range, "23:00~00:00");
}
#[test]
fn test_by_solar_zhongzhou() {
let astrolabe = by_solar(
"2000-8-16",
0,
Gender::Male,
true,
Language::ZhCN,
Config {
algorithm: Algorithm::Zhongzhou,
..Config::default()
},
)
.unwrap();
assert_eq!(astrolabe.palaces.len(), 12);
}
#[test]
fn test_exactly_one_body_palace() {
let astrolabe = by_solar(
"1990-5-15",
3,
Gender::Female,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
let body_count = astrolabe
.palaces
.iter()
.filter(|p| p.is_body_palace)
.count();
assert_eq!(body_count, 1);
}
}