Skip to content

Commit

Permalink
Fix Chinese by following the winter solstice drift
Browse files Browse the repository at this point in the history
  • Loading branch information
sffc committed May 22, 2024
1 parent d8f435c commit a64a154
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 50 deletions.
6 changes: 4 additions & 2 deletions components/calendar/src/chinese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ mod test {

use super::*;
use crate::types::MonthCode;
use calendrical_calculations::rata_die::RataDie;
use calendrical_calculations::{chinese_based::DriftDelta, rata_die::RataDie};
/// Run a test twice, with two calendars
fn do_twice(
chinese_calculating: &Chinese,
Expand Down Expand Up @@ -618,6 +618,8 @@ mod test {

#[test]
fn test_fixed_chinese_roundtrip() {
#[cfg(feature = "logging")]
let _ = simple_logger::SimpleLogger::new().env().init();
let mut fixed = -1963020;
let max_fixed = 1963020;
let mut iters = 0;
Expand Down Expand Up @@ -756,7 +758,7 @@ mod test {
expected_month,
calendrical_calculations::chinese_based::get_leap_month_from_new_year::<
calendrical_calculations::chinese_based::Chinese,
>(new_year),
>(new_year, DriftDelta(0)),
"[{calendar_type}] {year} have leap month {expected_month}"
);
},
Expand Down
13 changes: 11 additions & 2 deletions components/calendar/src/chinese_based.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,27 @@ fn compute_packed_with_yb<CB: ChineseBased>(
let YearBounds {
new_year,
next_new_year,
drift_delta,
..
} = year_bounds;
let (month_lengths, leap_month) =
chinese_based::month_structure_for_year::<CB>(new_year, next_new_year);
chinese_based::month_structure_for_year::<CB>(new_year, next_new_year, drift_delta);

let related_iso = CB::iso_from_extended(extended_year);
let iso_ny = calendrical_calculations::iso::fixed_from_iso(related_iso, 1, 1);

// +1 because `new_year - iso_ny` is zero-indexed, but `FIRST_NY` is 1-indexed
let ny_offset = new_year - iso_ny - i64::from(PackedChineseBasedYearInfo::FIRST_NY) + 1;
let ny_offset = if let Ok(ny_offset) = u8::try_from(ny_offset) {
ny_offset
if ny_offset >= 33 {
debug_assert!(
false,
"Expected small new years offset, got {ny_offset} in ISO year {related_iso}"
);
33
} else {
ny_offset
}
} else {
debug_assert!(
false,
Expand Down
4 changes: 2 additions & 2 deletions components/calendar/src/provider/chinese_based.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ impl<'data> ChineseBasedCacheV1<'data> {
/// Bit: 0 1 2 3 4 5 6 7
/// Byte 0: [ month lengths .............
/// Byte 1: .. month lengths ] | [ leap month index ..
/// Byte 2: ] | [ NY offset ] | unused
/// Byte 2: ] | [ NY offset ] | unused
/// ```
///
/// Where the New Year Offset is the offset from ISO Jan 21 of that year for Chinese New Year,
Expand Down Expand Up @@ -162,7 +162,7 @@ impl PackedChineseBasedYearInfo {
!month_lengths[12] || leap_month_idx.is_some(),
"Last month length should not be set for non-leap years"
);
debug_assert!(ny_offset < 32, "Year offset too big to store");
debug_assert!(ny_offset < 33, "Year offset too big to store");
debug_assert!(
leap_month_idx.map(|l| l.get() <= 13).unwrap_or(true),
"Leap month indices must be 1 <= i <= 13"
Expand Down
4 changes: 4 additions & 0 deletions components/calendar/src/tests/continuity_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ fn test_buddhist_continuity() {

#[test]
fn test_chinese_continuity() {
#[cfg(feature = "logging")]
let _ = simple_logger::SimpleLogger::new().env().init();
let cal = crate::chinese::Chinese::new();
let cal = Ref(&cal);
let date = Date::try_new_chinese_date_with_calendar(-10, 1, 1, cal);
Expand All @@ -95,6 +97,8 @@ fn test_coptic_continuity() {

#[test]
fn test_dangi_continuity() {
#[cfg(feature = "logging")]
let _ = simple_logger::SimpleLogger::new().env().init();
let cal = crate::dangi::Dangi::new();
let cal = Ref(&cal);
let date = Date::try_new_dangi_date_with_calendar(-10, 1, 1, cal);
Expand Down
151 changes: 107 additions & 44 deletions utils/calendrical_calculations/src/chinese_based.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::astronomy::{self, Astronomical, Location, MEAN_SYNODIC_MONTH, MEAN_TROPICAL_YEAR};
use crate::helpers::i64_to_i32;
use crate::iso::{fixed_from_iso, iso_from_fixed};
use crate::rata_die::{Moment, RataDie};
use core::num::NonZeroU8;
#[allow(unused_imports)]
Expand Down Expand Up @@ -28,8 +29,11 @@ pub trait ChineseBased {

/// The ISO year that corresponds to year 1
const EPOCH_ISO: i32;
/// Given an ISO year, return the extended year

/// The name of the calendar for debugging.
const DEBUG_NAME: &'static str;

/// Given an ISO year, return the extended year
fn extended_from_iso(iso_year: i32) -> i32 {
iso_year - Self::EPOCH_ISO + 1
}
Expand Down Expand Up @@ -137,6 +141,7 @@ impl ChineseBased for Chinese {

const EPOCH: RataDie = CHINESE_EPOCH;
const EPOCH_ISO: i32 = CHINESE_EPOCH_ISO;
const DEBUG_NAME: &'static str = "chinese";
}

impl ChineseBased for Dangi {
Expand All @@ -156,8 +161,13 @@ impl ChineseBased for Dangi {

const EPOCH: RataDie = KOREAN_EPOCH;
const EPOCH_ISO: i32 = KOREAN_EPOCH_ISO;
const DEBUG_NAME: &'static str = "dangi";
}

/// The number of days that were added to the Winter solstice to fix calendar drift
#[derive(Debug, Copy, Clone)]
pub struct DriftDelta(pub i64);

/// Marks the bounds of a lunar year
#[derive(Debug, Copy, Clone)]
#[allow(clippy::exhaustive_structs)] // we're comfortable making frequent breaking changes to this crate
Expand All @@ -166,6 +176,8 @@ pub struct YearBounds {
pub new_year: RataDie,
/// The date marking the start of the next lunar year
pub next_new_year: RataDie,
/// The number of days that were added to the Winter solstice to fix calendar drift
pub drift_delta: DriftDelta,
}

impl YearBounds {
Expand All @@ -176,13 +188,15 @@ impl YearBounds {
#[inline]
pub fn compute<C: ChineseBased>(date: RataDie) -> Self {
let prev_solstice = winter_solstice_on_or_before::<C>(date);
let (new_year, next_solstice) = new_year_on_or_before_fixed_date::<C>(date, prev_solstice);
let (new_year, next_solstice, drift_delta) =
new_year_on_or_before_fixed_date::<C>(date, prev_solstice);
// Using 400 here since new years can be up to 390 days apart, and we add some padding
let next_new_year = new_year_on_or_before_fixed_date::<C>(new_year + 400, next_solstice).0;

Self {
new_year,
next_new_year,
drift_delta,
}
}

Expand All @@ -207,7 +221,11 @@ impl YearBounds {
///
/// Based on functions from _Calendrical Calculations_ by Reingold & Dershowitz.
/// Lisp reference code: https:/EdReingold/calendar-code2/blob/main/calendar.l#L5273-L5281
pub(crate) fn major_solar_term_from_fixed<C: ChineseBased>(date: RataDie) -> u32 {
pub(crate) fn major_solar_term_from_fixed<C: ChineseBased>(
date: RataDie,
drift_delta: DriftDelta,
) -> u32 {
let date = date - drift_delta.0;
let moment: Moment = date.as_moment();
let location = C::location(date);
let universal: Moment = Location::universal_from_standard(moment, location);
Expand All @@ -223,16 +241,6 @@ pub(crate) fn major_solar_term_from_fixed<C: ChineseBased>(date: RataDie) -> u32
result_signed as u32
}

/// Returns true if the month of a given fixed date does not have a major solar term,
/// false otherwise.
///
/// Based on functions from _Calendrical Calculations_ by Reingold & Dershowitz.
/// Lisp reference code: https:/EdReingold/calendar-code2/blob/main/calendar.l#L5345-L5351
pub(crate) fn no_major_solar_term<C: ChineseBased>(date: RataDie) -> bool {
major_solar_term_from_fixed::<C>(date)
== major_solar_term_from_fixed::<C>(new_moon_on_or_after::<C>((date + 1).as_moment()))
}

/// The fixed date in standard time at the observation location of the next new moon on or after a given Moment.
///
/// Based on functions from _Calendrical Calculations_ by Reingold & Dershowitz.
Expand Down Expand Up @@ -264,42 +272,78 @@ pub(crate) fn midnight<C: ChineseBased>(moment: Moment) -> Moment {
/// Determines the fixed date of the lunar new year given the start of its corresponding solar year (歲), which is
/// also the winter solstice
///
/// The return values are:
///
/// - The calculated lunar new year
/// - The date of the following solstice
/// - The number of days between `prior_solstice` and December 20 if `prior_solstice` is too early
///
/// The third argument, called `drift_delta`, must be subtracted from dates when checking their
/// major solar term.
///
/// Based on functions from _Calendrical Calculations_ by Reingold & Dershowitz.
/// Lisp reference code: https:/EdReingold/calendar-code2/blob/main/calendar.l#L5370-L5394
pub(crate) fn new_year_in_sui<C: ChineseBased>(prior_solstice: RataDie) -> (RataDie, RataDie) {
pub(crate) fn new_year_in_sui<C: ChineseBased>(
prior_solstice: RataDie,
) -> (RataDie, RataDie, DriftDelta) {
// s1 is prior_solstice
// Using 370 here since solstices are ~365 days apart
// Both solstices should fall in December
// Both solstices should fall in December, and the first solstice MUST fall on
// December 20, 21, or 22. It doesn't seem to change the outcome if the following
// solstice is out of range.
let (prior_solstice, drift_delta) = {
let adjusted = bind_winter_solstice::<C>(prior_solstice);
(adjusted, DriftDelta(adjusted - prior_solstice))
};
let following_solstice = winter_solstice_on_or_before::<C>(prior_solstice + 370); // s2
debug_assert_eq!(
crate::iso::iso_from_fixed(prior_solstice).unwrap().1,
12,
"Winter solstice not in December! {prior_solstice:?}"
);
debug_assert_eq!(
crate::iso::iso_from_fixed(following_solstice).unwrap().1,
12,
"Winter solstice not in December! {following_solstice:?}"
);
let month_after_eleventh = new_moon_on_or_after::<C>((prior_solstice + 1).as_moment()); // m12
debug_assert!(month_after_eleventh - prior_solstice >= 0);
let month_after_twelfth = new_moon_on_or_after::<C>((month_after_eleventh + 1).as_moment()); // m13
let month_after_thirteenth = new_moon_on_or_after::<C>((month_after_twelfth + 1).as_moment());
debug_assert!(month_after_twelfth - month_after_eleventh >= 29);
let next_eleventh_month = new_moon_before::<C>((following_solstice + 1).as_moment()); // next-m11
let lhs_argument =
((next_eleventh_month - month_after_eleventh) as f64 / MEAN_SYNODIC_MONTH).round() as i64;
if lhs_argument == 12
&& (no_major_solar_term::<C>(month_after_eleventh)
|| no_major_solar_term::<C>(month_after_twelfth))
{
(
new_moon_on_or_after::<C>((month_after_twelfth + 1).as_moment()),
following_solstice,
)
let st1 = major_solar_term_from_fixed::<C>(month_after_eleventh, drift_delta);
let st2 = major_solar_term_from_fixed::<C>(month_after_twelfth, drift_delta);
let st3 = major_solar_term_from_fixed::<C>(month_after_thirteenth, drift_delta);
if lhs_argument == 12 && (st1 == st2 || st2 == st3) {
(month_after_thirteenth, following_solstice, drift_delta)
} else {
(month_after_twelfth, following_solstice, drift_delta)
}
}

/// This function forces the RataDie to be on December 20, 21, or 22. It was
/// created for practical considerations and is not in the text.
///
/// See: <https:/unicode-org/icu4x/pull/4904>
fn bind_winter_solstice<C: ChineseBased>(solstice: RataDie) -> RataDie {
let (iso_year, iso_month, iso_day) = match iso_from_fixed(solstice) {
Ok(ymd) => ymd,
Err(_) => {
debug_assert!(false, "Solstice REALLY out of bounds: {solstice:?}");
return solstice;
}
};
if iso_month < 12 || iso_day < 20 {
#[cfg(feature = "logging")]
log::trace!("({}) Solstice out of bounds: {solstice:?}", C::DEBUG_NAME);
fixed_from_iso(iso_year, 12, 20)
} else if iso_day > 22 {
#[cfg(feature = "logging")]
log::trace!("({}) Solstice out of bounds: {solstice:?}", C::DEBUG_NAME);
fixed_from_iso(iso_year, 12, 22)
} else {
(month_after_twelfth, following_solstice)
solstice
}
}

/// Get the moment of the nearest winter solstice on or before a given fixed date
/// Get the fixed date of the nearest winter solstice, in the Chinese time zone,
/// on or before a given fixed date.
///
/// This is valid for several thousand years, but it drifts for large positive
/// and negative years. See [`bind_winter_solstice`].
///
/// Based on functions from _Calendrical Calculations_ by Reingold & Dershowitz.
/// Lisp reference code: https:/EdReingold/calendar-code2/blob/main/calendar.l#L5359-L5368
Expand Down Expand Up @@ -328,6 +372,7 @@ pub(crate) fn winter_solstice_on_or_before<C: ChineseBased>(date: RataDie) -> Ra

/// Get the fixed date of the nearest Lunar New Year on or before a given fixed date.
/// This function also returns the solstice following a given date for optimization (see #3743).
/// The third return value is the drift delta; see [`new_year_in_sui`].
///
/// To call this function you must precompute the value of the prior solstice, which
/// is the result of winter_solstice_on_or_before
Expand All @@ -337,7 +382,7 @@ pub(crate) fn winter_solstice_on_or_before<C: ChineseBased>(date: RataDie) -> Ra
pub(crate) fn new_year_on_or_before_fixed_date<C: ChineseBased>(
date: RataDie,
prior_solstice: RataDie,
) -> (RataDie, RataDie) {
) -> (RataDie, RataDie, DriftDelta) {
let new_year = new_year_in_sui::<C>(prior_solstice);
if date >= new_year.0 {
new_year
Expand Down Expand Up @@ -438,7 +483,10 @@ pub fn chinese_based_date_from_fixed<C: ChineseBased>(date: RataDie) -> ChineseF
let leap_month = if year_bounds.is_leap() {
// This doesn't need to be checked for `None`, since `get_leap_month_from_new_year`
// will always return a number greater than or equal to 1, and less than 14.
NonZeroU8::new(get_leap_month_from_new_year::<C>(first_day_of_year))
NonZeroU8::new(get_leap_month_from_new_year::<C>(
first_day_of_year,
year_bounds.drift_delta,
))
} else {
None
};
Expand All @@ -460,11 +508,21 @@ pub fn chinese_based_date_from_fixed<C: ChineseBased>(date: RataDie) -> ChineseF
///
/// Conceptually similar to code from _Calendrical Calculations_ by Reingold & Dershowitz
/// Lisp reference code: <https:/EdReingold/calendar-code2/blob/main/calendar.l#L5443-L5450>
pub fn get_leap_month_from_new_year<C: ChineseBased>(new_year: RataDie) -> u8 {
pub fn get_leap_month_from_new_year<C: ChineseBased>(
new_year: RataDie,
drift_delta: DriftDelta,
) -> u8 {
let mut cur = new_year;
let mut result = 1;
while result < MAX_ITERS_FOR_MONTHS_OF_YEAR && !no_major_solar_term::<C>(cur) {
cur = new_moon_on_or_after::<C>((cur + 1).as_moment());
let mut solar_term = major_solar_term_from_fixed::<C>(cur, drift_delta);
loop {
let next = new_moon_on_or_after::<C>((cur + 1).as_moment());
let next_solar_term = major_solar_term_from_fixed::<C>(next, drift_delta);
if result >= MAX_ITERS_FOR_MONTHS_OF_YEAR || solar_term == next_solar_term {
break;
}
cur = next;
solar_term = next_solar_term;
result += 1;
}
debug_assert!(result < MAX_ITERS_FOR_MONTHS_OF_YEAR, "The given year was not a leap year and an unexpected number of iterations occurred searching for a leap month.");
Expand Down Expand Up @@ -504,7 +562,7 @@ pub fn days_in_month<C: ChineseBased>(
pub fn days_in_prev_year<C: ChineseBased>(new_year: RataDie) -> u16 {
let date = new_year - 300;
let prev_solstice = winter_solstice_on_or_before::<C>(date);
let (prev_new_year, _) = new_year_on_or_before_fixed_date::<C>(date, prev_solstice);
let (prev_new_year, _, _) = new_year_on_or_before_fixed_date::<C>(date, prev_solstice);
u16::try_from(new_year - prev_new_year).unwrap_or(360)
}

Expand All @@ -514,15 +572,18 @@ pub fn days_in_prev_year<C: ChineseBased>(new_year: RataDie) -> u16 {
pub fn month_structure_for_year<C: ChineseBased>(
new_year: RataDie,
next_new_year: RataDie,
drift_delta: DriftDelta,
) -> ([bool; 13], Option<NonZeroU8>) {
let mut ret = [false; 13];

let mut current_month_start = new_year;
let mut current_month_major_solar_term = major_solar_term_from_fixed::<C>(new_year);
let mut current_month_major_solar_term =
major_solar_term_from_fixed::<C>(new_year, drift_delta);
let mut leap_month_index = None;
for i in 0u8..12 {
let next_month_start = new_moon_on_or_after::<C>((current_month_start + 28).as_moment());
let next_month_major_solar_term = major_solar_term_from_fixed::<C>(next_month_start);
let next_month_major_solar_term =
major_solar_term_from_fixed::<C>(next_month_start, drift_delta);

if next_month_major_solar_term == current_month_major_solar_term {
leap_month_index = NonZeroU8::new(i + 1);
Expand Down Expand Up @@ -560,7 +621,8 @@ pub fn month_structure_for_year<C: ChineseBased>(
if current_month_start != next_new_year && leap_month_index.is_none() {
leap_month_index = NonZeroU8::new(13); // The last month is a leap month
debug_assert!(
major_solar_term_from_fixed::<C>(current_month_start) == current_month_major_solar_term,
major_solar_term_from_fixed::<C>(current_month_start, drift_delta)
== current_month_major_solar_term,
"A leap month is required here, but it had a major solar term!"
);
}
Expand Down Expand Up @@ -619,6 +681,7 @@ mod test {
let (month_lengths, leap) = month_structure_for_year::<Chinese>(
chinese_year.year_bounds.new_year,
chinese_year.year_bounds.next_new_year,
DriftDelta(0),
);

for (i, month_is_30) in month_lengths.into_iter().enumerate() {
Expand Down

0 comments on commit a64a154

Please sign in to comment.