Skip to main content

astroceleste_engine/chinese/
calendar.rs

1//! The Chinese calendar around a moment: the solar terms, the lunar months and the
2//! equation of time, which Ba Zi and Zi Wei Dou Shu are cast from.
3//!
4//! The rules are those of the modern calendar: true solar terms and true new moons (in
5//! force since 1645), dated in China Standard Time (UTC+8) from 1929 and on the Beijing
6//! meridian (116°25′ E) before. They are applied to every date, so dates before 1645
7//! follow them proleptically rather than the mean-term calendars then in use.
8//!
9//! A lunar month starts on the day (China time) of a new moon. Month 11 is the month that
10//! holds the winter solstice. When 13 months run from one month 11 to the next, the first
11//! of them without a major term (中氣) is intercalary and repeats the number before it.
12
13use serde::{Deserialize, Serialize};
14
15use crate::constants::TAU;
16use crate::ephemeris::observe::{apparent, ecliptic_latlon, observe};
17use crate::ephemeris::KernelSet;
18use crate::error::EngineError;
19use crate::frames::nutation::iau2000b_radians;
20use crate::frames::{mxv, Orientation};
21use crate::instant::UtcInstant;
22use crate::planets::require_kernel;
23use crate::time::Time;
24
25const SUN: i32 = 10;
26const MOON: i32 = 301;
27const TROPICAL_YEAR: f64 = 365.242_19;
28const SYNODIC_MONTH: f64 = 29.530_589;
29/// Mean motion of the Sun, degrees per day.
30const SUN_RATE: f64 = 360.0 / TROPICAL_YEAR;
31/// Mean motion of the Moon away from the Sun, degrees per day.
32const ELONGATION_RATE: f64 = 360.0 / SYNODIC_MONTH;
33/// Search precision, days (under 0.01 s).
34const EPSILON_DAYS: f64 = 1e-7;
35/// Julian date of the Unix epoch.
36const UNIX_EPOCH_JD: f64 = 2_440_587.5;
37/// Julian day number of 1970-01-01.
38const UNIX_EPOCH_JDN: i64 = 2_440_588;
39const MICROS_PER_DAY: i64 = 86_400_000_000;
40/// Births this close (days) to a lunar month boundary may fall either side of it once
41/// converted to the local civil or solar date, so the months around are kept too.
42const DATE_MARGIN: i64 = 3;
43
44/// The 24 solar terms by the Sun's longitude / 15°, from the spring equinox.
45const TERM_NAMES: [&str; 24] = [
46    "chunfen",
47    "qingming",
48    "guyu",
49    "lixia",
50    "xiaoman",
51    "mangzhong",
52    "xiazhi",
53    "xiaoshu",
54    "dashu",
55    "liqiu",
56    "chushu",
57    "bailu",
58    "qiufen",
59    "hanlu",
60    "shuangjiang",
61    "lidong",
62    "xiaoxue",
63    "daxue",
64    "dongzhi",
65    "xiaohan",
66    "dahan",
67    "lichun",
68    "yushui",
69    "jingzhe",
70];
71
72/// The longitude of 立春, the Beginning of Spring, which opens the year of the pillars.
73pub(crate) const LICHUN: u16 = 315;
74
75/// One of the 24 solar terms (節氣): the moment the Sun reaches a multiple of 15°.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct SolarTerm {
78    /// Pinyin code, e.g. "lichun".
79    pub name: String,
80    /// The Sun's apparent ecliptic longitude, degrees (a multiple of 15).
81    pub longitude: u16,
82    /// A "jie" (節) term, which opens a solar month (longitudes 15° + 30°·k); otherwise a
83    /// major term (中氣, multiples of 30°).
84    pub jie: bool,
85    /// The moment, ISO 8601 UTC to the second.
86    pub utc: String,
87}
88
89/// A month of the lunisolar calendar.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct LunarMonth {
92    /// The lunar year: the Gregorian year its New Year falls in.
93    pub year: i32,
94    /// Month number, 1-12.
95    pub month: u8,
96    /// Whether it is the intercalary month (閏月) repeating `month`.
97    pub leap: bool,
98    /// First day, `YYYY-MM-DD` (China time).
99    pub start: String,
100    /// Length in days: 29 or 30.
101    pub days: u8,
102}
103
104/// The Chinese calendar around a moment: what the Four Pillars and the Purple Star chart
105/// need from the ephemeris.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct ChineseCalendar {
108    /// Apparent minus mean solar time at the moment, minutes.
109    pub equation_of_time: f64,
110    /// Every solar term from the last 立春 at or before the moment to the first jie term
111    /// after it.
112    pub solar_terms: Vec<SolarTerm>,
113    /// The lunar months around the date of the moment (a few days either side).
114    pub lunar_months: Vec<LunarMonth>,
115}
116
117impl SolarTerm {
118    /// The moment of the term.
119    pub(crate) fn instant(&self) -> Result<UtcInstant, EngineError> {
120        UtcInstant::parse(&self.utc).map_err(|e| EngineError::InvalidInput(e.to_string()))
121    }
122}
123
124impl LunarMonth {
125    /// Julian day number of the first day.
126    pub(crate) fn start_day(&self) -> Result<i64, EngineError> {
127        let start = UtcInstant::parse(&format!("{}T00:00:00Z", self.start))
128            .map_err(|e| EngineError::InvalidInput(e.to_string()))?;
129        Ok(day_number(start))
130    }
131}
132
133/// Julian day number of the UTC date of `instant`.
134pub(crate) fn day_number(instant: UtcInstant) -> i64 {
135    instant.micros().div_euclid(MICROS_PER_DAY) + UNIX_EPOCH_JDN
136}
137
138/// `YYYY-MM-DD` of a Julian day number.
139fn date_of(day: i64) -> String {
140    let (y, m, d, ..) = UtcInstant::from_micros((day - UNIX_EPOCH_JDN) * MICROS_PER_DAY).civil();
141    format!("{y:04}-{m:02}-{d:02}")
142}
143
144/// The instant of a UT Julian date, to the second.
145fn instant_of(jd: f64) -> UtcInstant {
146    let seconds = ((jd - UNIX_EPOCH_JD) * 86_400.0).round() as i64;
147    UtcInstant::from_micros(seconds * 1_000_000)
148}
149
150fn wrap180(degrees: f64) -> f64 {
151    (degrees + 180.0).rem_euclid(360.0) - 180.0
152}
153
154/// Offset of China time from UT, days: UTC+8 from 1929, the Beijing meridian before.
155fn china_offset(jd: f64) -> f64 {
156    // 1929-01-01T00:00 at UTC+8.
157    const CST_SINCE_JD: f64 = 2_425_612.5 - 8.0 / 24.0;
158    if jd < CST_SINCE_JD {
159        (116.0 + 25.0 / 60.0) / 360.0
160    } else {
161        8.0 / 24.0
162    }
163}
164
165/// Julian day number of the date in China at UT Julian date `jd`.
166fn china_day(jd: f64) -> i64 {
167    (jd + 0.5 + china_offset(jd)).floor() as i64
168}
169
170/// Apparent geocentric positions for the searches.
171struct Sky<'a> {
172    kernels: &'a KernelSet,
173}
174
175impl Sky<'_> {
176    /// Apparent ecliptic longitude of date of `target` at UT Julian date `jd`, degrees.
177    fn longitude(&self, target: i32, jd: f64) -> Result<f64, EngineError> {
178        let kernel = require_kernel(self.kernels, jd)?;
179        let t = Time::from_ut1(jd);
180        // The faster IAU 2000B nutation: it moves a term by well under a second.
181        let orientation = Orientation::with_nutation(&t, iau2000b_radians(t.tt()));
182        let position = apparent(kernel, &observe(kernel, target, &t)?, &t)?;
183        Ok(ecliptic_latlon(&orientation, &position).1)
184    }
185
186    fn sun(&self, jd: f64) -> Result<f64, EngineError> {
187        self.longitude(SUN, jd)
188    }
189
190    fn elongation(&self, jd: f64) -> Result<f64, EngineError> {
191        Ok(self.longitude(MOON, jd)? - self.sun(jd)?)
192    }
193
194    /// When the Sun reaches `longitude`, starting from `guess` (secant method).
195    fn sun_at(&self, longitude: f64, guess: f64) -> Result<f64, EngineError> {
196        crossing(|jd| self.sun(jd), longitude, guess, SUN_RATE)
197    }
198
199    /// The new moon nearest `guess`.
200    fn new_moon_near(&self, guess: f64) -> Result<f64, EngineError> {
201        crossing(|jd| self.elongation(jd), 0.0, guess, ELONGATION_RATE)
202    }
203
204    /// The last moment at or before `jd` when the Sun was at `longitude`.
205    fn sun_before(&self, longitude: f64, jd: f64) -> Result<f64, EngineError> {
206        let behind = (self.sun(jd)? - longitude).rem_euclid(360.0);
207        let found = self.sun_at(longitude, jd - behind / SUN_RATE)?;
208        if found > jd {
209            self.sun_at(longitude, found - TROPICAL_YEAR)
210        } else {
211            Ok(found)
212        }
213    }
214
215    /// The last new moon at or before `jd`.
216    fn new_moon_before(&self, jd: f64) -> Result<f64, EngineError> {
217        let behind = self.elongation(jd)?.rem_euclid(360.0);
218        let found = self.new_moon_near(jd - behind / ELONGATION_RATE)?;
219        if found > jd {
220            self.new_moon_near(found - SYNODIC_MONTH)
221        } else {
222            Ok(found)
223        }
224    }
225
226    /// The new moon that starts the month holding the China-time day of the winter
227    /// solstice `solstice`: month 11.
228    fn month_eleven(&self, solstice: f64) -> Result<f64, EngineError> {
229        let solstice_day = china_day(solstice);
230        let moon = self.new_moon_before(solstice + 1.0)?;
231        if china_day(moon) > solstice_day {
232            self.new_moon_before(moon - 1.0)
233        } else {
234            Ok(moon)
235        }
236    }
237
238    /// The months of the year (歲) from the month 11 holding `solstice` to the next
239    /// month 11, with the first day of that next month 11 and the next solstice.
240    fn sui(&self, solstice: f64) -> Result<(Vec<Month>, i64, f64), EngineError> {
241        let next_solstice = self.sun_at(270.0, solstice + TROPICAL_YEAR)?;
242        let end = china_day(self.month_eleven(next_solstice)?);
243
244        let mut moon = self.month_eleven(solstice)?;
245        let mut starts = vec![china_day(moon)];
246        loop {
247            moon = self.new_moon_near(moon + SYNODIC_MONTH)?;
248            let day = china_day(moon);
249            if day >= end {
250                break;
251            }
252            starts.push(day);
253        }
254
255        // The major terms: the solstice and the eleven after it (the next solstice
256        // already starts the next year).
257        let mut major_days = vec![china_day(solstice)];
258        let mut term = solstice;
259        for k in 1..12 {
260            term = self.sun_at(f64::from((270 + 30 * k) % 360), term + 30.44)?;
261            major_days.push(china_day(term));
262        }
263
264        let (first_year, ..) =
265            UtcInstant::from_micros((starts[0] - UNIX_EPOCH_JDN) * MICROS_PER_DAY).civil();
266        let mut ends: Vec<i64> = starts[1..].to_vec();
267        ends.push(end);
268        let mut leap_pending = starts.len() == 13;
269        let mut number = 11u8;
270        let mut year = first_year;
271        let mut months = Vec::with_capacity(starts.len());
272        for (i, (&start, &month_end)) in starts.iter().zip(&ends).enumerate() {
273            let has_major = major_days.iter().any(|&d| start <= d && d < month_end);
274            let leap = i > 0 && leap_pending && !has_major;
275            if leap {
276                leap_pending = false;
277            } else if i > 0 {
278                number = number % 12 + 1;
279                if number == 1 {
280                    year = first_year + 1;
281                }
282            }
283            months.push(Month {
284                start,
285                end: month_end,
286                number,
287                leap,
288                year,
289            });
290        }
291        Ok((months, end, next_solstice))
292    }
293
294    /// Equation of time at `jd`, minutes: apparent minus mean solar time.
295    fn equation_of_time(&self, jd: f64) -> Result<f64, EngineError> {
296        let kernel = require_kernel(self.kernels, jd)?;
297        let t = Time::from_ut1(jd);
298        let orientation = Orientation::with_nutation(&t, iau2000b_radians(t.tt()));
299        let position = apparent(kernel, &observe(kernel, SUN, &t)?, &t)?;
300        let equatorial = mxv(&orientation.m, &position);
301        let ra_hours = equatorial[1].atan2(equatorial[0]).rem_euclid(TAU) * 24.0 / TAU;
302        let apparent_hours = orientation.gast_hours - ra_hours + 12.0;
303        let mean_hours = (jd + 0.5).rem_euclid(1.0) * 24.0;
304        Ok(((apparent_hours - mean_hours + 12.0).rem_euclid(24.0) - 12.0) * 60.0)
305    }
306}
307
308/// A lunar month, by Julian day numbers.
309struct Month {
310    start: i64,
311    /// First day of the next month.
312    end: i64,
313    number: u8,
314    leap: bool,
315    year: i32,
316}
317
318/// The moment near `guess` when the angle `f` (degrees, growing at about `rate` degrees
319/// per day) equals `target`.
320fn crossing(
321    f: impl Fn(f64) -> Result<f64, EngineError>,
322    target: f64,
323    guess: f64,
324    rate: f64,
325) -> Result<f64, EngineError> {
326    let mut t0 = guess;
327    let mut d0 = wrap180(f(t0)? - target);
328    let mut t1 = t0 - d0 / rate;
329    for _ in 0..50 {
330        if (t1 - t0).abs() < EPSILON_DAYS {
331            break;
332        }
333        let d1 = wrap180(f(t1)? - target);
334        let slope = (d1 - d0) / (t1 - t0);
335        // The angle always grows near the crossing; fall back to the mean rate when the
336        // secant says otherwise (a wrap between the samples).
337        let slope = if slope.is_finite() && slope > rate / 4.0 {
338            slope
339        } else {
340            rate
341        };
342        (t0, d0) = (t1, d1);
343        t1 -= d1 / slope;
344    }
345    Ok(t1)
346}
347
348/// The Chinese calendar around `instant`, or `None` when the loaded kernels do not cover
349/// the year or so of ephemeris it needs (about 14 months before the moment and 2 after).
350pub fn chinese_calendar(
351    kernels: &KernelSet,
352    instant: UtcInstant,
353) -> Result<Option<ChineseCalendar>, EngineError> {
354    match calendar(kernels, instant) {
355        Err(EngineError::OutOfRange { .. }) => Ok(None),
356        other => other.map(Some),
357    }
358}
359
360fn calendar(kernels: &KernelSet, instant: UtcInstant) -> Result<ChineseCalendar, EngineError> {
361    let sky = Sky { kernels };
362    let jd = instant.julian_day();
363
364    let mut solar_terms = Vec::new();
365    let mut term = sky.sun_before(f64::from(LICHUN), jd)?;
366    let mut longitude = LICHUN;
367    loop {
368        let jie = longitude % 30 == 15;
369        solar_terms.push(SolarTerm {
370            name: TERM_NAMES[usize::from(longitude / 15)].to_string(),
371            longitude,
372            jie,
373            utc: instant_of(term).isoformat(),
374        });
375        if jie && term > jd {
376            break;
377        }
378        longitude = (longitude + 15) % 360;
379        term = sky.sun_at(f64::from(longitude), term + 15.2)?;
380    }
381
382    let day = china_day(jd);
383    let solstice = sky.sun_before(270.0, jd)?;
384    let (mut months, mut end, mut next_solstice) = sky.sui(solstice)?;
385    if day - DATE_MARGIN < months[0].start {
386        let (mut earlier, ..) = sky.sui(sky.sun_at(270.0, solstice - TROPICAL_YEAR)?)?;
387        earlier.append(&mut months);
388        months = earlier;
389    }
390    while day + DATE_MARGIN >= end {
391        let (mut later, later_end, later_solstice) = sky.sui(next_solstice)?;
392        months.append(&mut later);
393        (end, next_solstice) = (later_end, later_solstice);
394    }
395    let lunar_months = months
396        .into_iter()
397        .filter(|m| m.start <= day + DATE_MARGIN && m.end > day - DATE_MARGIN)
398        .map(|m| LunarMonth {
399            year: m.year,
400            month: m.number,
401            leap: m.leap,
402            start: date_of(m.start),
403            days: (m.end - m.start) as u8,
404        })
405        .collect();
406
407    Ok(ChineseCalendar {
408        equation_of_time: sky.equation_of_time(jd)?,
409        solar_terms,
410        lunar_months,
411    })
412}
413
414/// A lunar year of the calendar: its New Year and its months in order.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub(crate) struct LunarYear {
417    /// The Gregorian year its New Year falls in.
418    pub year: i32,
419    /// Julian day number of the New Year.
420    pub start: i64,
421    /// Lengths of its months in order (29 or 30), the leap month in its place.
422    pub days: Vec<u8>,
423    /// Number of the month the leap month repeats, 0 without one.
424    pub leap: u8,
425}
426
427/// The lunar years `first..=last`, from the ephemeris (for the embedded table and its test).
428#[cfg(test)]
429pub(crate) fn lunar_years(
430    kernels: &KernelSet,
431    first: i32,
432    last: i32,
433) -> Result<Vec<LunarYear>, EngineError> {
434    let sky = Sky { kernels };
435    let december = |year: i32| UtcInstant::from_civil(year, 12, 10, 0, 0, 0, 0).julian_day();
436    let mut solstice = sky.sun_at(270.0, december(first - 1))?;
437    let mut months = Vec::new();
438    loop {
439        let (mut sui, _, next) = sky.sui(solstice)?;
440        let done = sui.last().is_some_and(|m| m.year > last);
441        months.append(&mut sui);
442        if done {
443            break;
444        }
445        solstice = next;
446    }
447    Ok((first..=last)
448        .map(|year| {
449            let of_year: Vec<&Month> = months.iter().filter(|m| m.year == year).collect();
450            LunarYear {
451                year,
452                start: of_year[0].start,
453                days: of_year.iter().map(|m| (m.end - m.start) as u8).collect(),
454                leap: of_year.iter().find(|m| m.leap).map_or(0, |m| m.number),
455            }
456        })
457        .collect())
458}
459
460/// The moments (UT Julian dates) of the 12 jie of a Gregorian year, from 小寒 (285°) to
461/// 大雪 (255°), from the ephemeris (for the embedded table and its test).
462#[cfg(test)]
463pub(crate) fn jie_of_year(kernels: &KernelSet, year: i32) -> Result<[f64; 12], EngineError> {
464    let sky = Sky { kernels };
465    let january = UtcInstant::from_civil(year, 1, 6, 0, 0, 0, 0).julian_day();
466    let mut moments = [0.0; 12];
467    for (i, moment) in moments.iter_mut().enumerate() {
468        let longitude = f64::from((285 + 30 * i as u32) % 360);
469        *moment = sky.sun_at(longitude, january + 30.44 * i as f64)?;
470    }
471    Ok(moments)
472}