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
92const 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}
145
146impl Default for BaziOptions<'_> {
147 fn default() -> Self {
148 BaziOptions {
149 solar_time: true,
150 zi_hour: "next_day",
151 sex: None,
152 luck_pillars: 10,
153 }
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct HiddenStem {
160 pub stem: &'static str,
162 pub element: &'static str,
164 pub ten_god: &'static str,
166 pub weight: f64,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize)]
172pub struct Pillar {
173 pub stem: &'static str,
175 pub branch: &'static str,
177 pub cycle: u8,
179 pub element: &'static str,
181 pub polarity: &'static str,
183 pub branch_element: &'static str,
185 pub animal: &'static str,
187 pub ten_god: Option<&'static str>,
189 pub hidden_stems: Vec<HiddenStem>,
191 pub nayin: &'static str,
193 pub life_stage: &'static str,
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize)]
200pub struct ElementBalance {
201 pub wood: f64,
203 pub fire: f64,
205 pub earth: f64,
207 pub metal: f64,
209 pub water: f64,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize)]
215pub struct TermMoment {
216 pub name: String,
218 pub utc: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize)]
224pub struct LunarDate {
225 pub year: i32,
227 pub month: u8,
229 pub leap: bool,
231 pub day: u8,
233 pub year_stem: &'static str,
235 pub year_branch: &'static str,
237 pub animal: &'static str,
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize)]
244pub struct LuckPillar {
245 pub start_age: f64,
247 pub start: String,
249 pub end: String,
251 pub pillar: Pillar,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize)]
257pub struct Luck {
258 pub direction: &'static str,
260 pub start_age: f64,
262 pub pillars: Vec<LuckPillar>,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize)]
268pub struct Bazi {
269 pub time_basis: &'static str,
271 pub zi_hour: &'static str,
273 pub civil_time: String,
275 pub solar_time: String,
277 pub year: Pillar,
279 pub month: Pillar,
281 pub day: Pillar,
283 pub hour: Pillar,
285 pub day_master: &'static str,
287 pub day_master_element: &'static str,
289 pub elements: ElementBalance,
291 pub month_term: TermMoment,
293 pub next_term: TermMoment,
295 pub lunar_date: Option<LunarDate>,
297 pub luck: Option<Luck>,
299}
300
301fn invalid(message: &str) -> EngineError {
302 EngineError::InvalidInput(message.to_string())
303}
304
305fn wall_clock(instant: UtcInstant) -> String {
307 let (y, m, d, hh, mm, ss, _) = instant.civil();
308 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}")
309}
310
311fn ten_god(master: usize, stem: usize) -> &'static str {
313 let relation = (stem_element(stem) + 5 - stem_element(master)) % 5;
314 TEN_GODS[relation][usize::from(master % 2 != stem % 2)]
315}
316
317fn life_stage(stem: usize, branch: usize) -> &'static str {
319 let birth = BIRTH_BRANCH[stem] as i64;
320 let steps = if stem % 2 == 0 {
321 branch as i64 - birth
322 } else {
323 birth - branch as i64
324 };
325 LIFE_STAGES[steps.rem_euclid(12) as usize]
326}
327
328fn pillar(cycle: usize, master: usize, is_master: bool) -> Pillar {
330 let (stem, branch) = (cycle % 10, cycle % 12);
331 let hidden = HIDDEN_STEMS[branch];
332 Pillar {
333 stem: STEMS[stem],
334 branch: BRANCHES[branch],
335 cycle: cycle as u8,
336 element: ELEMENTS[stem_element(stem)],
337 polarity: polarity(stem),
338 branch_element: ELEMENTS[branch_element(branch)],
339 animal: ANIMALS[branch],
340 ten_god: (!is_master).then(|| ten_god(master, stem)),
341 hidden_stems: hidden
342 .iter()
343 .zip(hidden_weights(hidden.len()))
344 .map(|(&h, &weight)| HiddenStem {
345 stem: STEMS[h],
346 element: ELEMENTS[stem_element(h)],
347 ten_god: ten_god(master, h),
348 weight,
349 })
350 .collect(),
351 nayin: NAYIN[cycle / 2],
352 life_stage: life_stage(master, branch),
353 }
354}
355
356fn balance(cycles: [usize; 4]) -> ElementBalance {
357 let mut weights = [0.0f64; 5];
358 for cycle in cycles {
359 let (stem, branch) = (cycle % 10, cycle % 12);
360 weights[stem_element(stem)] += 1.0;
361 let hidden = HIDDEN_STEMS[branch];
362 for (&h, &w) in hidden.iter().zip(hidden_weights(hidden.len())) {
363 weights[stem_element(h)] += w;
364 }
365 }
366 let round = |x: f64| (x * 100.0).round() / 100.0;
367 ElementBalance {
368 wood: round(weights[0]),
369 fire: round(weights[1]),
370 earth: round(weights[2]),
371 metal: round(weights[3]),
372 water: round(weights[4]),
373 }
374}
375
376type Dated<'a> = (UtcInstant, &'a SolarTerm);
378
379fn around<'a>(
381 terms: &'a [Dated<'a>],
382 birth: UtcInstant,
383 pick: impl Fn(&SolarTerm) -> bool,
384) -> (Option<&'a Dated<'a>>, Option<&'a Dated<'a>>) {
385 let before = terms.iter().rev().find(|(t, s)| *t <= birth && pick(s));
386 let after = terms.iter().find(|(t, s)| *t > birth && pick(s));
387 (before, after)
388}
389
390fn term_moment((instant, term): &Dated) -> TermMoment {
391 TermMoment {
392 name: term.name.clone(),
393 utc: instant.isoformat(),
394 }
395}
396
397fn lunar_date(calendar: &ChineseCalendar, day: i64) -> Result<Option<LunarDate>, EngineError> {
398 for month in &calendar.lunar_months {
399 let start = month.start_day()?;
400 if start <= day && day < start + i64::from(month.days) {
401 let cycle = year_cycle_index(month.year);
402 return Ok(Some(LunarDate {
403 year: month.year,
404 month: month.month,
405 leap: month.leap,
406 day: (day - start + 1) as u8,
407 year_stem: STEMS[cycle % 10],
408 year_branch: BRANCHES[cycle % 12],
409 animal: ANIMALS[cycle % 12],
410 }));
411 }
412 }
413 Ok(None)
414}
415
416pub fn bazi(
420 calendar: &ChineseCalendar,
421 birth: UtcInstant,
422 longitude: f64,
423 utc_offset_minutes: f64,
424 options: &BaziOptions,
425) -> Result<Bazi, EngineError> {
426 let zi_hour = match options.zi_hour {
427 "" | "next_day" => ZiHour::NextDay,
428 "split" => ZiHour::Split,
429 _ => return Err(invalid("zi_hour must be \"next_day\" or \"split\"")),
430 };
431 let male = match options.sex {
432 None | Some("") => None,
433 Some("male") => Some(true),
434 Some("female") => Some(false),
435 Some(_) => return Err(invalid("sex must be \"male\" or \"female\"")),
436 };
437
438 let terms = calendar
439 .solar_terms
440 .iter()
441 .map(|t| Ok((t.instant()?, t)))
442 .collect::<Result<Vec<_>, EngineError>>()?;
443 let (lichun, _) = around(&terms, birth, |t| t.longitude == LICHUN);
444 let (month_term, next_term) = around(&terms, birth, |t| t.jie);
445 let (Some(lichun), Some(month_term), Some(next_term)) = (lichun, month_term, next_term) else {
446 return Err(invalid("the calendar does not cover the birth"));
447 };
448
449 let year_cycle = year_cycle_index(lichun.0.civil().0);
451 let month_offset = usize::from((month_term.1.longitude + 360 - LICHUN) % 360 / 30);
452 let month_stem = ((year_cycle % 10) % 5 * 2 + 2 + month_offset) % 10;
453 let month_cycle = cycle_index(month_stem, (2 + month_offset) % 12);
454
455 let to_micros = |minutes: f64| (minutes * MICROS_PER_MINUTE).round() as i64;
457 let civil = birth.add_micros(to_micros(utc_offset_minutes));
458 let solar = birth.add_micros(to_micros(longitude * 4.0 + calendar.equation_of_time));
459 let local = if options.solar_time { solar } else { civil };
460 let hour = local.civil().3;
461 let date = day_number(local);
462 let late_zi = hour >= 23;
463 let day = if late_zi && zi_hour == ZiHour::NextDay {
464 date + 1
465 } else {
466 date
467 };
468 let day_cycle_of = |day: i64| (day - 11).rem_euclid(60) as usize;
469 let day_cycle = day_cycle_of(day);
470 let master = day_cycle % 10;
471 let hour_branch = (hour as usize).div_ceil(2) % 12;
473 let hour_day_stem = day_cycle_of(if late_zi { date + 1 } else { date }) % 10;
474 let hour_cycle = cycle_index((hour_day_stem % 5 * 2 + hour_branch) % 10, hour_branch);
475
476 let luck = male.map(|male| {
477 let forward = male == (year_cycle % 2 == 0);
478 let days = if forward {
479 next_term.0.seconds_since(&birth)
480 } else {
481 birth.seconds_since(&month_term.0)
482 } / 86_400.0;
483 let start_age = days / 3.0;
484 let at_age = |age: f64| birth.plus_days(age * TROPICAL_YEAR).isoformat();
485 let pillars = (1..=i64::from(options.luck_pillars))
486 .map(|k| {
487 let cycle = (month_cycle as i64 + if forward { k } else { -k }).rem_euclid(60);
488 let age = start_age + 10.0 * (k - 1) as f64;
489 LuckPillar {
490 start_age: age,
491 start: at_age(age),
492 end: at_age(age + 10.0),
493 pillar: pillar(cycle as usize, master, false),
494 }
495 })
496 .collect();
497 Luck {
498 direction: if forward { "forward" } else { "backward" },
499 start_age,
500 pillars,
501 }
502 });
503
504 Ok(Bazi {
505 time_basis: if options.solar_time { "solar" } else { "civil" },
506 zi_hour: match zi_hour {
507 ZiHour::NextDay => "next_day",
508 ZiHour::Split => "split",
509 },
510 civil_time: wall_clock(civil),
511 solar_time: wall_clock(solar),
512 year: pillar(year_cycle, master, false),
513 month: pillar(month_cycle, master, false),
514 day: pillar(day_cycle, master, true),
515 hour: pillar(hour_cycle, master, false),
516 day_master: STEMS[master],
517 day_master_element: ELEMENTS[stem_element(master)],
518 elements: balance([year_cycle, month_cycle, day_cycle, hour_cycle]),
519 month_term: term_moment(month_term),
520 next_term: term_moment(next_term),
521 lunar_date: lunar_date(calendar, day)?,
522 luck,
523 })
524}