use std::sync::OnceLock;
use lunar_rust::lunar::LunarRefHelper;
use lunar_rust::lunar_year::{self, LunarYearRefHelper};
use lunar_rust::{lunar, solar};
use serde::{Deserialize, Serialize};
use crate::astro::builder::{by_solar, four_pillars};
use crate::astro::lunar_table;
use crate::astro::palace::{get_five_elements_class, get_soul_and_body};
use crate::data::stars::StarKey;
use crate::data::types::*;
use crate::error::IztroError;
use crate::models::astrolabe::Astrolabe;
use crate::star::location::{
get_chang_qu_index, get_huo_ling_index, get_kong_jie_index, get_kui_yue_index,
get_lu_yang_tuo_ma_index, get_luan_xi_index, get_start_index, get_zuo_you_index,
};
use crate::utils::{fix_index, get_mutagens_by_heavenly_stem};
const SUPPORTED_YEARS: std::ops::RangeInclusive<i64> = 1583..=9999;
pub const DEFAULT_REVERSE_LIMIT: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BirthCandidate {
pub solar_date: String,
pub time_index: u8,
}
pub fn solar_dates_by_bazi(
yearly: (HeavenlyStem, EarthlyBranch),
monthly: (HeavenlyStem, EarthlyBranch),
daily: (HeavenlyStem, EarthlyBranch),
hourly: (HeavenlyStem, EarthlyBranch),
year_range: (i64, i64),
config: &Config,
) -> Result<Vec<BirthCandidate>, IztroError> {
for (name, (stem, branch)) in [
("yearly", yearly),
("monthly", monthly),
("daily", daily),
("hourly", hourly),
] {
if stem.index() % 2 != branch.index() % 2 {
return Err(IztroError::InvalidArgument(format!(
"invalid {name} pillar: stem and branch must have the same polarity"
)));
}
}
validate_year_range(year_range)?;
let target = [yearly, monthly, daily, hourly];
let target_day60 = sexagenary_index(daily);
let mut out = Vec::new();
for year in year_range.0..=year_range.1 {
if sexagenary_index(year_pillar(year)) != sexagenary_index(yearly)
&& sexagenary_index(year_pillar(year - 1)) != sexagenary_index(yearly)
{
continue;
}
for month in 1..=12u32 {
for day in 1..=days_in_month(year, month) {
let day60 = day_sexagenary_index(year, month, day);
let hour_branch = hourly.1;
let mut candidates: [Option<u8>; 2] = [None, None];
if hour_branch == EarthlyBranch::Zi {
match config.day_divide {
DayDivide::Current => {
if day60 == target_day60 {
candidates = [Some(0), Some(12)];
}
}
DayDivide::Forward => {
if day60 == target_day60 {
candidates[0] = Some(0);
}
if (day60 + 1) % 60 == target_day60 {
candidates[1] = Some(12);
}
}
}
} else if day60 == target_day60 {
candidates[0] = Some(hour_branch.index() as u8);
}
for time_index in candidates.into_iter().flatten() {
let date = format!("{year}-{month}-{day}");
if four_pillars(&date, time_index, config).is_ok_and(|p| p == target) {
out.push(BirthCandidate {
solar_date: date,
time_index,
});
}
}
}
}
}
Ok(out)
}
fn year_pillar(year: i64) -> (HeavenlyStem, EarthlyBranch) {
(
HeavenlyStem::from_index(fix_index((year - 4) as i32, 10)),
EarthlyBranch::from_index(fix_index((year - 4) as i32, 12)),
)
}
fn sexagenary_index((stem, branch): (HeavenlyStem, EarthlyBranch)) -> usize {
fix_index(6 * stem.index() as i32 - 5 * branch.index() as i32, 60)
}
fn day_sexagenary_index(year: i64, month: u32, day: u32) -> usize {
static ANCHOR: OnceLock<i64> = OnceLock::new();
let anchor = *ANCHOR.get_or_init(|| {
let l = lunar::from_solar(&solar::from_ymdhms(2000, 1, 1, 12, 0, 0));
let stem = crate::astro::builder::parse_heavenly_stem(&l.get_day_gan()).expect("锚点日干");
let branch =
crate::astro::builder::parse_earthly_branch(&l.get_day_zhi()).expect("锚点日支");
sexagenary_index((stem, branch)) as i64 - jdn(2000, 1, 1)
});
fix_index((anchor + jdn(year, month, day)) as i32, 60)
}
fn jdn(year: i64, month: u32, day: u32) -> i64 {
let (y, m, d) = (year, month as i64, day as i64);
let a = (14 - m) / 12;
let y2 = y + 4800 - a;
let m2 = m + 12 * a - 3;
d + (153 * m2 + 2) / 5 + 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 - 32045
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
}
}
fn validate_year_range((start, end): (i64, i64)) -> Result<(), IztroError> {
if start > end || !SUPPORTED_YEARS.contains(&start) || !SUPPORTED_YEARS.contains(&end) {
return Err(IztroError::InvalidArgument(format!(
"invalid year range {start}-{end}: expected {}-{} with start <= end",
SUPPORTED_YEARS.start(),
SUPPORTED_YEARS.end()
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StarPosition {
pub star: StarKey,
pub branch: EarthlyBranch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ReverseCriteria {
pub soul_branch: Option<EarthlyBranch>,
pub body_branch: Option<EarthlyBranch>,
pub five_elements_class: Option<FiveElementsClass>,
pub stars: Vec<StarPosition>,
pub mutagens: [Option<StarKey>; 4],
pub year_range: (i64, i64),
pub fix_leap: bool,
pub limit: usize,
}
impl Default for ReverseCriteria {
fn default() -> Self {
ReverseCriteria {
soul_branch: None,
body_branch: None,
five_elements_class: None,
stars: Vec::new(),
mutagens: [None; 4],
year_range: (1900, 2100),
fix_leap: true,
limit: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReverseResult {
pub candidates: Vec<BirthCandidate>,
pub truncated: bool,
}
pub fn reverse_chart(
criteria: &ReverseCriteria,
config: &Config,
) -> Result<ReverseResult, IztroError> {
validate_criteria(criteria)?;
let limit = if criteria.limit == 0 {
DEFAULT_REVERSE_LIMIT
} else {
criteria.limit
};
let mut out = Vec::new();
let mut truncated = false;
let domains = Domains::resolve(criteria);
if matches!(domains.ziwei_palace, Some(Err(()))) {
return Ok(ReverseResult {
candidates: out,
truncated,
});
}
let (start_year, end_year) = criteria.year_range;
'year: for year in (start_year - 1)..=end_year {
let stems: Vec<(HeavenlyStem, EarthlyBranch)> = year_stem_candidates(year, config)
.into_iter()
.filter(|(s, b)| domains.admits_year(*s, *b))
.collect();
if stems.is_empty() || !year_prefilter(criteria, &stems, config) {
continue;
}
let leap_month = lunar_year::LunarYear::from_lunar_year(year)
.get_leap_months()
.abs();
for month in 1..=12i64 {
if let Some(months) = &domains.months
&& !months.contains(&month)
{
continue;
}
for is_leap in [false, true] {
if is_leap && month != leap_month {
continue;
}
let signed_month = if is_leap { -month } else { month };
let Some(month_day_count) = lunar_table::month_day_count(year, signed_month) else {
continue;
};
let month_day_count = month_day_count as u32;
for time_index in 0..=12u8 {
if let Some(times) = &domains.times
&& !times.contains(&time_index)
{
continue;
}
let month_indices =
[fix_index(month as i32 - 1, 12), fix_index(month as i32, 12)];
let fecs =
month_time_prefilter(criteria, &stems, &month_indices, time_index, config);
if fecs.is_empty() {
continue;
}
let eff_t = effective_time_index(config.day_divide, time_index);
for day in 1..=month_day_count {
if !day_prefilter(&domains, day, eff_t, month_day_count, &fecs)
|| !daily_star_prefilter(criteria, month as usize - 1, day, eff_t)
{
continue;
}
let date = lunar_table::solar_date_of(year, signed_month, day as i64);
let solar_year: i64 = date
.split('-')
.next()
.and_then(|y| y.parse().ok())
.expect("solar_date_of 返回自产 Y-M-D 格式,首段恒为年份数字");
if solar_year < start_year || solar_year > end_year {
continue;
}
let Ok(chart) = by_solar(
&date,
time_index,
Gender::Male,
criteria.fix_leap,
Language::ZhCN,
config.clone(),
) else {
continue;
};
if matches_criteria(&chart, criteria, config) {
out.push(BirthCandidate {
solar_date: date,
time_index,
});
if out.len() >= limit {
truncated = true;
break 'year;
}
}
}
}
}
}
}
Ok(ReverseResult {
candidates: out,
truncated,
})
}
fn matches_criteria(chart: &Astrolabe, criteria: &ReverseCriteria, config: &Config) -> bool {
if let Some(b) = criteria.soul_branch
&& chart.earthly_branch_of_soul_palace != b
{
return false;
}
if let Some(b) = criteria.body_branch
&& chart.earthly_branch_of_body_palace != b
{
return false;
}
if let Some(f) = criteria.five_elements_class
&& chart.five_elements_class != f
{
return false;
}
let mutagens = get_mutagens_by_heavenly_stem(chart.raw_dates.chinese_date.yearly.0, config);
for (want, got) in criteria.mutagens.iter().zip(mutagens) {
if let Some(star) = want
&& *star != got
{
return false;
}
}
criteria.stars.iter().all(|p| {
chart
.star(p.star)
.is_some_and(|s| s.palace().earthly_branch == p.branch)
})
}
fn effective_time_index(day_divide: DayDivide, time_index: u8) -> u8 {
if day_divide == DayDivide::Current && time_index >= 12 {
0
} else {
time_index
}
}
fn year_stem_candidates(year: i64, config: &Config) -> Vec<(HeavenlyStem, EarthlyBranch)> {
match config.year_divide {
YearDivide::Normal => vec![year_pillar(year)],
YearDivide::Exact => vec![
year_pillar(year),
year_pillar(year - 1),
year_pillar(year + 1),
],
}
}
fn year_prefilter(
criteria: &ReverseCriteria,
stems: &[(HeavenlyStem, EarthlyBranch)],
config: &Config,
) -> bool {
stems.iter().any(|(stem, branch)| {
let mutagens = get_mutagens_by_heavenly_stem(*stem, config);
let mutagen_ok = criteria
.mutagens
.iter()
.zip(mutagens)
.all(|(want, got)| want.is_none_or(|w| w == got));
mutagen_ok
&& criteria.stars.iter().all(|p| {
let want = branch_to_palace(p.branch);
match p.star {
StarKey::LucunMin => get_lu_yang_tuo_ma_index(*stem, *branch).lu == want,
StarKey::QingyangMin => get_lu_yang_tuo_ma_index(*stem, *branch).yang == want,
StarKey::TuoluoMin => get_lu_yang_tuo_ma_index(*stem, *branch).tuo == want,
StarKey::TianmaMin => get_lu_yang_tuo_ma_index(*stem, *branch).ma == want,
StarKey::TiankuiMin => get_kui_yue_index(*stem).kui == want,
StarKey::TianyueMin => get_kui_yue_index(*stem).yue == want,
StarKey::Hongluan => get_luan_xi_index(*branch).hongluan == want,
StarKey::Tianxi => get_luan_xi_index(*branch).tianxi == want,
_ => true,
}
})
})
}
fn month_time_prefilter(
criteria: &ReverseCriteria,
stems: &[(HeavenlyStem, EarthlyBranch)],
month_indices: &[usize; 2],
time_index: u8,
config: &Config,
) -> Vec<u32> {
let _ = config;
let mut fecs: Vec<u32> = Vec::new();
for (stem, branch) in stems {
for &mi in month_indices {
let sb = get_soul_and_body(mi, time_index, *stem);
if let Some(b) = criteria.soul_branch
&& sb.earthly_branch_of_soul != b
{
continue;
}
if let Some(b) = criteria.body_branch
&& EarthlyBranch::from_index(fix_index(sb.body_index as i32 + 2, 12)) != b
{
continue;
}
let fec = get_five_elements_class(sb.heavenly_stem_of_soul, sb.earthly_branch_of_soul);
if let Some(f) = criteria.five_elements_class
&& fec != f
{
continue;
}
let stars_ok = criteria.stars.iter().all(|p| {
let want = branch_to_palace(p.branch);
match p.star {
StarKey::ZuofuMin => get_zuo_you_index(mi as u32 + 1).zuo == want,
StarKey::YoubiMin => get_zuo_you_index(mi as u32 + 1).you == want,
StarKey::WenchangMin => get_chang_qu_index(time_index).chang == want,
StarKey::WenquMin => get_chang_qu_index(time_index).qu == want,
StarKey::DikongMin => get_kong_jie_index(time_index).kong == want,
StarKey::DijieMin => get_kong_jie_index(time_index).jie == want,
StarKey::HuoxingMin => get_huo_ling_index(*branch, time_index).huo == want,
StarKey::LingxingMin => get_huo_ling_index(*branch, time_index).ling == want,
_ => true,
}
});
if !stars_ok {
continue;
}
if !fecs.contains(&(fec as u32)) {
fecs.push(fec as u32);
}
}
}
fecs
}
struct Domains {
ziwei_palace: Option<Result<usize, ()>>,
stems: Option<Vec<HeavenlyStem>>,
branches: Option<Vec<EarthlyBranch>>,
months: Option<Vec<i64>>,
times: Option<Vec<u8>>,
}
impl Domains {
fn resolve(criteria: &ReverseCriteria) -> Domains {
let mut ziwei: Option<Result<usize, ()>> = None;
for p in &criteria.stars {
if let Some((_, offset, from_tianfu)) =
MAJOR_OFFSETS.iter().find(|(k, _, _)| *k == p.star)
{
let palace = branch_to_palace(p.branch) as i32;
let z = if *from_tianfu {
fix_index(12 - fix_index(palace - offset, 12) as i32, 12)
} else {
fix_index(palace + offset, 12)
};
ziwei = Some(match ziwei {
Some(Ok(prev)) if prev != z => Err(()),
Some(Err(())) => Err(()),
_ => Ok(z),
});
}
}
let mut stems: Option<Vec<HeavenlyStem>> = None;
let mut branches: Option<Vec<EarthlyBranch>> = None;
let mut months: Option<Vec<i64>> = None;
let mut times: Option<Vec<u8>> = None;
let mut narrow_stems = |pred: &dyn Fn(HeavenlyStem) -> bool| {
let set: Vec<HeavenlyStem> = (0..10)
.map(HeavenlyStem::from_index)
.filter(|s| pred(*s))
.collect();
stems = Some(match stems.take() {
Some(prev) => prev.into_iter().filter(|s| set.contains(s)).collect(),
None => set,
});
};
let mut narrow_branches = |pred: &dyn Fn(EarthlyBranch) -> bool| {
let set: Vec<EarthlyBranch> = (0..12)
.map(EarthlyBranch::from_index)
.filter(|b| pred(*b))
.collect();
branches = Some(match branches.take() {
Some(prev) => prev.into_iter().filter(|b| set.contains(b)).collect(),
None => set,
});
};
for p in &criteria.stars {
let want = branch_to_palace(p.branch);
match p.star {
StarKey::LucunMin => narrow_stems(&|s| {
(0..12).any(|b| {
get_lu_yang_tuo_ma_index(s, EarthlyBranch::from_index(b)).lu == want
})
}),
StarKey::QingyangMin => narrow_stems(&|s| {
(0..12).any(|b| {
get_lu_yang_tuo_ma_index(s, EarthlyBranch::from_index(b)).yang == want
})
}),
StarKey::TuoluoMin => narrow_stems(&|s| {
(0..12).any(|b| {
get_lu_yang_tuo_ma_index(s, EarthlyBranch::from_index(b)).tuo == want
})
}),
StarKey::TiankuiMin => narrow_stems(&|s| get_kui_yue_index(s).kui == want),
StarKey::TianyueMin => narrow_stems(&|s| get_kui_yue_index(s).yue == want),
StarKey::TianmaMin => {
narrow_branches(&|b| get_lu_yang_tuo_ma_index(HeavenlyStem::Jia, b).ma == want)
}
StarKey::Hongluan => narrow_branches(&|b| get_luan_xi_index(b).hongluan == want),
StarKey::Tianxi => narrow_branches(&|b| get_luan_xi_index(b).tianxi == want),
StarKey::ZuofuMin | StarKey::YoubiMin => {
let base: Vec<i64> = (1..=12i64)
.filter(|m| {
let zy = get_zuo_you_index(*m as u32);
let idx = if p.star == StarKey::ZuofuMin {
zy.zuo
} else {
zy.you
};
idx == want
})
.collect();
let mut set: Vec<i64> = base
.iter()
.flat_map(|m| [*m, fix_index(*m as i32 - 2, 12) as i64 + 1])
.collect();
set.sort_unstable();
set.dedup();
months = Some(match months.take() {
Some(prev) => prev.into_iter().filter(|m| set.contains(m)).collect(),
None => set,
});
}
StarKey::WenchangMin
| StarKey::WenquMin
| StarKey::DikongMin
| StarKey::DijieMin => {
let set: Vec<u8> = (0..=12u8)
.filter(|t| {
let idx = match p.star {
StarKey::WenchangMin => get_chang_qu_index(*t).chang,
StarKey::WenquMin => get_chang_qu_index(*t).qu,
StarKey::DikongMin => get_kong_jie_index(*t).kong,
_ => get_kong_jie_index(*t).jie,
};
idx == want
})
.collect();
times = Some(match times.take() {
Some(prev) => prev.into_iter().filter(|t| set.contains(t)).collect(),
None => set,
});
}
_ => {}
}
}
Domains {
ziwei_palace: ziwei,
stems,
branches,
months,
times,
}
}
fn admits_year(&self, stem: HeavenlyStem, branch: EarthlyBranch) -> bool {
self.stems.as_ref().is_none_or(|s| s.contains(&stem))
&& self.branches.as_ref().is_none_or(|b| b.contains(&branch))
}
}
const MAJOR_OFFSETS: [(StarKey, i32, bool); 14] = [
(StarKey::ZiweiMaj, 0, false),
(StarKey::TianjiMaj, 1, false),
(StarKey::TaiyangMaj, 3, false),
(StarKey::WuquMaj, 4, false),
(StarKey::TiantongMaj, 5, false),
(StarKey::LianzhenMaj, 8, false),
(StarKey::TianfuMaj, 0, true),
(StarKey::TaiyinMaj, 1, true),
(StarKey::TanlangMaj, 2, true),
(StarKey::JumenMaj, 3, true),
(StarKey::TianxiangMaj, 4, true),
(StarKey::TianliangMaj, 5, true),
(StarKey::QishaMaj, 6, true),
(StarKey::PojunMaj, 10, true),
];
fn day_prefilter(
domains: &Domains,
day: u32,
effective_ti: u8,
month_day_count: u32,
fecs: &[u32],
) -> bool {
let Some(Ok(ziwei)) = domains.ziwei_palace else {
return true;
};
fecs.iter()
.any(|fec| get_start_index(day, effective_ti, month_day_count, *fec).ziwei == ziwei)
}
fn daily_star_prefilter(
criteria: &ReverseCriteria,
month_index: usize,
day: u32,
effective_ti: u8,
) -> bool {
let involved = criteria.stars.iter().any(|p| {
matches!(
p.star,
StarKey::Santai | StarKey::Bazuo | StarKey::Engguang | StarKey::Tiangui
)
});
if !involved {
return true;
}
[month_index, month_index + 1].iter().any(|mi| {
let zy = get_zuo_you_index(fix_index(*mi as i32, 12) as u32 + 1);
let cq = get_chang_qu_index(effective_ti);
let daily = crate::star::location::get_daily_star_index(
day,
effective_ti,
zy.zuo,
zy.you,
cq.chang,
cq.qu,
);
criteria.stars.iter().all(|p| {
let want = branch_to_palace(p.branch);
match p.star {
StarKey::Santai => daily.santai == want,
StarKey::Bazuo => daily.bazuo == want,
StarKey::Engguang => daily.enguang == want,
StarKey::Tiangui => daily.tiangui == want,
_ => true,
}
})
})
}
fn branch_to_palace(branch: EarthlyBranch) -> usize {
fix_index(branch.index() as i32 - 2, 12)
}
fn validate_criteria(criteria: &ReverseCriteria) -> Result<(), IztroError> {
validate_year_range(criteria.year_range)?;
let empty = criteria.soul_branch.is_none()
&& criteria.body_branch.is_none()
&& criteria.five_elements_class.is_none()
&& criteria.stars.is_empty()
&& criteria.mutagens.iter().all(Option::is_none);
if empty {
return Err(IztroError::InvalidArgument(
"reverse criteria must contain at least one condition".to_string(),
));
}
for p in &criteria.stars {
if is_flow_star(p.star) {
return Err(IztroError::InvalidArgument(format!(
"star '{}' is a horoscope-scope star and never appears on the natal chart",
p.star.as_key()
)));
}
if is_twelve_gods_star(p.star) {
return Err(IztroError::InvalidArgument(format!(
"star '{}' belongs to a per-palace twelve-gods cycle (changsheng12/boshi12/jiangqian12/suiqian12) and cannot be used as a reverse criterion",
p.star.as_key()
)));
}
}
Ok(())
}
fn is_twelve_gods_star(key: StarKey) -> bool {
use StarKey::*;
matches!(
key,
Changsheng | Muyu | Guandai | Linguan | Diwang | Shuai | Bing | Si | Mu | Jue | Tai | Yang
| Boshi | Lishi | Qinglong | Xiaohao | Jiangjun | Zhoushu | Faylian | Xishen
| Bingfu | Suipo | Fubing | Guanfu
| Suijian | Huiqi | Sangmen | Guansuo | Gwanfu | Baihu | Diaoke
| Jiangxing | Panan | Suiyi | Xiishen | Jiesha | Zhaisha | Tiansha
| Zhibei | Yuesha | Wangshen
)
}
fn is_flow_star(key: StarKey) -> bool {
use StarKey::*;
matches!(
key,
Yunkui
| Yunyue
| Yunchang
| Yunqu
| Yunluan
| Yunxi
| Yunlu
| Yunyang
| Yuntuo
| Yunma
| Liukui
| Liuyue
| Liuchang
| Liuqu
| Liuluan
| Liuxi
| Liulu
| Liuyang
| Liutuo
| Liuma
| Shikui
| Shiyue
| Shichang
| Shiqu
| Shiluan
| Shixi
| Shilu
| Shiyang
| Shituo
| Shima
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn year_stem_candidates_by_divide() {
let normal = Config::default();
assert_eq!(year_stem_candidates(2000, &normal), vec![year_pillar(2000)]);
let exact = Config {
year_divide: YearDivide::Exact,
..Config::default()
};
assert_eq!(
year_stem_candidates(2014, &exact),
vec![year_pillar(2014), year_pillar(2013), year_pillar(2015)]
);
}
#[test]
fn effective_time_index_normalizes_late_zi_only_under_current() {
assert_eq!(effective_time_index(DayDivide::Current, 12), 0);
assert_eq!(effective_time_index(DayDivide::Current, 0), 0);
assert_eq!(effective_time_index(DayDivide::Current, 6), 6);
assert_eq!(effective_time_index(DayDivide::Forward, 12), 12);
assert_eq!(effective_time_index(DayDivide::Forward, 6), 6);
}
}