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_dt, 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    /// True if the deciding new moon is within the ΔT-prediction uncertainty of
30    /// Beijing midnight, so the date could shift by a day under a different but
31    /// equally plausible future ΔT model. Always false for ΔT-observed years
32    /// (≤ 2050); see `docs/validation.md` "ΔT limit".
33    pub boundary_uncertain: bool,
34}
35
36/// One month of a lunisolar year: its number, leap flag, Gregorian start date
37/// (正月初一 etc., Beijing), and length in days (29 or 30).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct LunarMonth {
40    pub month_number: u32,
41    pub is_leap_month: bool,
42    pub start: CivilDate,
43    pub days: u32,
44    /// True if this month's start new moon is ΔT-uncertain (see [`LunisolarDate`]).
45    pub boundary_uncertain: bool,
46}
47
48/// 中氣 (zhongqi) indices into `SOLAR_TERM_LONGITUDES` — the 12 mid-month terms.
49const ZHONGQI_INDICES: [usize; 12] = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23];
50/// 冬至 (winter solstice) index — the month-11 anchor.
51const DONGZHI_INDEX: usize = 23;
52
53/// Sequential month numbers between two consecutive month-11 (冬至) anchors.
54const SEQ: [u32; 11] = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
55
56fn jde_to_jd_ut(jde: f64, dt: fn(f64) -> f64) -> f64 {
57    let year = 2000.0 + (jde - 2451545.0) / 365.25;
58    jde - dt(year) / 86400.0
59}
60
61/// Espenak–Meeus long-term ΔT parabola — a standard, higher future extrapolation
62/// than the default model; used only to size the boundary-uncertainty margin.
63fn em_parabola(year: f64) -> f64 {
64    let u = (year - 1820.0) / 100.0;
65    -20.0 + 32.0 * u * u
66}
67
68/// Whether a month-start new moon is close enough to Beijing midnight that a
69/// different (plausible) ΔT model would move it to the adjacent day. Zero margin
70/// for ΔT-observed years (≤ 2050); beyond, the margin is the gap between the
71/// supplied ΔT and the standard long-term parabola.
72fn start_is_uncertain(start_jd_ut: f64, dt: fn(f64) -> f64) -> bool {
73    let beijing = start_jd_ut + 8.0 / 24.0;
74    let year = ymd_from_jd(beijing).0;
75    if year <= 2050 {
76        return false;
77    }
78    let sigma_sec = (em_parabola(f64::from(year)) - dt(f64::from(year))).max(0.0);
79    let shifted = beijing + 0.5;
80    let frac = shifted - shifted.floor();
81    let dist_sec = frac.min(1.0 - frac) * 86400.0;
82    dist_sec < sigma_sec
83}
84
85fn beijing_civil(jd_ut: f64) -> CivilDate {
86    let (year, month, day) = ymd_from_jd(jd_ut + 8.0 / 24.0);
87    CivilDate { year, month, day }
88}
89
90fn civil_num(cd: CivilDate) -> i64 {
91    i64::from(cd.year) * 10000 + i64::from(cd.month) * 100 + i64::from(cd.day)
92}
93
94fn civil_jd(cd: CivilDate) -> f64 {
95    jd_from_ymd(cd.year, cd.month, cd.day)
96}
97
98/// Whole days between the Beijing civil dates of two instants.
99fn days_between(a_jd_ut: f64, b_jd_ut: f64) -> u32 {
100    let a = civil_jd(beijing_civil(a_jd_ut));
101    let b = civil_jd(beijing_civil(b_jd_ut));
102    (b - a).round() as u32
103}
104
105fn zhongqi_falls_in_month(zq_jd: f64, month_start_jd: f64, next_month_jd: f64) -> bool {
106    let zq = civil_num(beijing_civil(zq_jd));
107    let ms = civil_num(beijing_civil(month_start_jd));
108    let nm = civil_num(beijing_civil(next_month_jd));
109    zq >= ms && zq < nm
110}
111
112/// New-moon instants (JD UT) spanning the given year range, with ±30-day padding.
113fn new_moon_jd_uts(start_year: i32, end_year: i32, dt: fn(f64) -> f64) -> Vec<f64> {
114    let start_jd = jd_from_ymd(start_year, 1, 1) - 30.0;
115    let end_jd = jd_from_ymd(end_year + 1, 2, 28) + 30.0;
116    find_new_moons_in_range(start_jd, end_jd)
117        .into_iter()
118        .map(|jde| jde_to_jd_ut(jde, dt))
119        .collect()
120}
121
122/// 中氣 moments (term index, JD UT) over the year range, sorted chronologically.
123fn zhongqi_moments(start_year: i32, end_year: i32, dt: fn(f64) -> f64) -> Vec<(usize, f64)> {
124    let mut moments = Vec::new();
125    for y in start_year..=end_year {
126        for &idx in &ZHONGQI_INDICES {
127            let longitude = SOLAR_TERM_LONGITUDES[idx];
128            let start_month = if idx < 2 { 1 } else { (idx / 2 + 1) as u32 };
129            if let Some(jd) = find_solar_term_moment_dt(longitude, y, start_month, dt) {
130                moments.push((idx, jd));
131            }
132        }
133    }
134    moments.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
135    moments
136}
137
138struct MonthSpan {
139    start_jd: f64,
140    days: u32,
141    has_zhongqi: bool,
142    has_dongzhi: bool,
143}
144
145struct NumberedMonth {
146    month_number: u32,
147    is_leap_month: bool,
148    start_jd: f64,
149    days: u32,
150}
151
152/// Build a numbered month sequence (冬至-anchor algorithm): months between two
153/// consecutive 冬至 (month 11) are numbered 12,1,…,10; if there are 12 (not 11)
154/// of them, the first lacking a 中氣 is the leap month.
155fn build_month_sequence(new_moons: &[f64], zhongqi: &[(usize, f64)]) -> Vec<NumberedMonth> {
156    let mut spans: Vec<MonthSpan> = Vec::new();
157    for w in new_moons.windows(2) {
158        let (start, end) = (w[0], w[1]);
159        let mut has_zhongqi = false;
160        let mut has_dongzhi = false;
161        for &(index, date) in zhongqi {
162            if zhongqi_falls_in_month(date, start, end) {
163                has_zhongqi = true;
164                if index == DONGZHI_INDEX {
165                    has_dongzhi = true;
166                }
167            }
168        }
169        spans.push(MonthSpan {
170            start_jd: start,
171            days: days_between(start, end),
172            has_zhongqi,
173            has_dongzhi,
174        });
175    }
176
177    let dongzhi_indices: Vec<usize> = spans
178        .iter()
179        .enumerate()
180        .filter(|(_, s)| s.has_dongzhi)
181        .map(|(i, _)| i)
182        .collect();
183
184    let mut assignments: Vec<Option<(u32, bool)>> = vec![None; spans.len()];
185    for d in 0..dongzhi_indices.len() {
186        let m11 = dongzhi_indices[d];
187        assignments[m11] = Some((11, false));
188        if d + 1 >= dongzhi_indices.len() {
189            continue;
190        }
191        let next_m11 = dongzhi_indices[d + 1];
192        assignments[next_m11] = Some((11, false));
193
194        let intermediate = next_m11 - m11 - 1;
195        let needs_leap = intermediate > 11;
196
197        let mut seq_idx = 0usize;
198        let mut leap_inserted = false;
199        for i in (m11 + 1)..next_m11 {
200            if needs_leap && !leap_inserted && !spans[i].has_zhongqi {
201                let leap_num = if seq_idx == 0 { 11 } else { SEQ[seq_idx - 1] };
202                assignments[i] = Some((leap_num, true));
203                leap_inserted = true;
204            } else {
205                if seq_idx < SEQ.len() {
206                    assignments[i] = Some((SEQ[seq_idx], false));
207                }
208                seq_idx += 1;
209            }
210        }
211    }
212
213    spans
214        .iter()
215        .zip(assignments)
216        .filter_map(|(span, a)| {
217            a.map(|(month_number, is_leap_month)| NumberedMonth {
218                month_number,
219                is_leap_month,
220                start_jd: span.start_jd,
221                days: span.days,
222            })
223        })
224        .collect()
225}
226
227thread_local! {
228    /// Per-year cache — the conjunction new-moon solve (ELP + VSOP per lunation)
229    /// is expensive, and a year is recomputed on every date query without it.
230    /// Keyed by `lunar_year`; the result is a pure function of it.
231    static MONTHS_CACHE: RefCell<HashMap<i32, Vec<LunarMonth>>> = RefCell::new(HashMap::new());
232}
233
234/// Lunar months for the lunisolar year whose 正月 falls in `lunar_year` — 12
235/// months, or 13 in a leap year.
236///
237/// # Panics
238/// Panics if month 1 cannot be located for the year (outside the supported
239/// astronomical range).
240#[must_use]
241pub fn lunar_months_for_year(lunar_year: i32) -> Vec<LunarMonth> {
242    if let Some(v) = MONTHS_CACHE.with(|c| c.borrow().get(&lunar_year).cloned()) {
243        return v;
244    }
245    let result = compute_lunar_months_for_year(lunar_year, delta_t_for_year);
246    MONTHS_CACHE.with(|c| c.borrow_mut().insert(lunar_year, result.clone()));
247    result
248}
249
250fn compute_lunar_months_for_year(lunar_year: i32, dt: fn(f64) -> f64) -> Vec<LunarMonth> {
251    let new_moons = new_moon_jd_uts(lunar_year - 1, lunar_year + 1, dt);
252    let zhongqi = zhongqi_moments(lunar_year - 1, lunar_year + 1, dt);
253    let sequence = build_month_sequence(&new_moons, &zhongqi);
254
255    let month1_idx = sequence
256        .iter()
257        .position(|m| {
258            m.month_number == 1 && !m.is_leap_month && beijing_civil(m.start_jd).year == lunar_year
259        })
260        .unwrap_or_else(|| panic!("cannot find month 1 for lunar year {lunar_year}"));
261
262    let mut result = Vec::new();
263    for (i, m) in sequence.iter().enumerate().skip(month1_idx) {
264        if i > month1_idx && m.month_number == 1 && !m.is_leap_month {
265            break;
266        }
267        result.push(LunarMonth {
268            month_number: m.month_number,
269            is_leap_month: m.is_leap_month,
270            start: beijing_civil(m.start_jd),
271            days: m.days,
272            boundary_uncertain: start_is_uncertain(m.start_jd, dt),
273        });
274    }
275    result
276}
277
278/// The Lunar New Year (正月初一) Gregorian date for `gregorian_year`.
279///
280/// # Panics
281/// Panics if month 1 cannot be located (outside the supported range).
282#[must_use]
283pub fn lunar_new_year(gregorian_year: i32) -> CivilDate {
284    lunar_months_for_year(gregorian_year)
285        .into_iter()
286        .find(|m| m.month_number == 1 && !m.is_leap_month)
287        .unwrap_or_else(|| panic!("cannot find month 1 for lunar year {gregorian_year}"))
288        .start
289}
290
291/// Convert a Gregorian (Beijing) civil date to its Chinese lunisolar date.
292///
293/// # Panics
294/// Panics if the date cannot be converted (outside the supported range).
295#[must_use]
296pub fn gregorian_to_lunisolar(date: CivilDate) -> LunisolarDate {
297    convert(date, true, delta_t_for_year)
298}
299
300/// As [`gregorian_to_lunisolar`], with a caller-supplied ΔT model
301/// (decimal year → seconds) — e.g. to match an authority's calendar at a
302/// ΔT-uncertain far-future boundary. Bypasses the per-year cache.
303///
304/// # Panics
305/// Panics if the date cannot be converted (outside the supported range).
306#[must_use]
307pub fn gregorian_to_lunisolar_with(date: CivilDate, dt: fn(f64) -> f64) -> LunisolarDate {
308    convert(date, false, dt)
309}
310
311fn convert(date: CivilDate, cached: bool, dt: fn(f64) -> f64) -> LunisolarDate {
312    let target_jd = civil_jd(date);
313    for lunar_year in [date.year, date.year - 1, date.year + 1] {
314        let months = if cached {
315            lunar_months_for_year(lunar_year)
316        } else {
317            compute_lunar_months_for_year(lunar_year, dt)
318        };
319        for m in months.iter().rev() {
320            let start_jd = civil_jd(m.start);
321            if target_jd >= start_jd {
322                let lunar_day = (target_jd - start_jd).round() as i64 + 1;
323                if lunar_day >= 1 && lunar_day <= i64::from(m.days) {
324                    return LunisolarDate {
325                        year: lunar_year,
326                        month: m.month_number,
327                        day: lunar_day as u32,
328                        is_leap_month: m.is_leap_month,
329                        boundary_uncertain: m.boundary_uncertain,
330                    };
331                }
332            }
333        }
334    }
335    panic!(
336        "cannot convert {}-{}-{} to a lunisolar date",
337        date.year, date.month, date.day
338    )
339}