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