Skip to main content

lunar_lite/
four_pillars.rs

1//! Four-pillar (四柱 / BaZi) Heavenly Stem and Earthly Branch calculation.
2//!
3//! This is a faithful port of the TypeScript `lunar-lite@0.2.8` function
4//! `getHeavenlyStemAndEarthlyBranchBySolarDate`, validated against generated
5//! fixtures. Given a Gregorian [`SolarDate`], a 时辰 `time_index` (0..=12), and a
6//! [`StemBranchOptions`], it returns the year, month, day, and hour pillars.
7//!
8//! Like the reference, the wall-clock time is synthesized from `time_index` as
9//! `hour = max(time_index * 2 - 1, 0), minute = 30`. The supported range is
10//! **1850-01-01 ..= 2150-12-31**.
11//!
12//! ## Year pillar
13//! - [`YearDivide::Normal`]: the lunar-year pillar (Chinese New Year boundary).
14//! - [`YearDivide::Exact`]: the 立春 (LiChun) boundary, compared at **date**
15//!   granularity (matching the reference's `getYearGanByLiChun`).
16//!
17//! ## Month pillar
18//! The month pillar uses solar terms, not the lunar month, in `Exact` mode.
19//! - [`MonthDivide::Normal`]: lunar-month 五虎遁 (uses [`solar_to_lunar`]).
20//! - [`MonthDivide::Exact`]: the 12 Jie (节) boundaries at **exact second**.
21
22use crate::calendar::validate_solar_date;
23use crate::convert::solar_to_lunar;
24use crate::date::SolarDate;
25use crate::error::LunarError;
26use crate::julian_day::day_pillar_offset;
27use crate::sexagenary::StemBranch;
28use crate::solar_terms::{self, LI_CHUN, MONTH_BRANCH_BEFORE_FIRST_JIE, MONTH_BRANCH_OF_JIE};
29use crate::stem_branch::{EarthlyBranch, HeavenlyStem};
30
31/// Highest valid 时辰 index (late 子时).
32const MAX_TIME_INDEX: u8 = 12;
33
34/// How to resolve the year pillar across the year boundary.
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
38pub enum YearDivide {
39    /// Use the lunar year (Chinese New Year boundary).
40    Normal,
41    /// Use the 立春 (LiChun) boundary, at date granularity.
42    Exact,
43}
44
45/// How to resolve the month pillar across the month boundary.
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
49pub enum MonthDivide {
50    /// Use the lunar month with 五虎遁 (not solar terms).
51    Normal,
52    /// Use the 12 Jie (节) solar-term boundaries at exact second.
53    Exact,
54}
55
56/// Options controlling the year and month pillar boundaries.
57///
58/// The default (`Exact`, `Exact`) matches `lunar-lite@0.2.8`.
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
61pub struct StemBranchOptions {
62    /// Year pillar boundary mode.
63    pub year: YearDivide,
64    /// Month pillar boundary mode.
65    pub month: MonthDivide,
66}
67
68impl Default for StemBranchOptions {
69    fn default() -> Self {
70        Self {
71            year: YearDivide::Exact,
72            month: MonthDivide::Exact,
73        }
74    }
75}
76
77/// The four pillars (年柱, 月柱, 日柱, 时柱) of a date and time.
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
80pub struct FourPillars {
81    /// Year pillar (年柱).
82    pub yearly: StemBranch,
83    /// Month pillar (月柱).
84    pub monthly: StemBranch,
85    /// Day pillar (日柱).
86    pub daily: StemBranch,
87    /// Hour pillar (时柱).
88    pub hourly: StemBranch,
89}
90
91/// Compatibility alias mirroring the TypeScript `lunar-lite` result name.
92pub type HeavenlyStemAndEarthlyBranchDate = FourPillars;
93
94/// Computes the four pillars for a Gregorian solar date and 时辰 index, using the
95/// default ([`StemBranchOptions::default`], i.e. `Exact`/`Exact`, matching
96/// `lunar-lite@0.2.8`).
97///
98/// Use [`get_heavenly_stem_and_earthly_branch_by_solar_date_with_options`] to
99/// choose the year and month boundary conventions explicitly.
100///
101/// # Errors
102/// See [`get_heavenly_stem_and_earthly_branch_by_solar_date_with_options`].
103pub fn get_heavenly_stem_and_earthly_branch_by_solar_date(
104    solar: SolarDate,
105    time_index: u8,
106) -> Result<FourPillars, LunarError> {
107    get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
108        solar,
109        time_index,
110        StemBranchOptions::default(),
111    )
112}
113
114/// Computes the four pillars for a Gregorian solar date and 时辰 index with
115/// explicit [`StemBranchOptions`].
116///
117/// `time_index` is in `0..=12`, where both `0` (early 子) and `12` (late 子) map
118/// to the 子 branch; `12` additionally rolls the day pillar to the next day
119/// (晚子时), matching the reference.
120///
121/// # Errors
122/// - [`LunarError::InvalidSolarDate`] if `solar` is not a real date.
123/// - [`LunarError::InvalidTimeIndex`] if `time_index > 12`.
124/// - [`LunarError::SolarTermOutOfRange`] if `solar.year` is outside 1850..=2150.
125/// - [`LunarError::YearOutOfRange`] for `Normal` options when the lunar year is
126///   outside the table (the early-1850 corner before Chinese New Year 1850).
127pub fn get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
128    solar: SolarDate,
129    time_index: u8,
130    options: StemBranchOptions,
131) -> Result<FourPillars, LunarError> {
132    validate_solar_date(solar)?;
133
134    if time_index > MAX_TIME_INDEX {
135        return Err(LunarError::InvalidTimeIndex { time_index });
136    }
137
138    if !(solar_terms::MIN_YEAR..=solar_terms::MAX_YEAR).contains(&solar.year) {
139        return Err(LunarError::SolarTermOutOfRange { year: solar.year });
140    }
141
142    // Synthesized wall-clock time: hour = max(time_index*2 - 1, 0), minute = 30.
143    let synth_hour = (time_index as i64 * 2 - 1).max(0);
144    let synth_second_of_day = synth_hour * 3600 + 30 * 60;
145
146    let yearly = year_pillar(solar, options.year)?;
147    let monthly = match options.month {
148        MonthDivide::Normal => month_pillar_normal(solar, yearly)?,
149        MonthDivide::Exact => month_pillar_exact(solar, synth_second_of_day)?,
150    };
151
152    // Day pillar: floor(noonJulianDay) - 11, rolled forward for late 子时.
153    let mut day_offset = day_pillar_offset(solar.year, solar.month, solar.day);
154    if time_index == MAX_TIME_INDEX {
155        day_offset += 1;
156    }
157    let daily = StemBranch::from_cycle_index(day_offset.rem_euclid(60) as usize);
158    let day_stem_index = day_offset.rem_euclid(10) as usize;
159
160    // Hour pillar: branch from time_index, stem derived from the (rolled) day stem.
161    let hour_branch_index = (time_index % 12) as usize;
162    let hour_stem_index = (day_stem_index % 5 * 2 + hour_branch_index) % 10;
163    let hourly = pillar_from_indices(hour_stem_index, hour_branch_index);
164
165    Ok(FourPillars {
166        yearly,
167        monthly,
168        daily,
169        hourly,
170    })
171}
172
173/// Rust-native alias for [`get_heavenly_stem_and_earthly_branch_by_solar_date`]
174/// (default `Exact`/`Exact` options).
175pub fn four_pillars_from_solar_date(
176    solar: SolarDate,
177    time_index: u8,
178) -> Result<FourPillars, LunarError> {
179    get_heavenly_stem_and_earthly_branch_by_solar_date(solar, time_index)
180}
181
182/// Rust-native alias for
183/// [`get_heavenly_stem_and_earthly_branch_by_solar_date_with_options`].
184pub fn four_pillars_from_solar_date_with_options(
185    solar: SolarDate,
186    time_index: u8,
187    options: StemBranchOptions,
188) -> Result<FourPillars, LunarError> {
189    get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(solar, time_index, options)
190}
191
192fn year_pillar(solar: SolarDate, divide: YearDivide) -> Result<StemBranch, LunarError> {
193    match divide {
194        YearDivide::Normal => Ok(StemBranch::from_lunar_year(solar_to_lunar(solar)?.year)),
195        YearDivide::Exact => {
196            let (li_chun_month, li_chun_day) = solar_terms::li_chun_date(solar.year)?;
197            let before_li_chun = (solar.month, solar.day) < (li_chun_month, li_chun_day);
198            let pillar_year = if before_li_chun {
199                solar.year - 1
200            } else {
201                solar.year
202            };
203            // Sexagenary of a Gregorian year: the 1984 anchor is congruent mod 60.
204            Ok(StemBranch::from_lunar_year(pillar_year))
205        }
206    }
207}
208
209fn month_pillar_normal(solar: SolarDate, yearly: StemBranch) -> Result<StemBranch, LunarError> {
210    let lunar = solar_to_lunar(solar)?;
211    let year_stem = yearly.stem().index();
212    let yin_stem = (year_stem % 5 * 2 + 2) % 10;
213    // Leap month past its 15th day counts toward the following month.
214    let fix_leap = usize::from(lunar.is_leap_month && lunar.day > 15);
215    let offset = (lunar.month as usize - 1) + fix_leap;
216    let stem = (yin_stem + offset) % 10;
217    let branch = (2 + offset) % 12; // lunar month 1 (正月) == 寅 (index 2)
218    Ok(pillar_from_indices(stem, branch))
219}
220
221fn month_pillar_exact(
222    solar: SolarDate,
223    synth_second_of_day: i64,
224) -> Result<StemBranch, LunarError> {
225    let instant = solar_terms::day_instant(solar.year, solar.month, solar.day, synth_second_of_day);
226    let jie = solar_terms::jie_instants(solar.year)?;
227
228    // Month branch: the branch of the most recent Jie at or before `instant`.
229    let mut branch = MONTH_BRANCH_BEFORE_FIRST_JIE;
230    for (k, &boundary) in jie.iter().enumerate() {
231        if boundary <= instant {
232            branch = MONTH_BRANCH_OF_JIE[k];
233        } else {
234            break;
235        }
236    }
237
238    // Month stem by 五虎遁 from the 立春 suì year (exact-second granularity).
239    let sui_year = if instant >= jie[LI_CHUN] {
240        solar.year
241    } else {
242        solar.year - 1
243    };
244    let sui_stem = (sui_year - 4).rem_euclid(10) as usize;
245    let yin_stem = (sui_stem % 5 * 2 + 2) % 10;
246    let offset_from_yin = (branch + 12 - 2) % 12;
247    let stem = (yin_stem + offset_from_yin) % 10;
248    Ok(pillar_from_indices(stem, branch))
249}
250
251/// Builds a [`StemBranch`] from stem (0..10) and branch (0..12) indices that are
252/// guaranteed to share parity by construction.
253fn pillar_from_indices(stem_index: usize, branch_index: usize) -> StemBranch {
254    StemBranch::try_new(
255        HeavenlyStem::from_index(stem_index),
256        EarthlyBranch::from_index(branch_index),
257    )
258    .expect("computed stem and branch share parity by construction")
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn sb(stem: HeavenlyStem, branch: EarthlyBranch) -> StemBranch {
266        StemBranch::try_new(stem, branch).unwrap()
267    }
268
269    fn solar(year: i32, month: u8, day: u8) -> SolarDate {
270        SolarDate { year, month, day }
271    }
272
273    const EXACT: StemBranchOptions = StemBranchOptions {
274        year: YearDivide::Exact,
275        month: MonthDivide::Exact,
276    };
277    const NORMAL: StemBranchOptions = StemBranchOptions {
278        year: YearDivide::Normal,
279        month: MonthDivide::Normal,
280    };
281
282    // Reference: lunar-lite@0.2.8, 2000-08-16 timeIndex 2 -> 庚辰 甲申 丙午 庚寅.
283    #[test]
284    fn spot_check_2000_08_16() {
285        let r = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
286            solar(2000, 8, 16),
287            2,
288            EXACT,
289        )
290        .unwrap();
291        assert_eq!(r.yearly, sb(HeavenlyStem::Geng, EarthlyBranch::Chen));
292        assert_eq!(r.monthly, sb(HeavenlyStem::Jia, EarthlyBranch::Shen));
293        assert_eq!(r.daily, sb(HeavenlyStem::Bing, EarthlyBranch::Wu));
294        assert_eq!(r.hourly, sb(HeavenlyStem::Geng, EarthlyBranch::Yin));
295
296        // Interior date: normal options agree with exact.
297        let n = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
298            solar(2000, 8, 16),
299            2,
300            NORMAL,
301        )
302        .unwrap();
303        assert_eq!(n, r);
304    }
305
306    // Late 子时: same date, time_index 0 vs 12. Day pillar rolls and the hour stem
307    // follows the rolled day stem.
308    #[test]
309    fn late_zi_rolls_day_and_hour() {
310        let early = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
311            solar(2000, 8, 16),
312            0,
313            EXACT,
314        )
315        .unwrap();
316        assert_eq!(early.daily, sb(HeavenlyStem::Bing, EarthlyBranch::Wu));
317        assert_eq!(early.hourly, sb(HeavenlyStem::Wu, EarthlyBranch::Zi));
318
319        let late = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
320            solar(2000, 8, 16),
321            12,
322            EXACT,
323        )
324        .unwrap();
325        assert_eq!(late.daily, sb(HeavenlyStem::Ding, EarthlyBranch::Wei));
326        assert_eq!(late.hourly, sb(HeavenlyStem::Geng, EarthlyBranch::Zi));
327    }
328
329    #[test]
330    fn all_time_indices_produce_expected_branches() {
331        // Branch index for each time_index: 0 and 12 -> 子, otherwise time_index.
332        for ti in 0..=12u8 {
333            let r = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
334                solar(2000, 8, 16),
335                ti,
336                EXACT,
337            )
338            .unwrap();
339            let expected = EarthlyBranch::from_index((ti % 12) as usize);
340            assert_eq!(r.hourly.branch(), expected, "time_index {ti}");
341        }
342    }
343
344    #[test]
345    fn default_function_equals_explicit_exact_exact() {
346        let default =
347            get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2024, 6, 1), 5).unwrap();
348        let explicit = get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
349            solar(2024, 6, 1),
350            5,
351            EXACT,
352        )
353        .unwrap();
354        assert_eq!(default, explicit);
355    }
356
357    #[test]
358    fn aliases_match_primary_functions() {
359        // Default-options alias.
360        assert_eq!(
361            four_pillars_from_solar_date(solar(2024, 6, 1), 5).unwrap(),
362            get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2024, 6, 1), 5).unwrap(),
363        );
364        // Explicit-options alias.
365        assert_eq!(
366            four_pillars_from_solar_date_with_options(solar(2024, 6, 1), 5, NORMAL).unwrap(),
367            get_heavenly_stem_and_earthly_branch_by_solar_date_with_options(
368                solar(2024, 6, 1),
369                5,
370                NORMAL
371            )
372            .unwrap(),
373        );
374    }
375
376    #[test]
377    fn compatibility_alias_type_is_usable() {
378        let pillars: HeavenlyStemAndEarthlyBranchDate =
379            four_pillars_from_solar_date(solar(2000, 8, 16), 2).unwrap();
380        let native: FourPillars = pillars;
381        assert_eq!(native.yearly, sb(HeavenlyStem::Geng, EarthlyBranch::Chen));
382    }
383
384    #[test]
385    fn invalid_time_index_errors() {
386        assert_eq!(
387            get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2000, 1, 1), 13),
388            Err(LunarError::InvalidTimeIndex { time_index: 13 })
389        );
390    }
391
392    #[test]
393    fn year_out_of_range_errors() {
394        assert_eq!(
395            get_heavenly_stem_and_earthly_branch_by_solar_date(solar(1849, 6, 1), 0),
396            Err(LunarError::SolarTermOutOfRange { year: 1849 })
397        );
398        assert_eq!(
399            get_heavenly_stem_and_earthly_branch_by_solar_date(solar(2151, 6, 1), 0),
400            Err(LunarError::SolarTermOutOfRange { year: 2151 })
401        );
402    }
403
404    #[test]
405    fn default_options_are_exact_exact() {
406        assert_eq!(
407            StemBranchOptions::default(),
408            StemBranchOptions {
409                year: YearDivide::Exact,
410                month: MonthDivide::Exact,
411            }
412        );
413    }
414}