1use 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
31const MAX_TIME_INDEX: u8 = 12;
33
34#[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 Normal,
41 Exact,
43}
44
45#[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 Normal,
52 Exact,
54}
55
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
61pub struct StemBranchOptions {
62 pub year: YearDivide,
64 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
80pub struct FourPillars {
81 pub yearly: StemBranch,
83 pub monthly: StemBranch,
85 pub daily: StemBranch,
87 pub hourly: StemBranch,
89}
90
91pub type HeavenlyStemAndEarthlyBranchDate = FourPillars;
93
94pub 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
114pub 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 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 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 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
173pub 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
182pub 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 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 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; 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 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 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
251fn 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 #[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 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 #[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 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 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 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}