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