lunar-lite 1.3.0

A small Rust library for Chinese lunisolar date conversion.
Documentation
//! Lunar months located from astronomical new moons (朔) and winter solstice.

use crate::astronomical::{lunar_year, solar_term};
use crate::julian_day::{J2000, to_solar_date};
use crate::{LunarError, SolarDate};

use super::kernel::AstronomicalKernel;

const SYNODIC_MONTH_DAYS: f64 = 29.5306;

#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct LunarMonth {
    year: i32,
    month: u8,
    is_leap: bool,
}

impl LunarMonth {
    pub(crate) fn from_ym(year: i32, month_with_leap: i8) -> Result<Self, LunarError> {
        lunar_year::validate_lunar_year(year)?;

        if month_with_leap == 0 || !(-12..=12).contains(&month_with_leap) {
            return Err(invalid_lunar_month(
                year,
                month_with_leap.unsigned_abs(),
                month_with_leap < 0,
            ));
        }

        let month = month_with_leap.unsigned_abs();
        let is_leap = month_with_leap < 0;
        if is_leap && lunar_year::leap_month(year)? != Some(month) {
            return Err(invalid_lunar_month(year, month, true));
        }

        Ok(Self {
            year,
            month,
            is_leap,
        })
    }

    pub(crate) fn next(self, n: i32) -> Result<Self, LunarError> {
        if n == 0 {
            return Ok(self);
        }

        let mut index = self.index_in_year()? as i32 + 1 + n;
        let mut year = self.year;
        let mut month_count = lunar_year_month_count(year)?;
        let forward = n > 0;
        let add = if forward { 1 } else { -1 };

        while if forward {
            index > month_count
        } else {
            index <= 0
        } {
            if forward {
                index -= month_count;
            }
            year += add;
            lunar_year::validate_lunar_year(year)?;
            month_count = lunar_year_month_count(year)?;
            if !forward {
                index += month_count;
            }
        }

        let mut is_leap = false;
        let leap_month = lunar_year::leap_month(year)?.unwrap_or(0);
        if leap_month > 0 {
            if index == leap_month as i32 + 1 {
                is_leap = true;
            }
            if index > leap_month as i32 {
                index -= 1;
            }
        }

        Self::from_ym(year, if is_leap { -(index as i8) } else { index as i8 })
    }

    pub(crate) fn year(self) -> i32 {
        self.year
    }

    pub(crate) fn month(self) -> u8 {
        self.month
    }

    #[cfg(test)]
    fn month_with_leap(self) -> i8 {
        if self.is_leap {
            -(self.month as i8)
        } else {
            self.month as i8
        }
    }

    pub(crate) fn is_leap(self) -> bool {
        self.is_leap
    }

    pub(crate) fn index_in_year(self) -> Result<u8, LunarError> {
        let mut index = self.month - 1;
        if self.is_leap {
            index += 1;
        } else if let Some(leap_month) = lunar_year::leap_month(self.year)? {
            if self.month > leap_month {
                index += 1;
            }
        }
        Ok(index)
    }

    pub(crate) fn first_julian_day(self) -> Result<f64, LunarError> {
        Ok(J2000 + AstronomicalKernel::new_moon_day_offset(self.new_moon_offset()?))
    }

    pub(crate) fn first_solar_date(self) -> Result<SolarDate, LunarError> {
        Ok(to_solar_date(self.first_julian_day()?))
    }

    pub(crate) fn day_count(self) -> Result<u8, LunarError> {
        let new_moon = self.new_moon_offset()?;
        Ok(
            (AstronomicalKernel::new_moon_day_offset(new_moon + SYNODIC_MONTH_DAYS)
                - AstronomicalKernel::new_moon_day_offset(new_moon)) as u8,
        )
    }

