-
Notifications
You must be signed in to change notification settings - Fork 225
Add a FastPingqi approximation for LunarChinese calendar #6995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
sffc
wants to merge
2
commits into
unicode-org:main
Choose a base branch
from
sffc:pinqi
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,333 @@ | ||
// 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; | ||
|
||
/// 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, | ||
local_milliseconds: u32, | ||
} | ||
|
||
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) = ( | ||
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, | ||
} | ||
} | ||
|
||
/// 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 | ||
} | ||
} | ||
|
||
#[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, | ||
} | ||
); | ||
} | ||
|
||
/// 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 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<u8>, | ||
} | ||
|
||
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 | ||
Some(0) => unreachable!(), // the first month contains the winter solstice | ||
Some(1) => 3, // M11L | ||
Some(2) => 3, // M12L | ||
Some(_) => 2, // all other leap months | ||
} | ||
} | ||
|
||
/// 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], | ||
Some(_) => &self.month_lengths, | ||
} | ||
} | ||
|
||
/// 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; | ||
if self.lunar_new_year_index() == 3 { | ||
result += 29 + self.month_lengths[2] as i64; | ||
} | ||
result | ||
} | ||
} | ||
|
||
/// 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 { | ||
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 { | ||
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); | ||
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 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 { | ||
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 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..]; | ||
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!(), | ||
}; | ||
// 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) | ||
} | ||
|
||
fn reference_year_from_month_day( | ||
&self, | ||
month_code: icu_calendar::types::MonthCode, | ||
day: u8, | ||
) -> Result<chinese::LunarChineseYearData, icu_calendar::DateError> { | ||
todo!() | ||
} | ||
} | ||
|
||
#[test] | ||
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), | ||
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 = FastPingqi { | ||
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()); | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code has a lot of panics that will need to be cleaned up before we would graduate this.