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