Skip to main content

astroceleste_engine/chinese/
bazi.rs

1//! Ba Zi (八字), the Four Pillars of Destiny of a birth, cast from its
2//! [`ChineseCalendar`]. No kernel is needed.
3//!
4//! - The year pillar changes at 立春 and the month pillar at each jie term; both compare
5//!   instants, so they do not depend on the clock the birth was recorded in.
6//! - The day and hour pillars are cast on true solar time at the birthplace (the default)
7//!   or on civil time. The Zi hour (23:00-01:00) belongs to the next day by default; with
8//!   `zi_hour = "split"` the day changes at midnight and the late Zi hour keeps its day,
9//!   its stem still taken from the next day's.
10//! - The luck pillars (大運) run forward from the month pillar for a yang year and a man
11//!   or a yin year and a woman, backward otherwise. They start after the days from the
12//!   birth to the next (forward) or previous (backward) jie term, three days to a year.
13
14use serde::Serialize;
15
16use super::calendar::{day_number, ChineseCalendar, SolarTerm, LICHUN};
17use super::{
18    branch_element, cycle_index, polarity, stem_element, year_cycle_index, ANIMALS, BRANCHES,
19    ELEMENTS, STEMS,
20};
21use crate::error::EngineError;
22use crate::instant::UtcInstant;
23
24const TROPICAL_YEAR: f64 = 365.242_19;
25const MICROS_PER_MINUTE: f64 = 60_000_000.0;
26
27/// The stems hidden in each branch (藏干): main qi first, then middle and residual qi.
28const HIDDEN_STEMS: [&[usize]; 12] = [
29    &[9],       // 子 癸
30    &[5, 9, 7], // 丑 己癸辛
31    &[0, 2, 4], // 寅 甲丙戊
32    &[1],       // 卯 乙
33    &[4, 1, 9], // 辰 戊乙癸
34    &[2, 6, 4], // 巳 丙庚戊
35    &[3, 5],    // 午 丁己
36    &[5, 3, 1], // 未 己丁乙
37    &[6, 8, 4], // 申 庚壬戊
38    &[7],       // 酉 辛
39    &[4, 7, 3], // 戌 戊辛丁
40    &[8, 0],    // 亥 壬甲
41];
42
43/// Share of its branch each hidden stem carries in the element balance, by how many the
44/// branch holds.
45fn hidden_weights(count: usize) -> &'static [f64] {
46    match count {
47        1 => &[1.0],
48        2 => &[0.7, 0.3],
49        _ => &[0.6, 0.3, 0.1],
50    }
51}
52
53/// NaYin (納音) of each pair of the sexagenary cycle, 甲子乙丑 first.
54const NAYIN: [&str; 30] = [
55    "sea_metal",
56    "furnace_fire",
57    "forest_wood",
58    "roadside_earth",
59    "sword_metal",
60    "mountain_fire",
61    "stream_water",
62    "rampart_earth",
63    "wax_metal",
64    "willow_wood",
65    "spring_water",
66    "roof_earth",
67    "thunder_fire",
68    "pine_wood",
69    "river_water",
70    "sand_metal",
71    "foothill_fire",
72    "plain_wood",
73    "wall_earth",
74    "gold_leaf_metal",
75    "lamp_fire",
76    "heavenly_river_water",
77    "highway_earth",
78    "hairpin_metal",
79    "mulberry_wood",
80    "creek_water",
81    "sand_earth",
82    "sky_fire",
83    "pomegranate_wood",
84    "ocean_water",
85];
86
87/// The NaYin code of a pair of the sexagenary cycle (0-29).
88pub(crate) fn nayin(pair: usize) -> &'static str {
89    NAYIN[pair]
90}
91
92/// The twelve stages of life (十二長生), from Birth.
93pub(crate) const LIFE_STAGES: [&str; 12] = [
94    "birth",
95    "bath",
96    "cap_and_belt",
97    "coming_of_age",
98    "prosperity",
99    "decline",
100    "sickness",
101    "death",
102    "tomb",
103    "extinction",
104    "conception",
105    "nurture",
106];
107
108/// The branch where each stem is born (長生); yang stems go forward through the
109/// branches, yin stems backward.
110const BIRTH_BRANCH: [usize; 10] = [11, 6, 2, 9, 2, 9, 5, 0, 8, 3];
111
112/// The Ten Gods (十神) by the element relation to the Day Master (same, produced by it,
113/// controlled by it, controlling it, producing it) and same or opposite polarity.
114const TEN_GODS: [[&str; 2]; 5] = [
115    ["friend", "rob_wealth"],
116    ["eating_god", "hurting_officer"],
117    ["indirect_wealth", "direct_wealth"],
118    ["seven_killings", "direct_officer"],
119    ["indirect_resource", "direct_resource"],
120];
121
122/// When the day changes around the Zi hour.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum ZiHour {
125    /// 23:00: the whole Zi hour belongs to the next day.
126    NextDay,
127    /// Midnight: the late Zi hour (夜子時) keeps its day.
128    Split,
129}
130
131/// How to cast the pillars.
132#[derive(Debug, Clone)]
133pub struct BaziOptions<'a> {
134    /// Cast the day and hour on true solar time at the birthplace (`true`, the default)
135    /// or on civil time.
136    pub solar_time: bool,
137    /// "next_day" (default): the day changes at 23:00; "split": at midnight.
138    pub zi_hour: &'a str,
139    /// "male" or "female": sets the direction of the luck pillars, which are left out
140    /// without it.
141    pub sex: Option<&'a str>,
142    /// How many ten-year luck pillars to list (default 10).
143    pub luck_pillars: u8,
144    /// A year whose pillar (流年, changing at 立春) to give, seen from the Day Master.
145    pub year: Option<i32>,
146}
147
148impl Default for BaziOptions<'_> {
149    fn default() -> Self {
150        BaziOptions {
151            solar_time: true,
152            zi_hour: "next_day",
153            sex: None,
154            luck_pillars: 10,
155            year: None,
156        }
157    }
158}
159
160/// A stem hidden in a branch.
161#[derive(Debug, Clone, PartialEq, Serialize)]
162pub struct HiddenStem {
163    /// Stem code, e.g. "gui".
164    pub stem: &'static str,
165    /// Its element.
166    pub element: &'static str,
167    /// Its relation to the Day Master.
168    pub ten_god: &'static str,
169    /// Its share of the branch in the element balance.
170    pub weight: f64,
171}
172
173/// A stem and a branch: one of the four pillars, or a luck pillar.
174#[derive(Debug, Clone, PartialEq, Serialize)]
175pub struct Pillar {
176    /// Heavenly Stem code, e.g. "jia".
177    pub stem: &'static str,
178    /// Earthly Branch code, e.g. "zi".
179    pub branch: &'static str,
180    /// Position in the sexagenary cycle, 0 (甲子) to 59 (癸亥).
181    pub cycle: u8,
182    /// Element of the stem.
183    pub element: &'static str,
184    /// "yang" or "yin", of the stem and the branch alike.
185    pub polarity: &'static str,
186    /// Element of the branch.
187    pub branch_element: &'static str,
188    /// Animal of the branch.
189    pub animal: &'static str,
190    /// The stem's relation to the Day Master; `None` for the Day Master itself.
191    pub ten_god: Option<&'static str>,
192    /// The stems hidden in the branch, main qi first.
193    pub hidden_stems: Vec<HiddenStem>,
194    /// NaYin element of the pair, e.g. "sea_metal".
195    pub nayin: &'static str,
196    /// The Day Master's stage of life in this branch.
197    pub life_stage: &'static str,
198}
199
200/// Weight of each element among the eight characters (stems count 1, each branch 1
201/// shared among its hidden stems).
202#[derive(Debug, Clone, PartialEq, Serialize)]
203pub struct ElementBalance {
204    /// 木
205    pub wood: f64,
206    /// 火
207    pub fire: f64,
208    /// 土
209    pub earth: f64,
210    /// 金
211    pub metal: f64,
212    /// 水
213    pub water: f64,
214}
215
216/// A solar term the pillars refer to.
217#[derive(Debug, Clone, PartialEq, Serialize)]
218pub struct TermMoment {
219    /// Pinyin code, e.g. "jingzhe".
220    pub name: String,
221    /// ISO 8601 UTC.
222    pub utc: String,
223}
224
225/// A date of the lunisolar calendar.
226#[derive(Debug, Clone, PartialEq, Serialize)]
227pub struct LunarDate {
228    /// The lunar year: the Gregorian year its New Year falls in.
229    pub year: i32,
230    /// Month number, 1-12.
231    pub month: u8,
232    /// Whether the month is intercalary.
233    pub leap: bool,
234    /// Day of the month, 1-30.
235    pub day: u8,
236    /// Stem of the lunar year.
237    pub year_stem: &'static str,
238    /// Branch of the lunar year.
239    pub year_branch: &'static str,
240    /// Animal of the lunar year: the popular "Chinese zodiac sign", which changes at the
241    /// New Year rather than at 立春.
242    pub animal: &'static str,
243}
244
245/// A ten-year luck pillar.
246#[derive(Debug, Clone, PartialEq, Serialize)]
247pub struct LuckPillar {
248    /// Age at which it starts, years.
249    pub start_age: f64,
250    /// Start, ISO 8601 UTC.
251    pub start: String,
252    /// End (the next one's start), ISO 8601 UTC.
253    pub end: String,
254    /// Its stem and branch.
255    pub pillar: Pillar,
256}
257
258/// The luck pillars (大運).
259#[derive(Debug, Clone, PartialEq, Serialize)]
260pub struct Luck {
261    /// "forward" or "backward" through the sexagenary cycle from the month pillar.
262    pub direction: &'static str,
263    /// Age at which the first one starts, years.
264    pub start_age: f64,
265    /// The pillars in order.
266    pub pillars: Vec<LuckPillar>,
267}
268
269/// The Four Pillars of a birth.
270#[derive(Debug, Clone, PartialEq, Serialize)]
271pub struct Bazi {
272    /// "solar" or "civil": the clock the day and hour pillars were cast on.
273    pub time_basis: &'static str,
274    /// "next_day" or "split".
275    pub zi_hour: &'static str,
276    /// Local civil time of birth, `YYYY-MM-DDTHH:MM:SS`.
277    pub civil_time: String,
278    /// True solar time of birth at the birthplace, `YYYY-MM-DDTHH:MM:SS`.
279    pub solar_time: String,
280    /// Year pillar (changes at 立春).
281    pub year: Pillar,
282    /// Month pillar (changes at each jie term).
283    pub month: Pillar,
284    /// Day pillar; its stem is the Day Master.
285    pub day: Pillar,
286    /// Hour pillar.
287    pub hour: Pillar,
288    /// Stem of the day pillar, the self.
289    pub day_master: &'static str,
290    /// Element of the Day Master.
291    pub day_master_element: &'static str,
292    /// The weight of each element among the eight characters.
293    pub elements: ElementBalance,
294    /// The jie term that opened the birth month.
295    pub month_term: TermMoment,
296    /// The next jie term after the birth.
297    pub next_term: TermMoment,
298    /// The birth date in the lunisolar calendar; `None` when the calendar lacks it.
299    pub lunar_date: Option<LunarDate>,
300    /// The luck pillars; `None` without the native's sex.
301    pub luck: Option<Luck>,
302    /// The pillar of the requested year (流年), seen from the Day Master.
303    pub annual: Option<Pillar>,
304}
305
306fn invalid(message: &str) -> EngineError {
307    EngineError::InvalidInput(message.to_string())
308}
309
310/// `YYYY-MM-DDTHH:MM:SS` of a wall-clock instant.
311fn wall_clock(instant: UtcInstant) -> String {
312    let (y, m, d, hh, mm, ss, _) = instant.civil();
313    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}")
314}
315
316/// The relation of `stem` to the Day Master `master`.
317fn ten_god(master: usize, stem: usize) -> &'static str {
318    let relation = (stem_element(stem) + 5 - stem_element(master)) % 5;
319    TEN_GODS[relation][usize::from(master % 2 != stem % 2)]
320}
321
322/// The stage of life of `stem` in `branch`.
323fn life_stage(stem: usize, branch: usize) -> &'static str {
324    let birth = BIRTH_BRANCH[stem] as i64;
325    let steps = if stem % 2 == 0 {
326        branch as i64 - birth
327    } else {
328        birth - branch as i64
329    };
330    LIFE_STAGES[steps.rem_euclid(12) as usize]
331}
332
333/// The pillar at `cycle` (0-59), seen from the Day Master `master`.
334fn pillar(cycle: usize, master: usize, is_master: bool) -> Pillar {
335    let (stem, branch) = (cycle % 10, cycle % 12);
336    let hidden = HIDDEN_STEMS[branch];
337    Pillar {
338        stem: STEMS[stem],
339        branch: BRANCHES[branch],
340        cycle: cycle as u8,
341        element: ELEMENTS[stem_element(stem)],
342        polarity: polarity(stem),
343        branch_element: ELEMENTS[branch_element(branch)],
344        animal: ANIMALS[branch],
345        ten_god: (!is_master).then(|| ten_god(master, stem)),
346        hidden_stems: hidden
347            .iter()
348            .zip(hidden_weights(hidden.len()))
349            .map(|(&h, &weight)| HiddenStem {
350                stem: STEMS[h],
351                element: ELEMENTS[stem_element(h)],
352                ten_god: ten_god(master, h),
353                weight,
354            })
355            .collect(),
356        nayin: NAYIN[cycle / 2],
357        life_stage: life_stage(master, branch),
358    }
359}
360
361fn balance(cycles: [usize; 4]) -> ElementBalance {
362    let mut weights = [0.0f64; 5];
363    for cycle in cycles {
364        let (stem, branch) = (cycle % 10, cycle % 12);
365        weights[stem_element(stem)] += 1.0;
366        let hidden = HIDDEN_STEMS[branch];
367        for (&h, &w) in hidden.iter().zip(hidden_weights(hidden.len())) {
368            weights[stem_element(h)] += w;
369        }
370    }
371    let round = |x: f64| (x * 100.0).round() / 100.0;
372    ElementBalance {
373        wood: round(weights[0]),
374        fire: round(weights[1]),
375        earth: round(weights[2]),
376        metal: round(weights[3]),
377        water: round(weights[4]),
378    }
379}
380
381/// A solar term with its parsed moment.
382type Dated<'a> = (UtcInstant, &'a SolarTerm);
383
384/// The last term at or before `birth` matching `pick`, and the first after it.
385fn around<'a>(
386    terms: &'a [Dated<'a>],
387    birth: UtcInstant,
388    pick: impl Fn(&SolarTerm) -> bool,
389) -> (Option<&'a Dated<'a>>, Option<&'a Dated<'a>>) {
390    let before = terms.iter().rev().find(|(t, s)| *t <= birth && pick(s));
391    let after = terms.iter().find(|(t, s)| *t > birth && pick(s));
392    (before, after)
393}
394
395fn term_moment((instant, term): &Dated) -> TermMoment {
396    TermMoment {
397        name: term.name.clone(),
398        utc: instant.isoformat(),
399    }
400}
401
402fn lunar_date(calendar: &ChineseCalendar, day: i64) -> Result<Option<LunarDate>, EngineError> {
403    for month in &calendar.lunar_months {
404        let start = month.start_day()?;
405        if start <= day && day < start + i64::from(month.days) {
406            let cycle = year_cycle_index(month.year);
407            return Ok(Some(LunarDate {
408                year: month.year,
409                month: month.month,
410                leap: month.leap,
411                day: (day - start + 1) as u8,
412                year_stem: STEMS[cycle % 10],
413                year_branch: BRANCHES[cycle % 12],
414                animal: ANIMALS[cycle % 12],
415            }));
416        }
417    }
418    Ok(None)
419}
420
421/// Cast the Four Pillars of a birth at `birth` (UTC), at `longitude` (degrees east) where
422/// civil time was `utc_offset_minutes` ahead of UTC, from its `calendar`
423/// ([`chinese_calendar`](super::calendar::chinese_calendar) of the same moment).
424pub fn bazi(
425    calendar: &ChineseCalendar,
426    birth: UtcInstant,
427    longitude: f64,
428    utc_offset_minutes: f64,
429    options: &BaziOptions,
430) -> Result<Bazi, EngineError> {
431    let zi_hour = match options.zi_hour {
432        "" | "next_day" => ZiHour::NextDay,
433        "split" => ZiHour::Split,
434        _ => return Err(invalid("zi_hour must be \"next_day\" or \"split\"")),
435    };
436    let male = match options.sex {
437        None | Some("") => None,
438        Some("male") => Some(true),
439        Some("female") => Some(false),
440        Some(_) => return Err(invalid("sex must be \"male\" or \"female\"")),
441    };
442
443    let terms = calendar
444        .solar_terms
445        .iter()
446        .map(|t| Ok((t.instant()?, t)))
447        .collect::<Result<Vec<_>, EngineError>>()?;
448    let (lichun, _) = around(&terms, birth, |t| t.longitude == LICHUN);
449    let (month_term, next_term) = around(&terms, birth, |t| t.jie);
450    let (Some(lichun), Some(month_term), Some(next_term)) = (lichun, month_term, next_term) else {
451        return Err(invalid("the calendar does not cover the birth"));
452    };
453
454    // Year and month: by the solar terms.
455    let year_cycle = year_cycle_index(lichun.0.civil().0);
456    let month_offset = usize::from((month_term.1.longitude + 360 - LICHUN) % 360 / 30);
457    let month_stem = ((year_cycle % 10) % 5 * 2 + 2 + month_offset) % 10;
458    let month_cycle = cycle_index(month_stem, (2 + month_offset) % 12);
459
460    // Day and hour: by the local clock.
461    let to_micros = |minutes: f64| (minutes * MICROS_PER_MINUTE).round() as i64;
462    let civil = birth.add_micros(to_micros(utc_offset_minutes));
463    let solar = birth.add_micros(to_micros(longitude * 4.0 + calendar.equation_of_time));
464    let local = if options.solar_time { solar } else { civil };
465    let hour = local.civil().3;
466    let date = day_number(local);
467    let late_zi = hour >= 23;
468    let day = if late_zi && zi_hour == ZiHour::NextDay {
469        date + 1
470    } else {
471        date
472    };
473    let day_cycle_of = |day: i64| (day - 11).rem_euclid(60) as usize;
474    let day_cycle = day_cycle_of(day);
475    let master = day_cycle % 10;
476    // 子 is 23:00-01:00, 丑 01:00-03:00, …
477    let hour_branch = (hour as usize).div_ceil(2) % 12;
478    let hour_day_stem = day_cycle_of(if late_zi { date + 1 } else { date }) % 10;
479    let hour_cycle = cycle_index((hour_day_stem % 5 * 2 + hour_branch) % 10, hour_branch);
480
481    let luck = male.map(|male| {
482        let forward = male == (year_cycle % 2 == 0);
483        let days = if forward {
484            next_term.0.seconds_since(&birth)
485        } else {
486            birth.seconds_since(&month_term.0)
487        } / 86_400.0;
488        let start_age = days / 3.0;
489        let at_age = |age: f64| birth.plus_days(age * TROPICAL_YEAR).isoformat();
490        let pillars = (1..=i64::from(options.luck_pillars))
491            .map(|k| {
492                let cycle = (month_cycle as i64 + if forward { k } else { -k }).rem_euclid(60);
493                let age = start_age + 10.0 * (k - 1) as f64;
494                LuckPillar {
495                    start_age: age,
496                    start: at_age(age),
497                    end: at_age(age + 10.0),
498                    pillar: pillar(cycle as usize, master, false),
499                }
500            })
501            .collect();
502        Luck {
503            direction: if forward { "forward" } else { "backward" },
504            start_age,
505            pillars,
506        }
507    });
508
509    Ok(Bazi {
510        time_basis: if options.solar_time { "solar" } else { "civil" },
511        zi_hour: match zi_hour {
512            ZiHour::NextDay => "next_day",
513            ZiHour::Split => "split",
514        },
515        civil_time: wall_clock(civil),
516        solar_time: wall_clock(solar),
517        year: pillar(year_cycle, master, false),
518        month: pillar(month_cycle, master, false),
519        day: pillar(day_cycle, master, true),
520        hour: pillar(hour_cycle, master, false),
521        day_master: STEMS[master],
522        day_master_element: ELEMENTS[stem_element(master)],
523        elements: balance([year_cycle, month_cycle, day_cycle, hour_cycle]),
524        month_term: term_moment(month_term),
525        next_term: term_moment(next_term),
526        lunar_date: lunar_date(calendar, day)?,
527        luck,
528        annual: options
529            .year
530            .map(|year| pillar(year_cycle_index(year), master, false)),
531    })
532}