use serde::Serialize;
use super::calendar::{day_number, ChineseCalendar, SolarTerm, LICHUN};
use super::{
branch_element, cycle_index, polarity, stem_element, year_cycle_index, ANIMALS, BRANCHES,
ELEMENTS, STEMS,
};
use crate::error::EngineError;
use crate::instant::UtcInstant;
const TROPICAL_YEAR: f64 = 365.242_19;
const MICROS_PER_MINUTE: f64 = 60_000_000.0;
const HIDDEN_STEMS: [&[usize]; 12] = [
&[9], &[5, 9, 7], &[0, 2, 4], &[1], &[4, 1, 9], &[2, 6, 4], &[3, 5], &[5, 3, 1], &[6, 8, 4], &[7], &[4, 7, 3], &[8, 0], ];
fn hidden_weights(count: usize) -> &'static [f64] {
match count {
1 => &[1.0],
2 => &[0.7, 0.3],
_ => &[0.6, 0.3, 0.1],
}
}
const NAYIN: [&str; 30] = [
"sea_metal",
"furnace_fire",
"forest_wood",
"roadside_earth",
"sword_metal",
"mountain_fire",
"stream_water",
"rampart_earth",
"wax_metal",
"willow_wood",
"spring_water",
"roof_earth",
"thunder_fire",
"pine_wood",
"river_water",
"sand_metal",
"foothill_fire",
"plain_wood",
"wall_earth",
"gold_leaf_metal",
"lamp_fire",
"heavenly_river_water",
"highway_earth",
"hairpin_metal",
"mulberry_wood",
"creek_water",
"sand_earth",
"sky_fire",
"pomegranate_wood",
"ocean_water",
];
pub(crate) fn nayin(pair: usize) -> &'static str {
NAYIN[pair]
}
const LIFE_STAGES: [&str; 12] = [
"birth",
"bath",
"cap_and_belt",
"coming_of_age",
"prosperity",
"decline",
"sickness",
"death",
"tomb",
"extinction",
"conception",
"nurture",
];
const BIRTH_BRANCH: [usize; 10] = [11, 6, 2, 9, 2, 9, 5, 0, 8, 3];
const TEN_GODS: [[&str; 2]; 5] = [
["friend", "rob_wealth"],
["eating_god", "hurting_officer"],
["indirect_wealth", "direct_wealth"],
["seven_killings", "direct_officer"],
["indirect_resource", "direct_resource"],
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ZiHour {
NextDay,
Split,
}
#[derive(Debug, Clone)]
pub struct BaziOptions<'a> {
pub solar_time: bool,
pub zi_hour: &'a str,
pub sex: Option<&'a str>,
pub luck_pillars: u8,
}
impl Default for BaziOptions<'_> {
fn default() -> Self {
BaziOptions {
solar_time: true,
zi_hour: "next_day",
sex: None,
luck_pillars: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct HiddenStem {
pub stem: &'static str,
pub element: &'static str,
pub ten_god: &'static str,
pub weight: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Pillar {
pub stem: &'static str,
pub branch: &'static str,
pub cycle: u8,
pub element: &'static str,
pub polarity: &'static str,
pub branch_element: &'static str,
pub animal: &'static str,
pub ten_god: Option<&'static str>,
pub hidden_stems: Vec<HiddenStem>,
pub nayin: &'static str,
pub life_stage: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ElementBalance {
pub wood: f64,
pub fire: f64,
pub earth: f64,
pub metal: f64,
pub water: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TermMoment {
pub name: String,
pub utc: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LunarDate {
pub year: i32,
pub month: u8,
pub leap: bool,
pub day: u8,
pub year_stem: &'static str,
pub year_branch: &'static str,
pub animal: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LuckPillar {
pub start_age: f64,
pub start: String,
pub end: String,
pub pillar: Pillar,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Luck {
pub direction: &'static str,
pub start_age: f64,
pub pillars: Vec<LuckPillar>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Bazi {
pub time_basis: &'static str,
pub zi_hour: &'static str,
pub civil_time: String,
pub solar_time: String,
pub year: Pillar,
pub month: Pillar,
pub day: Pillar,
pub hour: Pillar,
pub day_master: &'static str,
pub day_master_element: &'static str,
pub elements: ElementBalance,
pub month_term: TermMoment,
pub next_term: TermMoment,
pub lunar_date: Option<LunarDate>,
pub luck: Option<Luck>,
}
fn invalid(message: &str) -> EngineError {
EngineError::InvalidInput(message.to_string())
}
fn wall_clock(instant: UtcInstant) -> String {
let (y, m, d, hh, mm, ss, _) = instant.civil();
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}")
}
fn ten_god(master: usize, stem: usize) -> &'static str {
let relation = (stem_element(stem) + 5 - stem_element(master)) % 5;
TEN_GODS[relation][usize::from(master % 2 != stem % 2)]
}
fn life_stage(stem: usize, branch: usize) -> &'static str {
let birth = BIRTH_BRANCH[stem] as i64;
let steps = if stem % 2 == 0 {
branch as i64 - birth
} else {
birth - branch as i64
};
LIFE_STAGES[steps.rem_euclid(12) as usize]
}
fn pillar(cycle: usize, master: usize, is_master: bool) -> Pillar {
let (stem, branch) = (cycle % 10, cycle % 12);
let hidden = HIDDEN_STEMS[branch];
Pillar {
stem: STEMS[stem],
branch: BRANCHES[branch],
cycle: cycle as u8,
element: ELEMENTS[stem_element(stem)],
polarity: polarity(stem),
branch_element: ELEMENTS[branch_element(branch)],
animal: ANIMALS[branch],
ten_god: (!is_master).then(|| ten_god(master, stem)),
hidden_stems: hidden
.iter()
.zip(hidden_weights(hidden.len()))
.map(|(&h, &weight)| HiddenStem {
stem: STEMS[h],
element: ELEMENTS[stem_element(h)],
ten_god: ten_god(master, h),
weight,
})
.collect(),
nayin: NAYIN[cycle / 2],
life_stage: life_stage(master, branch),
}
}
fn balance(cycles: [usize; 4]) -> ElementBalance {
let mut weights = [0.0f64; 5];
for cycle in cycles {
let (stem, branch) = (cycle % 10, cycle % 12);
weights[stem_element(stem)] += 1.0;
let hidden = HIDDEN_STEMS[branch];
for (&h, &w) in hidden.iter().zip(hidden_weights(hidden.len())) {
weights[stem_element(h)] += w;
}
}
let round = |x: f64| (x * 100.0).round() / 100.0;
ElementBalance {
wood: round(weights[0]),
fire: round(weights[1]),
earth: round(weights[2]),
metal: round(weights[3]),
water: round(weights[4]),
}
}
type Dated<'a> = (UtcInstant, &'a SolarTerm);
fn around<'a>(
terms: &'a [Dated<'a>],
birth: UtcInstant,
pick: impl Fn(&SolarTerm) -> bool,
) -> (Option<&'a Dated<'a>>, Option<&'a Dated<'a>>) {
let before = terms.iter().rev().find(|(t, s)| *t <= birth && pick(s));
let after = terms.iter().find(|(t, s)| *t > birth && pick(s));
(before, after)
}
fn term_moment((instant, term): &Dated) -> TermMoment {
TermMoment {
name: term.name.clone(),
utc: instant.isoformat(),
}
}
fn lunar_date(calendar: &ChineseCalendar, day: i64) -> Result<Option<LunarDate>, EngineError> {
for month in &calendar.lunar_months {
let start = month.start_day()?;
if start <= day && day < start + i64::from(month.days) {
let cycle = year_cycle_index(month.year);
return Ok(Some(LunarDate {
year: month.year,
month: month.month,
leap: month.leap,
day: (day - start + 1) as u8,
year_stem: STEMS[cycle % 10],
year_branch: BRANCHES[cycle % 12],
animal: ANIMALS[cycle % 12],
}));
}
}
Ok(None)
}
pub fn bazi(
calendar: &ChineseCalendar,
birth: UtcInstant,
longitude: f64,
utc_offset_minutes: f64,
options: &BaziOptions,
) -> Result<Bazi, EngineError> {
let zi_hour = match options.zi_hour {
"" | "next_day" => ZiHour::NextDay,
"split" => ZiHour::Split,
_ => return Err(invalid("zi_hour must be \"next_day\" or \"split\"")),
};
let male = match options.sex {
None | Some("") => None,
Some("male") => Some(true),
Some("female") => Some(false),
Some(_) => return Err(invalid("sex must be \"male\" or \"female\"")),
};
let terms = calendar
.solar_terms
.iter()
.map(|t| Ok((t.instant()?, t)))
.collect::<Result<Vec<_>, EngineError>>()?;
let (lichun, _) = around(&terms, birth, |t| t.longitude == LICHUN);
let (month_term, next_term) = around(&terms, birth, |t| t.jie);
let (Some(lichun), Some(month_term), Some(next_term)) = (lichun, month_term, next_term) else {
return Err(invalid("the calendar does not cover the birth"));
};
let year_cycle = year_cycle_index(lichun.0.civil().0);
let month_offset = usize::from((month_term.1.longitude + 360 - LICHUN) % 360 / 30);
let month_stem = ((year_cycle % 10) % 5 * 2 + 2 + month_offset) % 10;
let month_cycle = cycle_index(month_stem, (2 + month_offset) % 12);
let to_micros = |minutes: f64| (minutes * MICROS_PER_MINUTE).round() as i64;
let civil = birth.add_micros(to_micros(utc_offset_minutes));
let solar = birth.add_micros(to_micros(longitude * 4.0 + calendar.equation_of_time));
let local = if options.solar_time { solar } else { civil };
let hour = local.civil().3;
let date = day_number(local);
let late_zi = hour >= 23;
let day = if late_zi && zi_hour == ZiHour::NextDay {
date + 1
} else {
date
};
let day_cycle_of = |day: i64| (day - 11).rem_euclid(60) as usize;
let day_cycle = day_cycle_of(day);
let master = day_cycle % 10;
let hour_branch = (hour as usize).div_ceil(2) % 12;
let hour_day_stem = day_cycle_of(if late_zi { date + 1 } else { date }) % 10;
let hour_cycle = cycle_index((hour_day_stem % 5 * 2 + hour_branch) % 10, hour_branch);
let luck = male.map(|male| {
let forward = male == (year_cycle % 2 == 0);
let days = if forward {
next_term.0.seconds_since(&birth)
} else {
birth.seconds_since(&month_term.0)
} / 86_400.0;
let start_age = days / 3.0;
let at_age = |age: f64| birth.plus_days(age * TROPICAL_YEAR).isoformat();
let pillars = (1..=i64::from(options.luck_pillars))
.map(|k| {
let cycle = (month_cycle as i64 + if forward { k } else { -k }).rem_euclid(60);
let age = start_age + 10.0 * (k - 1) as f64;
LuckPillar {
start_age: age,
start: at_age(age),
end: at_age(age + 10.0),
pillar: pillar(cycle as usize, master, false),
}
})
.collect();
Luck {
direction: if forward { "forward" } else { "backward" },
start_age,
pillars,
}
});
Ok(Bazi {
time_basis: if options.solar_time { "solar" } else { "civil" },
zi_hour: match zi_hour {
ZiHour::NextDay => "next_day",
ZiHour::Split => "split",
},
civil_time: wall_clock(civil),
solar_time: wall_clock(solar),
year: pillar(year_cycle, master, false),
month: pillar(month_cycle, master, false),
day: pillar(day_cycle, master, true),
hour: pillar(hour_cycle, master, false),
day_master: STEMS[master],
day_master_element: ELEMENTS[stem_element(master)],
elements: balance([year_cycle, month_cycle, day_cycle, hour_cycle]),
month_term: term_moment(month_term),
next_term: term_moment(next_term),
lunar_date: lunar_date(calendar, day)?,
luck,
})
}