1use 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
31const HIDDEN_STEMS: [&[usize]; 12] = [
33 &[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], ];
46
47fn 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
57const 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
91pub(crate) fn nayin(pair: usize) -> &'static str {
93 NAYIN[pair]
94}
95
96pub(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
112const BIRTH_BRANCH: [usize; 10] = [11, 6, 2, 9, 2, 9, 5, 0, 8, 3];
115
116const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum ZiHour {
129 NextDay,
131 Split,
133}
134
135#[derive(Debug, Clone)]
137pub struct BaziOptions<'a> {
138 pub solar_time: bool,
141 pub zi_hour: &'a str,
143 pub sex: Option<&'a str>,
146 pub luck_pillars: u8,
148 pub year: Option<i32>,
150 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#[derive(Debug, Clone, PartialEq, Serialize)]
170pub struct HiddenStem {
171 pub stem: &'static str,
173 pub element: &'static str,
175 pub ten_god: &'static str,
177 pub weight: f64,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct Pillar {
184 pub stem: &'static str,
186 pub branch: &'static str,
188 pub cycle: u8,
190 pub element: &'static str,
192 pub polarity: &'static str,
194 pub branch_element: &'static str,
196 pub animal: &'static str,
198 pub ten_god: Option<&'static str>,
200 pub hidden_stems: Vec<HiddenStem>,
202 pub nayin: &'static str,
204 pub life_stage: &'static str,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize)]
211pub struct ElementBalance {
212 pub wood: f64,
214 pub fire: f64,
216 pub earth: f64,
218 pub metal: f64,
220 pub water: f64,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize)]
226pub struct TermMoment {
227 pub name: String,
229 pub utc: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize)]
235pub struct LunarDate {
236 pub year: i32,
238 pub month: u8,
240 pub leap: bool,
242 pub day: u8,
244 pub year_stem: &'static str,
246 pub year_branch: &'static str,
248 pub animal: &'static str,
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize)]
255pub struct LuckPillar {
256 pub start_age: f64,
258 pub start: String,
260 pub end: String,
262 pub pillar: Pillar,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize)]
268pub struct Luck {
269 pub direction: &'static str,
271 pub start_age: f64,
273 pub pillars: Vec<LuckPillar>,
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize)]
279pub struct BaziFlow {
280 pub date: String,
282 pub year: Pillar,
284 pub month: Pillar,
286 pub day: Pillar,
288 pub month_term: TermMoment,
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize)]
294pub struct Bazi {
295 pub time_basis: &'static str,
297 pub zi_hour: &'static str,
299 pub civil_time: String,
301 pub solar_time: String,
303 pub year: Pillar,
305 pub month: Pillar,
307 pub day: Pillar,
309 pub hour: Pillar,
311 pub day_master: &'static str,
313 pub day_master_element: &'static str,
315 pub elements: ElementBalance,
317 pub month_term: TermMoment,
319 pub next_term: TermMoment,
321 pub lunar_date: Option<LunarDate>,
323 pub luck: Option<Luck>,
325 pub annual: Option<Pillar>,
327 pub flow: Option<BaziFlow>,
329}
330
331fn invalid(message: &str) -> EngineError {
332 EngineError::InvalidInput(message.to_string())
333}
334
335fn 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
341fn 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
347fn 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
358fn 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
406type Dated<'a> = (UtcInstant, &'a SolarTerm);
408
409fn 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
427const 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
443fn flow_of(date: &str, utc_offset_minutes: f64, master: usize) -> Result<BaziFlow, EngineError> {
445 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 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
491pub 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 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 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 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}