Skip to main content

stem_branch/
lunisolar.rs

1//! Chinese lunisolar calendar — 冬至-anchored month numbering with leap-month
2//! (閏月) detection via the 中氣 (zhongqi) rule. Faithful port of the TypeScript
3//! `lunisolar-calendar`. All instants are carried as JD(UT); civil dates are
4//! Beijing time (UTC+8), matching the authoritative algorithm.
5
6use std::cell::RefCell;
7use std::collections::HashMap;
8
9use crate::delta_t::delta_t_for_year;
10use crate::julian::{jd_from_ymd, ymd_from_jd};
11use crate::new_moon::find_new_moons_in_range;
12use crate::solar_terms::{find_solar_term_moment, SOLAR_TERM_LONGITUDES};
13
14/// A Gregorian civil date in Beijing time.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct CivilDate {
17    pub year: i32,
18    pub month: u32,
19    pub day: u32,
20}
21
22/// A Chinese lunisolar date.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct LunisolarDate {
25    pub year: i32,
26    pub month: u32,
27    pub day: u32,
28    pub is_leap_month: bool,
29}
30
31/// One month of a lunisolar year: its number, leap flag, Gregorian start date
32/// (正月初一 etc., Beijing), and length in days (29 or 30).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct LunarMonth {
35    pub month_number: u32,
36    pub is_leap_month: bool,
37    pub start: CivilDate,
38    pub days: u32,
39}
40
41/// 中氣 (zhongqi) indices into `SOLAR_TERM_LONGITUDES` — the 12 mid-month terms.
42const ZHONGQI_INDICES: [usize; 12] = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23];
43/// 冬至 (winter solstice) index — the month-11 anchor.
44const DONGZHI_INDEX: usize = 23;
45
46/// Sequential month numbers between two consecutive month-11 (冬至) anchors.
47const SEQ: [u32; 11] = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
48
49fn jde_to_jd_ut(jde: f64) -> f64 {
50    let year = 2000.0 + (jde - 2451545.0) / 365.25;
51    jde - delta_t_for_year(year) / 86400.0
52}
53
54fn beijing_civil(jd_ut: f64) -> CivilDate {
55    let (year, month, day) = ymd_from_jd(jd_ut + 8.0 / 24.0);
56    CivilDate { year, month, day }
57}
58
59fn civil_num(cd: CivilDate) -> i64 {
60    i64::from(cd.year) * 10000 + i64::from(cd.month) * 100 + i64::from(cd.day)
61}
62
63fn civil_jd(cd: CivilDate) -> f64 {
64    jd_from_ymd(cd.year, cd.month, cd.day)
65}
66
67/// Whole days between the Beijing civil dates of two instants.
68fn days_between(a_jd_ut: f64, b_jd_ut: f64) -> u32 {
69    let a = civil_jd(beijing_civil(a_jd_ut));
70    let b = civil_jd(beijing_civil(b_jd_ut));
71    (b - a).round() as u32
72}
73
74fn zhongqi_falls_in_month(zq_jd: f64, month_start_jd: f64, next_month_jd: f64) -> bool {
75    let zq = civil_num(beijing_civil(zq_jd));
76    let ms = civil_num(beijing_civil(month_start_jd));
77    let nm = civil_num(beijing_civil(next_month_jd));
78    zq >= ms && zq < nm
79}
80
81/// New-moon instants (JD UT) spanning the given year range, with ±30-day padding.
82fn new_moon_jd_uts(start_year: i32, end_year: i32) -> Vec<f64> {
83    let start_jd = jd_from_ymd(start_year, 1, 1) - 30.0;
84    let end_jd = jd_from_ymd(end_year + 1, 2, 28) + 30.0;
85    find_new_moons_in_range(start_jd, end_jd)
86        .into_iter()
87        .map(jde_to_jd_ut)
88        .collect()
89}
90
91/// 中氣 moments (term index, JD UT) over the year range, sorted chronologically.
92fn zhongqi_moments(start_year: i32, end_year: i32) -> Vec<(usize, f64)> {
93    let mut moments = Vec::new();
94    for y in start_year..=end_year {
95        for &idx in &ZHONGQI_INDICES {
96            let longitude = SOLAR_TERM_LONGITUDES[idx];
97            let start_month = if idx < 2 { 1 } else { (idx / 2 + 1) as u32 };
98            if let Some(jd) = find_solar_term_moment(longitude, y, start_month) {
99                moments.push((idx, jd));
100            }
101        }
102    }
103    moments.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
104    moments
105}
106
107struct MonthSpan {
108    start_jd: f64,
109    days: u32,
110    has_zhongqi: bool,
111    has_dongzhi: bool,
112}
113
114struct NumberedMonth {
115    month_number: u32,
116    is_leap_month: bool,
117    start_jd: f64,
118    days: u32,
119}
120
121/// Build a numbered month sequence (冬至-anchor algorithm): months between two
122/// consecutive 冬至 (month 11) are numbered 12,1,…,10; if there are 12 (not 11)
123/// of them, the first lacking a 中氣 is the leap month.
124fn build_month_sequence(new_moons: &[f64], zhongqi: &[(usize, f64)]) -> Vec<NumberedMonth> {
125    let mut spans: Vec<MonthSpan> = Vec::new();
126    for w in new_moons.windows(2) {
127        let (start, end) = (w[0], w[1]);
128        let mut has_zhongqi = false;
129        let mut has_dongzhi = false;
130        for &(index, date) in zhongqi {
131            if zhongqi_falls_in_month(date, start, end) {
132                has_zhongqi = true;
133                if index == DONGZHI_INDEX {
134                    has_dongzhi = true;
135                }
136            }
137        }
138        spans.push(MonthSpan {
139            start_jd: start,
140            days: days_between(start, end),
141            has_zhongqi,
142            has_dongzhi,
143        });
144    }
145
146    let dongzhi_indices: Vec<usize> = spans
147        .iter()
148        .enumerate()
149        .filter(|(_, s)| s.has_dongzhi)
150        .map(|(i, _)| i)
151        .collect();
152
153    let mut assignments: Vec<Option<(u32, bool)>> = vec![None; spans.len()];
154    for d in 0..dongzhi_indices.len() {
155        let m11 = dongzhi_indices[d];
156        assignments[m11] = Some((11, false));
157        if d + 1 >= dongzhi_indices.len() {
158            continue;
159        }
160        let next_m11 = dongzhi_indices[d + 1];
161        assignments[next_m11] = Some((11, false));
162
163        let intermediate = next_m11 - m11 - 1;
164        let needs_leap = intermediate > 11;
165
166        let mut seq_idx = 0usize;
167        let mut leap_inserted = false;
168        for i in (m11 + 1)..next_m11 {
169            if needs_leap && !leap_inserted && !spans[i].has_zhongqi {
170                let leap_num = if seq_idx == 0 { 11 } else { SEQ[seq_idx - 1] };
171                assignments[i] = Some((leap_num, true));
172                leap_inserted = true;
173            } else {
174                if seq_idx < SEQ.len() {
175                    assignments[i] = Some((SEQ[seq_idx], false));
176                }
177                seq_idx += 1;
178            }
179        }
180    }
181
182    spans
183        .iter()
184        .zip(assignments)
185        .filter_map(|(span, a)| {
186            a.map(|(month_number, is_leap_month)| NumberedMonth {
187                month_number,
188                is_leap_month,
189                start_jd: span.start_jd,
190                days: span.days,
191            })
192        })
193        .collect()
194}
195
196thread_local! {
197    /// Per-year cache — the conjunction new-moon solve (ELP + VSOP per lunation)
198    /// is expensive, and a year is recomputed on every date query without it.
199    /// Keyed by `lunar_year`; the result is a pure function of it.
200    static MONTHS_CACHE: RefCell<HashMap<i32, Vec<LunarMonth>>> = RefCell::new(HashMap::new());
201}
202
203/// Lunar months for the lunisolar year whose 正月 falls in `lunar_year` — 12
204/// months, or 13 in a leap year.
205///
206/// # Panics
207/// Panics if month 1 cannot be located for the year (outside the supported
208/// astronomical range).
209#[must_use]
210pub fn lunar_months_for_year(lunar_year: i32) -> Vec<LunarMonth> {
211    if let Some(v) = MONTHS_CACHE.with(|c| c.borrow().get(&lunar_year).cloned()) {
212        return v;
213    }
214    let result = compute_lunar_months_for_year(lunar_year);
215    MONTHS_CACHE.with(|c| c.borrow_mut().insert(lunar_year, result.clone()));
216    result
217}
218
219fn compute_lunar_months_for_year(lunar_year: i32) -> Vec<LunarMonth> {
220    let new_moons = new_moon_jd_uts(lunar_year - 1, lunar_year + 1);
221    let zhongqi = zhongqi_moments(lunar_year - 1, lunar_year + 1);
222    let sequence = build_month_sequence(&new_moons, &zhongqi);
223
224    let month1_idx = sequence
225        .iter()
226        .position(|m| {
227            m.month_number == 1 && !m.is_leap_month && beijing_civil(m.start_jd).year == lunar_year
228        })
229        .unwrap_or_else(|| panic!("cannot find month 1 for lunar year {lunar_year}"));
230
231    let mut result = Vec::new();
232    for (i, m) in sequence.iter().enumerate().skip(month1_idx) {
233        if i > month1_idx && m.month_number == 1 && !m.is_leap_month {
234            break;
235        }
236        result.push(LunarMonth {
237            month_number: m.month_number,
238            is_leap_month: m.is_leap_month,
239            start: beijing_civil(m.start_jd),
240            days: m.days,
241        });
242    }
243    result
244}
245
246/// The Lunar New Year (正月初一) Gregorian date for `gregorian_year`.
247///
248/// # Panics
249/// Panics if month 1 cannot be located (outside the supported range).
250#[must_use]
251pub fn lunar_new_year(gregorian_year: i32) -> CivilDate {
252    lunar_months_for_year(gregorian_year)
253        .into_iter()
254        .find(|m| m.month_number == 1 && !m.is_leap_month)
255        .unwrap_or_else(|| panic!("cannot find month 1 for lunar year {gregorian_year}"))
256        .start
257}
258
259/// Convert a Gregorian (Beijing) civil date to its Chinese lunisolar date.
260///
261/// # Panics
262/// Panics if the date cannot be converted (outside the supported range).
263#[must_use]
264pub fn gregorian_to_lunisolar(date: CivilDate) -> LunisolarDate {
265    let target_jd = civil_jd(date);
266    for lunar_year in [date.year, date.year - 1, date.year + 1] {
267        let months = lunar_months_for_year(lunar_year);
268        for m in months.iter().rev() {
269            let start_jd = civil_jd(m.start);
270            if target_jd >= start_jd {
271                let lunar_day = (target_jd - start_jd).round() as i64 + 1;
272                if lunar_day >= 1 && lunar_day <= i64::from(m.days) {
273                    return LunisolarDate {
274                        year: lunar_year,
275                        month: m.month_number,
276                        day: lunar_day as u32,
277                        is_leap_month: m.is_leap_month,
278                    };
279                }
280            }
281        }
282    }
283    panic!(
284        "cannot convert {}-{}-{} to a lunisolar date",
285        date.year, date.month, date.day
286    )
287}