    fn new_moon_offset(self) -> Result<f64, LunarError> {
        let winter_solstice_offset = solar_term::winter_solstice_cursory_offset(self.year);
        let mut winter_solstice_new_moon_offset =
            AstronomicalKernel::new_moon_day_offset(winter_solstice_offset);
        if winter_solstice_new_moon_offset > winter_solstice_offset {
            winter_solstice_new_moon_offset -= 29.53;
        }

        // The previous year's leap month feeds a month-offset correction. At the
        // lower boundary (year == MIN_LUNAR_YEAR) the previous year is out of
        // range; treat it as having no leap month so month-length lookups for
        // the earliest supported lunar year still resolve.
        let previous_leap_month = if self.year > lunar_year::MIN_LUNAR_YEAR {
            lunar_year::leap_month(self.year - 1)?.unwrap_or(0)
        } else {
            0
        };
        // The `year > 8 && year < 24` and `year != 239 && year != 240` branches
        // are inherited from tyme4rs 1.5.0 compatibility behaviour; do not
        // adjust without rerunning the oracle tests.
        let mut offset = 2.0;
        if self.year > 8 && self.year < 24 {
            offset = 1.0;
        } else if previous_leap_month > 10 && self.year != 239 && self.year != 240 {
            offset = 3.0;
        }

        Ok(winter_solstice_new_moon_offset
            + SYNODIC_MONTH_DAYS * (offset + self.index_in_year()? as f64))
    }
}

fn lunar_year_month_count(year: i32) -> Result<i32, LunarError> {
    Ok(if lunar_year::leap_month(year)?.is_some() {
        13
    } else {
        12
    })
}

fn invalid_lunar_month(year: i32, month: u8, is_leap_month: bool) -> LunarError {
    LunarError::InvalidLunarMonth {
        year,
        month,
        is_leap_month,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn month_lengths_and_first_days_match_tyme() {
        let cases = [
            (
                2020,
                4,
                30,
                SolarDate {
                    year: 2020,
                    month: 4,
                    day: 23,
                },
            ),
            (
                2020,
                -4,
                29,
                SolarDate {
                    year: 2020,
                    month: 5,
                    day: 23,
                },
            ),
            (
                2023,
                6,
                29,
                SolarDate {
                    year: 2023,
                    month: 7,
                    day: 18,
                },
            ),
            (
                2023,
                7,
                30,
                SolarDate {
                    year: 2023,
                    month: 8,
                    day: 16,
                },
            ),
            (
                2023,
                8,
                30,
                SolarDate {
                    year: 2023,
                    month: 9,
                    day: 15,
                },
            ),
            (
                2023,
                9,
                29,
                SolarDate {
                    year: 2023,
                    month: 10,
                    day: 15,
                },
            ),
            (
                2033,
                -11,
                29,
                SolarDate {
                    year: 2033,
                    month: 12,
                    day: 22,
                },
            ),
            (
                2033,
                12,
                30,
                SolarDate {
                    year: 2034,
                    month: 1,
                    day: 20,
                },
            ),
            (
                9_999,
                12,
                29,
                SolarDate {
                    year: 9_999,
                    month: 12,
                    day: 30,
                },
            ),
        ];

        for (year, month, days, first_day) in cases {
            let lunar_month = LunarMonth::from_ym(year, month).unwrap();
            assert_eq!(lunar_month.day_count().unwrap(), days, "{year}-{month}");
            assert_eq!(
                lunar_month.first_solar_date().unwrap(),
                first_day,
                "{year}-{month}"
            );
        }
    }

    #[test]
    fn next_month_matches_tyme_leap_navigation() {
        assert_eq!(
            LunarMonth::from_ym(2008, 11)
                .unwrap()
                .next(1)
                .unwrap()
                .month_with_leap(),
            12
        );
        assert_eq!(
            LunarMonth::from_ym(2008, 11)
                .unwrap()
                .next(2)
                .unwrap()
                .month_with_leap(),
            1
        );
        assert_eq!(
            LunarMonth::from_ym(2020, 4)
                .unwrap()
                .next(1)
                .unwrap()
                .month_with_leap(),
            -4
        );
        assert_eq!(
            LunarMonth::from_ym(2020, -4)
                .unwrap()
                .next(1)
                .unwrap()
                .month_with_leap(),
            5
        );
    }
}