From da82250d96235e7c6f401336c96eaf64b9ac9001 Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Fri, 26 Sep 2025 22:34:39 -0700 Subject: [PATCH 1/2] Initial commit of FastPinqi --- components/calendar/tests/pinqi.rs | 293 +++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 components/calendar/tests/pinqi.rs diff --git a/components/calendar/tests/pinqi.rs b/components/calendar/tests/pinqi.rs new file mode 100644 index 00000000000..cb057613c30 --- /dev/null +++ b/components/calendar/tests/pinqi.rs @@ -0,0 +1,293 @@ +// This file is part of ICU4X. For terms of use, please see the file +// called LICENSE at the top level of the ICU4X source tree +// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). + +use icu_calendar::{ + cal::{ + chinese::{self}, + LunarChinese, + }, + types::{MonthCode, RataDie}, + Date, Ref, +}; + +#[derive(Debug, Copy, Clone)] +struct Duration { + days: u32, + milliseconds: u32, +} + +impl Duration { + fn to_i64(&self) -> i64 { + self.days as i64 * MILLISECONDS_IN_DAY as i64 + self.milliseconds as i64 + } +} + +/// The mean solar term length indexed according to the Gregorian solar cycle: +/// +/// `146097 / 400 / 12` +/// +/// 146097 days every 400 years, and 12 major solar terms per year. This resolves to an +/// exact number of seconds. +/// +/// This could alternatively be defined in terms of the mean tropical year. +const MEAN_SOLAR_TERM_LENGTH: Duration = Duration { + days: 30, + milliseconds: 37746000, +}; + +/// The mean length of a solstice year, which is 12 times MEAN_SOLAR_TERM_LENGTH. +const MEAN_SOLSTICE_YEAR_LENGTH: Duration = Duration { + days: 365, + milliseconds: 20952000, +}; + +/// The mean synodic length according to Wikipedia. +const MEAN_SYNODIC_MONTH_LENGTH: Duration = Duration { + days: 29, + milliseconds: 45842800, +}; + +/// Number of milliseconds in a day. +const MILLISECONDS_IN_DAY: i64 = 86400000; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct LocalMoment { + rata_die: RataDie, + local_milliseconds: u32, +} + +impl LocalMoment { + fn add_duration_times_n(&self, duration: Duration, n: i64) -> LocalMoment { + let temp = self.local_milliseconds as i64 + (duration.milliseconds as i64 * n); + let (extra_days, local_milliseconds) = ( + temp.div_euclid(MILLISECONDS_IN_DAY), + temp.rem_euclid(MILLISECONDS_IN_DAY), + ); + let rata_die = self.rata_die + extra_days + (duration.days as i64 * n) as i64; + let local_milliseconds = u32::try_from(local_milliseconds).unwrap(); + Self { + rata_die, + local_milliseconds, + } + } + + fn to_i64(&self) -> i64 { + (self.rata_die.to_i64_date() * MILLISECONDS_IN_DAY) + self.local_milliseconds as i64 + } +} + +#[test] +fn test_local_moment_add() { + let local_moment = LocalMoment { + rata_die: RataDie::new(1000), + local_milliseconds: 0, + }; + let duration = Duration { + days: 77, + milliseconds: 25000000, + }; + assert_eq!(local_moment.add_duration_times_n(duration, 0), local_moment); + assert_eq!( + local_moment.add_duration_times_n(duration, 1), + LocalMoment { + rata_die: RataDie::new(1077), + local_milliseconds: 25000000 + } + ); + assert_eq!( + local_moment.add_duration_times_n(duration, -1), + LocalMoment { + rata_die: RataDie::new(922), + local_milliseconds: 61400000 + } + ); + assert_eq!( + local_moment.add_duration_times_n(duration, -500), + LocalMoment { + rata_die: RataDie::new(-37645), + local_milliseconds: 28000000, + } + ); +} + +#[derive(Debug, Clone)] +struct FastPinqi { + pub winter_solstice: LocalMoment, + pub new_moon: LocalMoment, +} + +struct SolsticeYearInfo { + m11_rd: RataDie, + month_lengths: [bool; 13], + leap_month: Option, +} + +impl SolsticeYearInfo { + fn lunar_new_year_index(&self) -> usize { + match self.leap_month { + None => 2, // common year + Some(0) => unreachable!(), // the first month contains the winter solstice + Some(1) => 3, // M11L + Some(2) => 3, // M12L + Some(_) => 2, // all other leap months + } + } + + fn valid_month_lengths(&self) -> &[bool] { + match self.leap_month { + None => &self.month_lengths[0..12], + Some(_) => &self.month_lengths, + } + } + + fn lunar_new_year_rd(&self) -> RataDie { + let mut result = + self.m11_rd + 58 + self.month_lengths[0] as i64 + self.month_lengths[1] as i64; + if self.lunar_new_year_index() == 3 { + result += 29 + self.month_lengths[2] as i64; + } + result + } +} + +impl FastPinqi { + fn periodic_duration_on_or_before( + &self, + rata_die: RataDie, + base_moment: LocalMoment, + duration: Duration, + ) -> LocalMoment { + // For now, do math as i64 milliseconds, which covers 600 million years. + let upper_bound = LocalMoment { + rata_die: rata_die + 1, + local_milliseconds: 0, + } + .to_i64(); + let num_periods = (upper_bound - base_moment.to_i64() - 1).div_euclid(duration.to_i64()); + base_moment.add_duration_times_n(duration, num_periods) + } + + fn winter_solstice_on_or_before(&self, rata_die: RataDie) -> LocalMoment { + self.periodic_duration_on_or_before( + rata_die, + self.winter_solstice, + MEAN_SOLSTICE_YEAR_LENGTH, + ) + } + + fn new_moon_on_or_before(&self, rata_die: RataDie) -> LocalMoment { + self.periodic_duration_on_or_before(rata_die, self.new_moon, MEAN_SYNODIC_MONTH_LENGTH) + } + + fn solstice_year_for_iso_year(&self, related_iso: i32) -> SolsticeYearInfo { + let iso_new_year_rd = calendrical_calculations::iso::fixed_from_iso(related_iso, 1, 1); + let prev_winter_solstice = self.winter_solstice_on_or_before(iso_new_year_rd); + let next_winter_solstice = + prev_winter_solstice.add_duration_times_n(MEAN_SOLAR_TERM_LENGTH, 12); + let m11_moment = self.new_moon_on_or_before(prev_winter_solstice.rata_die); + let mut new_moon = m11_moment; + let mut major_solar_term = prev_winter_solstice; + let mut month_lengths: [bool; 13] = Default::default(); + let mut month_lengths_iter = month_lengths.iter_mut(); + let mut potential_leap_month = None; + loop { + let next_new_moon = new_moon.add_duration_times_n(MEAN_SYNODIC_MONTH_LENGTH, 1); + if next_new_moon.rata_die > next_winter_solstice.rata_die { + break; + } + if next_new_moon.rata_die - new_moon.rata_die == 30 { + *month_lengths_iter.next().unwrap() = true; + } else { + debug_assert_eq!(next_new_moon.rata_die - new_moon.rata_die, 29); + month_lengths_iter.next(); + } + dbg!(new_moon, major_solar_term); + if major_solar_term.rata_die >= next_new_moon.rata_die { + assert!(potential_leap_month.is_none()); + potential_leap_month = + Some(u8::try_from(12 - month_lengths_iter.as_slice().len()).unwrap()); + } else { + major_solar_term = major_solar_term.add_duration_times_n(MEAN_SOLAR_TERM_LENGTH, 1); + } + new_moon = next_new_moon; + } + match month_lengths_iter.into_slice().len() { + 1 => SolsticeYearInfo { + m11_rd: m11_moment.rata_die, + month_lengths, + leap_month: None, + }, + 0 => SolsticeYearInfo { + m11_rd: m11_moment.rata_die, + month_lengths, + leap_month: Some(potential_leap_month.unwrap()), + }, + _ => unreachable!(), + } + } +} + +impl chinese::Rules for FastPinqi { + fn year_data(&self, related_iso: i32) -> chinese::LunarChineseYearData { + let solstice_year_0 = self.solstice_year_for_iso_year(related_iso); + let solstice_year_1 = self.solstice_year_for_iso_year(related_iso + 1); + let start_day = solstice_year_0.lunar_new_year_rd(); + let ny0 = solstice_year_0.lunar_new_year_index(); + let ny1 = solstice_year_1.lunar_new_year_index(); + let month_lengths_0 = &solstice_year_0.valid_month_lengths()[ny0..]; + let month_lengths_1 = &solstice_year_1.month_lengths[..ny1]; + let num_months = month_lengths_0.len() + month_lengths_1.len(); + let leap_month = match num_months { + 12 => None, + 13 => { + match (solstice_year_0.leap_month, solstice_year_1.leap_month) { + (Some(x), None) if x >= 3 => { + // 3 => M01L, 4 => M02L, ... + Some(x - 2) + } + (None, Some(x)) if x <= 2 => { + // 1 => M11L, 2 => M12L + Some(x + 10) + } + _ => unreachable!(), + } + } + _ => unreachable!(), + }; + let mut month_lengths: [bool; 13] = Default::default(); + month_lengths[0..month_lengths_0.len()].copy_from_slice(month_lengths_0); + month_lengths[month_lengths_0.len()..num_months].copy_from_slice(month_lengths_1); + chinese::LunarChineseYearData::new(related_iso, start_day, month_lengths, leap_month) + } + + fn reference_year_from_month_day( + &self, + month_code: icu_calendar::types::MonthCode, + day: u8, + ) -> Result { + todo!() + } +} + +#[test] +fn test_fast_pinqi() { + // From navy.mil, minute resolution + let winter_solstice_2024 = LocalMoment { + rata_die: calendrical_calculations::iso::fixed_from_iso(2024, 12, 21), + local_milliseconds: 62400000, + }; + let new_moon_near_winter_solstice_2024 = LocalMoment { + rata_die: calendrical_calculations::iso::fixed_from_iso(2024, 12, 1), + local_milliseconds: 51660000, + }; + let rules = FastPinqi { + winter_solstice: winter_solstice_2024, + new_moon: new_moon_near_winter_solstice_2024, + }; + let cal = LunarChinese(rules); + let date = + Date::try_new_from_codes(None, 2025, MonthCode::new_normal(1).unwrap(), 1, Ref(&cal)) + .unwrap(); + assert_eq!(date.to_iso(), Date::try_new_iso(2025, 1, 29).unwrap()); +} From f8432c11c4f1bd7809b2b0b04c25535ac7a3e0ae Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Fri, 26 Sep 2025 23:05:37 -0700 Subject: [PATCH 2/2] Pingqi with a G, and add more comments --- .../calendar/tests/{pinqi.rs => pingqi.rs} | 90 +++++++++++++------ 1 file changed, 65 insertions(+), 25 deletions(-) rename components/calendar/tests/{pinqi.rs => pingqi.rs} (73%) diff --git a/components/calendar/tests/pinqi.rs b/components/calendar/tests/pingqi.rs similarity index 73% rename from components/calendar/tests/pinqi.rs rename to components/calendar/tests/pingqi.rs index cb057613c30..273dc01ffef 100644 --- a/components/calendar/tests/pinqi.rs +++ b/components/calendar/tests/pingqi.rs @@ -51,6 +51,7 @@ const MEAN_SYNODIC_MONTH_LENGTH: Duration = Duration { /// Number of milliseconds in a day. const MILLISECONDS_IN_DAY: i64 = 86400000; +/// A RataDie with a number of milliseconds within that day, in local standard time. #[derive(Debug, Copy, Clone, PartialEq, Eq)] struct LocalMoment { rata_die: RataDie, @@ -58,6 +59,7 @@ struct LocalMoment { } impl LocalMoment { + /// Adds a specific [`Duration`] to this moment `n` times. fn add_duration_times_n(&self, duration: Duration, n: i64) -> LocalMoment { let temp = self.local_milliseconds as i64 + (duration.milliseconds as i64 * n); let (extra_days, local_milliseconds) = ( @@ -72,6 +74,7 @@ impl LocalMoment { } } + /// Converts this moment to an i64 local timestamp in milliseconds (with Rata Die epoch) fn to_i64(&self) -> i64 { (self.rata_die.to_i64_date() * MILLISECONDS_IN_DAY) + self.local_milliseconds as i64 } @@ -111,19 +114,32 @@ fn test_local_moment_add() { ); } +/// A fast approximation for the Chinese calendar, inspired by the _píngqì_ (平氣) rule +/// used in the Ming dynasty. +/// +/// Stays anchored in the Gregorian calendar, even as the Gregorian calendar drifts +/// from the seasons in the distant future and distant past. #[derive(Debug, Clone)] -struct FastPinqi { +struct FastPingqi { pub winter_solstice: LocalMoment, pub new_moon: LocalMoment, } +/// A "solstice year", also known as a _suì_ (歲). +/// +/// This is the 12 or 13 month year between two adjacent winter solstices, for internal +/// calculations only. The first month is M11. struct SolsticeYearInfo { + /// The first day of M11. m11_rd: RataDie, + /// The lengths of the months, starting with M11 (true = 30 days). month_lengths: [bool; 13], + /// The 0-based index of the leap month, if any. leap_month: Option, } impl SolsticeYearInfo { + /// Returns the index of the lunar new year (M01) within month_lengths. fn lunar_new_year_index(&self) -> usize { match self.leap_month { None => 2, // common year @@ -134,6 +150,7 @@ impl SolsticeYearInfo { } } + /// Returns the month_lengths in this solstice year, either 12 or 13. fn valid_month_lengths(&self) -> &[bool] { match self.leap_month { None => &self.month_lengths[0..12], @@ -141,6 +158,7 @@ impl SolsticeYearInfo { } } + /// Returns the [`RataDie`] of the linar new year (first day of M01). fn lunar_new_year_rd(&self) -> RataDie { let mut result = self.m11_rd + 58 + self.month_lengths[0] as i64 + self.month_lengths[1] as i64; @@ -151,35 +169,47 @@ impl SolsticeYearInfo { } } -impl FastPinqi { - fn periodic_duration_on_or_before( - &self, - rata_die: RataDie, - base_moment: LocalMoment, - duration: Duration, - ) -> LocalMoment { - // For now, do math as i64 milliseconds, which covers 600 million years. - let upper_bound = LocalMoment { - rata_die: rata_die + 1, - local_milliseconds: 0, - } - .to_i64(); - let num_periods = (upper_bound - base_moment.to_i64() - 1).div_euclid(duration.to_i64()); - base_moment.add_duration_times_n(duration, num_periods) +/// Calculates the last moment that occurs on or before the given rata die that is an +/// exact multiple of the given duration relative to the base moment. +/// +/// For example, given these inputs: +/// +/// - Rata Die: 900 +/// - Base Moment: 1000.0 (RD 1000, 0 milliseconds) +/// - Duration: 30.3 (30 days, 25920 milliseconds) +/// +/// The result is the moment 878.8 (RD 878, 69120 milliseconds), which is 4 periods before +/// the base moment, and the last period before RD 900. +/// +/// This is the heart of FastPingqi. +fn periodic_duration_on_or_before( + rata_die: RataDie, + base_moment: LocalMoment, + duration: Duration, +) -> LocalMoment { + // For now, do math as i64 milliseconds, which covers 600 million years. + let upper_bound = LocalMoment { + rata_die: rata_die + 1, + local_milliseconds: 0, } + .to_i64(); + let num_periods = (upper_bound - base_moment.to_i64() - 1).div_euclid(duration.to_i64()); + base_moment.add_duration_times_n(duration, num_periods) +} +impl FastPingqi { + /// Calculates the moment of the nearest winter solstice on or before the given rata die fn winter_solstice_on_or_before(&self, rata_die: RataDie) -> LocalMoment { - self.periodic_duration_on_or_before( - rata_die, - self.winter_solstice, - MEAN_SOLSTICE_YEAR_LENGTH, - ) + periodic_duration_on_or_before(rata_die, self.winter_solstice, MEAN_SOLSTICE_YEAR_LENGTH) } + /// Calculates the moment of the nearest new moon on or before the given rata die fn new_moon_on_or_before(&self, rata_die: RataDie) -> LocalMoment { - self.periodic_duration_on_or_before(rata_die, self.new_moon, MEAN_SYNODIC_MONTH_LENGTH) + periodic_duration_on_or_before(rata_die, self.new_moon, MEAN_SYNODIC_MONTH_LENGTH) } + /// Calculates the [`SolsticeYear`] containing the specified ISO new year, i.e., + /// the one starting in November or December of the previous ISO year. fn solstice_year_for_iso_year(&self, related_iso: i32) -> SolsticeYearInfo { let iso_new_year_rd = calendrical_calculations::iso::fixed_from_iso(related_iso, 1, 1); let prev_winter_solstice = self.winter_solstice_on_or_before(iso_new_year_rd); @@ -191,6 +221,7 @@ impl FastPinqi { let mut month_lengths: [bool; 13] = Default::default(); let mut month_lengths_iter = month_lengths.iter_mut(); let mut potential_leap_month = None; + // Loop over the months to calculate month lengths and identify a leap month: loop { let next_new_moon = new_moon.add_duration_times_n(MEAN_SYNODIC_MONTH_LENGTH, 1); if next_new_moon.rata_die > next_winter_solstice.rata_die { @@ -228,11 +259,18 @@ impl FastPinqi { } } -impl chinese::Rules for FastPinqi { +impl chinese::Rules for FastPingqi { fn year_data(&self, related_iso: i32) -> chinese::LunarChineseYearData { + // Calculate the two solstice years that occur during the lunar year. let solstice_year_0 = self.solstice_year_for_iso_year(related_iso); let solstice_year_1 = self.solstice_year_for_iso_year(related_iso + 1); + // The lunar new year occurs in the first solstice year. let start_day = solstice_year_0.lunar_new_year_rd(); + // We need to pull 10-11 month lengths from the first solstice year + // and 2-3 month lengths from the second solstice year. + // + // First, we need to figure out if there is a leap month and which solstice year + // it comes from. let ny0 = solstice_year_0.lunar_new_year_index(); let ny1 = solstice_year_1.lunar_new_year_index(); let month_lengths_0 = &solstice_year_0.valid_month_lengths()[ny0..]; @@ -255,9 +293,11 @@ impl chinese::Rules for FastPinqi { } _ => unreachable!(), }; + // Now copy over the month lengths. let mut month_lengths: [bool; 13] = Default::default(); month_lengths[0..month_lengths_0.len()].copy_from_slice(month_lengths_0); month_lengths[month_lengths_0.len()..num_months].copy_from_slice(month_lengths_1); + // All done. chinese::LunarChineseYearData::new(related_iso, start_day, month_lengths, leap_month) } @@ -271,7 +311,7 @@ impl chinese::Rules for FastPinqi { } #[test] -fn test_fast_pinqi() { +fn test_fast_pingqi() { // From navy.mil, minute resolution let winter_solstice_2024 = LocalMoment { rata_die: calendrical_calculations::iso::fixed_from_iso(2024, 12, 21), @@ -281,7 +321,7 @@ fn test_fast_pinqi() { rata_die: calendrical_calculations::iso::fixed_from_iso(2024, 12, 1), local_milliseconds: 51660000, }; - let rules = FastPinqi { + let rules = FastPingqi { winter_solstice: winter_solstice_2024, new_moon: new_moon_near_winter_solstice_2024, };