1use 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
27const HIDDEN_STEMS: [&[usize]; 12] = [
29 &[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], ];
42
43fn 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
53const 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
87pub(crate) fn nayin(pair: usize) -> &'static str {
89 NAYIN[pair]
90}
91
92pub(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
108const BIRTH_BRANCH: [usize; 10] = [11, 6, 2, 9, 2, 9, 5, 0, 8, 3];
111
112const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum ZiHour {
125 NextDay,
127 Split,
129}
130
131#[derive(Debug, Clone)]
133pub struct BaziOptions<'a> {
134 pub solar_time: bool,
137 pub zi_hour: &'a str,
139 pub sex: Option<&'a str>,
142 pub luck_pillars: u8,
144 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#[derive(Debug, Clone, PartialEq, Serialize)]
162pub struct HiddenStem {
163 pub stem: &'static str,
165 pub element: &'static str,
167 pub ten_god: &'static str,
169 pub weight: f64,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize)]
175pub struct Pillar {
176 pub stem: &'static str,
178 pub branch: &'static str,
180 pub cycle: u8,
182 pub element: &'static str,
184 pub polarity: &'static str,
186 pub branch_element: &'static str,
188 pub animal: &'static str,
190 pub ten_god: Option<&'static str>,
192 pub hidden_stems: Vec<HiddenStem>,
194 pub nayin: &'static str,
196 pub life_stage: &'static str,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize)]
203pub struct ElementBalance {
204 pub wood: f64,
206 pub fire: f64,
208 pub earth: f64,
210 pub metal: f64,
212 pub water: f64,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize)]
218pub struct TermMoment {
219 pub name: String,
221 pub utc: String,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize)]
227pub struct LunarDate {
228 pub year: i32,
230 pub month: u8,
232 pub leap: bool,
234 pub day: u8,
236 pub year_stem: &'static str,
238 pub year_branch: &'static str,
240 pub animal: &'static str,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize)]
247pub struct LuckPillar {
248 pub start_age: f64,
250 pub start: String,
252 pub end: String,
254 pub pillar: Pillar,
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize)]
260pub struct Luck {
261 pub direction: &'static str,
263 pub start_age: f64,
265 pub pillars: Vec<LuckPillar>,
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
271pub struct Bazi {
272 pub time_basis: &'static str,
274 pub zi_hour: &'static str,
276 pub civil_time: String,
278 pub solar_time: String,
280 pub year: Pillar,
282 pub month: Pillar,
284 pub day: Pillar,
286 pub hour: Pillar,
288 pub day_master: &'static str,
290 pub day_master_element: &'static str,
292 pub elements: ElementBalance,
294 pub month_term: TermMoment,
296 pub next_term: TermMoment,
298 pub lunar_date: Option<LunarDate>,
300 pub luck: Option<Luck>,
302 pub annual: Option<Pillar>,
304}
305
306fn invalid(message: &str) -> EngineError {
307 EngineError::InvalidInput(message.to_string())
308}
309
310fn 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
316fn 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
322fn 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
333fn 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
381type Dated<'a> = (UtcInstant, &'a SolarTerm);
383
384fn 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
421pub 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 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 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 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}