Skip to content
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

Сalculation speed up for the Gregorian calendar #5849

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 47 additions & 94 deletions components/calendar/src/iso.rs
Original file line number Diff line number Diff line change
@@ -61,13 +61,35 @@ pub struct IsoDateInner(pub(crate) ArithmeticDate<Iso>);
impl CalendarArithmetic for Iso {
type YearInfo = ();

fn month_days(year: i32, month: u8, _data: ()) -> u8 {
fn month_days(year: i32, month: u8, _: ()) -> u8 {
// Binary representation of `30` is `0b__11110`
// Month in 1..=12 represented as `0b__00001`..=`0b__01100`
// So:
// A. For any x in 0..31: `30 | x` = `30 + is_odd(x)`
// | so `30 | (month ^ (month >> 3))` = `30 + is_odd(month ^ (month >> 3))`
// B. `month >> 3` is:
// | * `0` for months in 1..=7,
// | * `1` for months in 8..=12,
// C. From [B] => `is_odd(month ^ (month >> 3))` is
// | * `is_odd(month)` for months in 1..=7,
// | * `!is_odd(month)` for months in 8..=12,
//
// days: | 31 | 28 | 31 | 30 | 31 | 30 | 31 | 31 | 30 | 31 | 30 | 31 |
// month: | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
// B: | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 1 |
// C: | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 0 | 1 |
// A: | 31 |=30=| 31 | 30 | 31 | 30 | 31 | 31 | 30 | 31 | 30 | 31 |
//
//
// Avg. speed is ~the same as full matching because of
// computation time for `30 | (month ^ (month >> 3))`,
// but there will be less jump and therefore it can be
// helpful for branch predictor.
// Also it use less memory space (fewer generated code).
debug_assert!((1..=12).contains(&month));
match month {
4 | 6 | 9 | 11 => 30,
2 if Self::is_leap_year(year, ()) => 29,
2 => 28,
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
_ => 0,
2 => 28 | (calendrical_calculations::iso::is_leap_year(year) as u8),
_ => 30 | (month ^ (month >> 3)),
}
}

@@ -83,12 +105,8 @@ impl CalendarArithmetic for Iso {
(12, 31)
}

fn days_in_provided_year(year: i32, _data: ()) -> u16 {
if Self::is_leap_year(year, ()) {
366
} else {
365
}
fn days_in_provided_year(year: i32, _: ()) -> u16 {
Self::days_in_year_direct(year)
}
}

@@ -132,51 +150,9 @@ impl Calendar for Iso {
}

fn day_of_week(&self, date: &Self::DateInner) -> types::IsoWeekday {
// For the purposes of the calculation here, Monday is 0, Sunday is 6
// ISO has Monday=1, Sunday=7, which we transform in the last step

// The days of the week are the same every 400 years
// so we normalize to the nearest multiple of 400
let years_since_400 = date.0.year.rem_euclid(400);
debug_assert!(years_since_400 >= 0); // rem_euclid returns positive numbers
let years_since_400 = years_since_400 as u32;
let leap_years_since_400 = years_since_400 / 4 - years_since_400 / 100;
// The number of days to the current year
// Can never cause an overflow because years_since_400 has a maximum value of 399.
let days_to_current_year = 365 * years_since_400 + leap_years_since_400;
// The weekday offset from January 1 this year and January 1 2000
let year_offset = days_to_current_year % 7;

// Corresponding months from
// https://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week#Corresponding_months
let month_offset = if Self::is_leap_year(date.0.year, ()) {
match date.0.month {
10 => 0,
5 => 1,
2 | 8 => 2,
3 | 11 => 3,
6 => 4,
9 | 12 => 5,
1 | 4 | 7 => 6,
_ => unreachable!(),
}
} else {
match date.0.month {
1 | 10 => 0,
5 => 1,
8 => 2,
2 | 3 | 11 => 3,
6 => 4,
9 | 12 => 5,
4 | 7 => 6,
_ => unreachable!(),
}
};
let january_1_2000 = 5; // Saturday
let day_offset = (january_1_2000 + year_offset + month_offset + date.0.day as u32) % 7;

// We calculated in a zero-indexed fashion, but ISO specifies one-indexed
types::IsoWeekday::from((day_offset + 1) as usize)
let day_of_week =
calendrical_calculations::iso::day_of_week(date.0.year, date.0.month, date.0.day);
types::IsoWeekday::from(day_of_week as usize)
}

fn offset_date(&self, date: &mut Self::DateInner, offset: DateDuration<Self>) {
@@ -200,7 +176,7 @@ impl Calendar for Iso {
}

fn is_in_leap_year(&self, date: &Self::DateInner) -> bool {
Self::is_leap_year(date.0.year, ())
calendrical_calculations::iso::is_leap_year(date.0.year)
}

/// The calendar-specific month represented by `date`
@@ -291,18 +267,17 @@ impl Iso {
Self
}

/// Count the number of days in a given month/year combo
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
4 | 6 | 9 | 11 => 30,
2 if Self::is_leap_year(year, ()) => 29,
2 => 28,
_ => 31,
}
}
// /// Count the number of days in a given month/year combo
// const fn days_in_month(year: i32, month: u8) -> u8 {
// // see comment to `<impl CalendarArithmetic for Iso>::month_days`
// match month {
// 2 => 28 | (is_leap_year(year) as u8),
// _ => 30 | (month ^ (month >> 3)),
// }
// }

pub(crate) fn days_in_year_direct(year: i32) -> u16 {
if Self::is_leap_year(year, ()) {
pub(crate) const fn days_in_year_direct(year: i32) -> u16 {
if calendrical_calculations::iso::is_leap_year(year) {
366
} else {
365
@@ -316,19 +291,8 @@ impl Iso {
}

pub(crate) fn iso_from_year_day(year: i32, year_day: u16) -> Date<Iso> {
let mut month = 1;
let mut day = year_day as i32;
while month <= 12 {
let month_days = Self::days_in_month(year, month) as i32;
if day <= month_days {
break;
} else {
debug_assert!(month < 12); // don't try going to month 13
day -= month_days;
month += 1;
}
}
let day = day as u8; // day <= month_days < u8::MAX
let (month, day) = calendrical_calculations::iso::iso_from_year_day(year, year_day);
debug_assert!(month < 13);

#[allow(clippy::unwrap_used)] // month in 1..=12, day <= month_days
Date::try_new_iso(year, month, day).unwrap()
@@ -348,18 +312,7 @@ impl Iso {
}

pub(crate) fn day_of_year(date: IsoDateInner) -> u16 {
// Cumulatively how much are dates in each month
// offset from "30 days in each month" (in non leap years)
let month_offset = [0, 1, -1, 0, 0, 1, 1, 2, 3, 3, 4, 4];
#[allow(clippy::indexing_slicing)] // date.0.month in 1..=12
let mut offset = month_offset[date.0.month as usize - 1];
if Self::is_leap_year(date.0.year, ()) && date.0.month > 2 {
// Months after February in a leap year are offset by one less
offset += 1;
}
let prev_month_days = (30 * (date.0.month as i32 - 1) + offset) as u16;

prev_month_days + date.0.day as u16
calendrical_calculations::iso::day_of_year(date.0.year, date.0.month, date.0.day)
}

/// Wrap the year in the appropriate era code
9 changes: 4 additions & 5 deletions components/calendar/src/julian.rs
Original file line number Diff line number Diff line change
@@ -67,12 +67,11 @@ pub struct JulianDateInner(pub(crate) ArithmeticDate<Julian>);
impl CalendarArithmetic for Julian {
type YearInfo = ();

fn month_days(year: i32, month: u8, _data: ()) -> u8 {
fn month_days(year: i32, month: u8, data: ()) -> u8 {
// See `Iso::month_days`
match month {
4 | 6 | 9 | 11 => 30,
2 if Self::is_leap_year(year, ()) => 29,
2 => 28,
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2 => 28 | (Self::is_leap_year(year, data) as u8),
1..=12 => 30 | (month ^ (month >> 3)),
_ => 0,
}
}
19 changes: 15 additions & 4 deletions utils/calendrical_calculations/Cargo.toml
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ rust-version.workspace = true

# This is a special exception: The algorithms in this crate are based on "Calendrical Calculations" by Reingold and Dershowitz
# which has its lisp code published at https://github.com/EdReingold/calendar-code2/
license = "Apache-2.0"
license = "Apache-2.0" # TODO: Need to add `MIT`/`GNU`?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the license was picked by a lawyer. Why would GNU be needed?

Copy link
Author

@Nikita-str Nikita-str Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a realization of the algorithm(from the author of the article) in C/C++ and in the repo no apache 2.0 license

Here is a comment with mentioning it in the PR

So I don't sure is it necessary to add any of them or not

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. We'd have to talk to the lawyer again for this.

Typically algorithms themselves aren't copyrightable, however we would indeed need to check with our lawyer to pull in this code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the policy is, we can redistribute MIT (but not GPL) code under the Apache-2.0 license, and all third-party code should retain its copyright comments inline in the code, similar to the Reingold code.

Copy link
Member

@sffc sffc Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first link goes to a file with the following comment at the top

// SPDX-License-Identifier: GPL-3.0-or-later

According to the terms of the GPL license, which are fairly strict, an Apache-licensed crate such as calendrical_calculations would not be able to redistribute that code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A workaround to this type of issue would be for any GPL-licensed code to live in its own crate, and then either icu_calendar or calendrical_calculations has an optional Cargo feature to consume it. Clients who would like the speedup and are okay consuming GPL-licensed code would need to manually enable the Cargo feature.

I do not know whether such a GPL crate could live in this repository or whether it would need its own repository.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather avoid introducing GPL licensed code in our dep tree at all.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sffc @Manishearth
As I can understand you can implement algos from an article without such license restriction.
And if you will check the code by link and the code from PR it's will be pretty clear* that code was inspired by the article and only then it was matched with author's code for reference to authority. So maybe I can just remove link to the author implementation and leave only links to the article?

[*]: because of naming(unnamed const and very short names that say almost nothing (except for y/m/d ones, yeah they can say something, but nothing about how and why)) in the repo of the article's author. And in the PR even some consts was changed because of we have larger valid dates' interval -- in the author's code they are just magic numbers again; and how will you change such consts without understanding for what and why? And of course in the PR code there is plenty comments about why and how.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I understand that generally algorithms are not copyrightable, but either way, we will have to get approval from our lawyer for doing this, and they may choose to be more cautious about this. We were already quite cautious about the Reingold&Dershowitz algoritms.


[package.metadata.workspaces]
independent = true
@@ -37,9 +37,20 @@ displaydoc = { workspace = true }
log = { workspace = true, optional = true }

[features]
bench = []
logging = ["dep:log"]
std = []

[package.metadata.cargo-all-features]
# Bench feature gets tested separately and is only relevant for CI
denylist = ["bench"]
# [package.metadata.cargo-all-features]
# # Bench feature gets tested separately and is only relevant for CI
# denylist = ["bench"]

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
criterion = { workspace = true }

[[bench]]
name = "iso"
harness = false

# [profile.bench]
# lto = false
291 changes: 291 additions & 0 deletions utils/calendrical_calculations/benches/iso.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
use core::hint::black_box as bb;

use calendrical_calculations::{helpers::I32CastError, rata_die::RataDie};
use criterion::{criterion_group, criterion_main, Criterion};

#[cfg(feature = "bench")]
use calendrical_calculations::bench_support::iso_old as old;
use calendrical_calculations::iso as new;

#[cfg(feature = "bench")]
use calendrical_calculations::bench_support::julian_old as j_old;
use calendrical_calculations::julian as j_new;

const MONTH_DAYS: [u8; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
type IsoFromFixedFn = fn(RataDie) -> Result<(i32, u8, u8), I32CastError>;

fn prep_gen_ymd_vec(y: i32, delta: i32) -> Vec<(i32, u8, u8)> {
let mut ret = Vec::with_capacity(delta as usize * 2 * 366);
for year in y - delta..=y + delta {
for month in 1..=12u8 {
for day in 1..=MONTH_DAYS[(month as usize) - 1] {
ret.push((year, month, day));
}
}
}
bb(ret)
}

fn prep_gen_yd_vec(y: i32, delta: i32, from: u16, to: u16) -> Vec<(i32, u16)> {
let mut ret = Vec::with_capacity(delta as usize * 2 * 365);
for year in y - delta..=y + delta {
for day in from..=to {
ret.push((year, day))
}
}
bb(ret)
}

fn prep_gen_rata_die_vec(n: i64) -> Vec<RataDie> {
let mut ret = Vec::with_capacity(n as usize);
(1..=n).for_each(|n| ret.push(RataDie::new(n)));
bb(ret)
}

fn fixed_from(f: fn(i32, u8, u8) -> RataDie, ymd_vec: &Vec<(i32, u8, u8)>) -> i64 {
// The problem here is that LTO(it is turned on for benches in the workspace)
// is very good at cyclic optimization when we goes year by year:
// ```
// for year in (y - delta)..=(y + delta) {
// for month in 1..=12u8 {
// for day in 1..=MONTH_DAYS[(month as usize) - 1] {
// ...
// ```
// and seems like the optimizer find out that in such cycles
// result will be differ a little, and loop unroling stage
// transforms it in something very optimized
// (like incrimenting by one. It seems really so, because for new algo
// mentioned cycle was optimized to perf of empty cycle with `black_box`'es)
//
// And we want to test perf of the algos, not how good the optimizer work with
// cases when the dates goes one by one.
//
// And there we get another problem: the algo(at least the new one) is +- as fast
// as {cycle + `black_box`} overheads ://
// So we should to call the function multiple times to at least reduce
// the influence of perf measurements of the cycle ¯\_(ツ)_/¯

let mut sum = 0;
for (year, month, day) in ymd_vec {
let (year, month, day) = (bb(*year), bb(*month), bb(*day));

// If you comment next lines
// Then you will get ~10% of NEW algo (lets say this ~10% is `X ms`)
// So real perf ratio is:
// `(OLD - X)/(NEW - X)`
// And in my case this is +- about the asm instr len differ
sum += f(year, month, day).to_i64_date();
sum += f(year + 7, month, (day + 2) >> 1).to_i64_date();
sum += f(year + 37, (month + 3) >> 1, (day + 3) >> 1).to_i64_date();
sum += f(year + 137, (month + 7) >> 1, (day + 19) >> 1).to_i64_date();
}

bb(sum)
}

fn day_of_week(f: fn(i32, u8, u8) -> u8, ymd_vec: &Vec<(i32, u8, u8)>) -> i32 {
let mut sum = 0;
for (year, month, day) in ymd_vec {
let (year, month, day) = (bb(*year), bb(*month), bb(*day));

sum += f(year, month, day) as i32;
sum += f(year + 31, month, (day + 7) >> 1) as i32;
sum += f(year + 141, (month + 5) >> 1, day) as i32;
sum += f(year + 243, (month + 2) >> 1, (day + 17) >> 1) as i32;
}

sum
}

fn day_of_year(f: fn(i32, u8, u8) -> u16, ymd_vec: &Vec<(i32, u8, u8)>) -> i32 {
let mut sum = 0;
for (year, month, day) in ymd_vec {
let (year, month, day) = (bb(*year), bb(*month), bb(*day));

sum += f(year, month, day) as i32;
sum += f(year + 31, month, (day + 7) >> 1) as i32;
sum += f(year + 141, (month + 5) >> 1, day) as i32;
sum += f(year + 243, (month + 2) >> 1, (day + 17) >> 1) as i32;
}

sum
}

fn from_fixed(f: IsoFromFixedFn, rd_vec: &Vec<RataDie>) -> i64 {
let map = |r: Result<(i32, u8, u8), I32CastError>| {
let x = r.unwrap();
x.0 as i64 + (x.1 + x.2) as i64
};

let mut sum = 0;
for rd in rd_vec {
let rd = bb(*rd).to_i64_date();

sum += map(f(RataDie::new(rd)));
sum += map(f(RataDie::new(rd + 1313)));
sum += map(f(RataDie::new(rd + 7429)));
sum += map(f(RataDie::new(rd - 5621)));
}

sum
}

fn iso_from_year_day(f: fn(i32, u16) -> (u8, u8), rd_vec: &Vec<(i32, u16)>) -> i64 {
let map = |x: (u8, u8)| (x.0 + x.1) as i64;
let mut sum = 0;

for (year, day_of_year) in rd_vec {
let (year, day_of_year) = (bb(*year), bb(*day_of_year));

sum += map(f(year, day_of_year));
sum += map(f(year + 21, day_of_year));
sum += map(f(year + 97, (day_of_year + 127) >> 1));
sum += map(f(year + 137, (day_of_year + 12) >> 1));
}

sum
}

#[no_mangle]
fn bench_fixed_from(c: &mut Criterion) {
const Y: i32 = 1_000;
const DELTA: i32 = 2_000;
let ymd_vec = prep_gen_ymd_vec(Y, DELTA);

{
let mut group = c.benchmark_group("fixed_from_iso");

#[cfg(feature = "bench")]
group.bench_function("OLD", |b| {
b.iter(|| fixed_from(old::fixed_from_iso, bb(&ymd_vec)))
});
group.bench_function("NEW", |b| {
b.iter(|| fixed_from(new::fixed_from_iso, bb(&ymd_vec)))
});
}

{
let mut group = c.benchmark_group("fixed_from_julian");

#[cfg(feature = "bench")]
group.bench_function("OLD", |b| {
b.iter(|| fixed_from(j_old::fixed_from_julian, bb(&ymd_vec)))
});
group.bench_function("NEW", |b| {
b.iter(|| fixed_from(j_new::fixed_from_julian, bb(&ymd_vec)))
});
}
}

fn bench_day_of_week(c: &mut Criterion) {
const Y: i32 = 1_000;
const DELTA: i32 = 2_000;
let ymd_vec = prep_gen_ymd_vec(Y, DELTA);

let mut group = c.benchmark_group("day_of_week");

#[cfg(feature = "bench")]
group.bench_function("iso/OLD", |b| {
b.iter(|| day_of_week(old::day_of_week, bb(&ymd_vec)))
});
group.bench_function("iso/NEW", |b| {
b.iter(|| day_of_week(new::day_of_week, bb(&ymd_vec)))
});
}

fn bench_day_of_year(c: &mut Criterion) {
const Y: i32 = 1_000;
const DELTA: i32 = 2_000;
let ymd_vec = prep_gen_ymd_vec(Y, DELTA);

let mut group = c.benchmark_group("day_of_year");

#[cfg(feature = "bench")]
group.bench_function("iso/OLD", |b| {
b.iter(|| day_of_year(old::day_of_year, bb(&ymd_vec)))
});
group.bench_function("iso/NEW", |b| {
b.iter(|| day_of_year(new::day_of_year, bb(&ymd_vec)))
});

#[cfg(feature = "bench")]
group.bench_function("julian/OLD", |b| {
b.iter(|| day_of_year(j_old::day_of_year, bb(&ymd_vec)))
});
group.bench_function("julian/NEW", |b| {
b.iter(|| day_of_year(j_new::day_of_year, bb(&ymd_vec)))
});
}

fn bench_from_fixed(c: &mut Criterion) {
const N: i64 = 10_000;
let rd_vec = prep_gen_rata_die_vec(N);

let mut group = c.benchmark_group("from_fixed");

#[cfg(feature = "bench")]
group.bench_function("iso/OLD", |b| {
b.iter(|| from_fixed(old::iso_from_fixed, bb(&rd_vec)))
});
group.bench_function("iso/NEW", |b| {
b.iter(|| from_fixed(new::iso_from_fixed, bb(&rd_vec)))
});

#[cfg(feature = "bench")]
group.bench_function("julian/OLD", |b| {
b.iter(|| from_fixed(j_old::julian_from_fixed, bb(&rd_vec)))
});
group.bench_function("julian/NEW", |b| {
b.iter(|| from_fixed(j_new::julian_from_fixed, bb(&rd_vec)))
});
}

fn bench_iso_from_year_day(c: &mut Criterion) {
const Y: i32 = 1_000;
const DELTA: i32 = 2_000;

let mut group = c.benchmark_group("from_year_day");

let yd_vec = prep_gen_yd_vec(Y, DELTA, 1, 365);
#[cfg(feature = "bench")]
group.bench_function("iso/OLD/AVG", |b| {
b.iter(|| iso_from_year_day(old::iso_from_year_day, bb(&yd_vec)))
});
group.bench_function("iso/NEW/AVG", |b| {
b.iter(|| iso_from_year_day(new::iso_from_year_day, bb(&yd_vec)))
});

// In range of first two months old algo is faster
// And they ~ the same perf in 3rd/4th months
let yd_vec = prep_gen_yd_vec(Y, DELTA, 3, 57);
#[cfg(feature = "bench")]
group.bench_function("iso/OLD/START", |b| {
b.iter(|| iso_from_year_day(old::iso_from_year_day, bb(&yd_vec)))
});
group.bench_function("iso/NEW/START", |b| {
b.iter(|| iso_from_year_day(new::iso_from_year_day, bb(&yd_vec)))
});

let yd_vec = prep_gen_yd_vec(Y, DELTA, 300, 360);
#[cfg(feature = "bench")]
group.bench_function("iso/OLD/END", |b| {
b.iter(|| iso_from_year_day(old::iso_from_year_day, bb(&yd_vec)))
});
group.bench_function("iso/NEW/END", |b| {
b.iter(|| iso_from_year_day(new::iso_from_year_day, bb(&yd_vec)))
});
}

criterion_group!(benchmark_fixed_from, bench_fixed_from);
criterion_group!(benchmark_year_from_fixed, bench_day_of_week);
criterion_group!(benchmark_day_of_year, bench_day_of_year);
criterion_group!(benchmark_from_fixed, bench_from_fixed);
criterion_group!(benchmark_iso_from_year_day, bench_iso_from_year_day);

criterion_main!(
benchmark_fixed_from,
benchmark_year_from_fixed,
benchmark_day_of_year,
benchmark_from_fixed,
benchmark_iso_from_year_day
);
2 changes: 1 addition & 1 deletion utils/calendrical_calculations/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -282,7 +282,7 @@ fn test_invert_angular() {
}

/// Error returned when casting from an i32
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
#[allow(clippy::exhaustive_enums)] // enum is specific to function and has a closed set of possible values
pub enum I32CastError {
/// Less than i32::MIN
564 changes: 499 additions & 65 deletions utils/calendrical_calculations/src/iso.rs

Large diffs are not rendered by default.

175 changes: 95 additions & 80 deletions utils/calendrical_calculations/src/julian.rs
Original file line number Diff line number Diff line change
@@ -1,99 +1,99 @@
// This file is part of ICU4X.
//
// The contents of this file implement algorithms from Calendrical Calculations
// by Reingold & Dershowitz, Cambridge University Press, 4th edition (2018),
// which have been released as Lisp code at <https://github.com/EdReingold/calendar-code2/>
// under the Apache-2.0 license. Accordingly, this file is released under
// the Apache License, Version 2.0 which can be found at the calendrical_calculations
// package root or at http://www.apache.org/licenses/LICENSE-2.0.

use crate::helpers::{i64_to_i32, I32CastError};
// The contents of this file implement algorithms from the article:
// "Euclidean affine functions and their application to calendar algorithms"
// by Cassio Neri & Lorenz Schneider (Dec. 2022), DOI: 10.1002/spe.3172

use crate::helpers::I32CastError;
use crate::rata_die::RataDie;

// Julian epoch is equivalent to fixed_from_iso of December 30th of 0 year
// 1st Jan of 1st year Julian is equivalent to December 30th of 0th year of ISO year
const JULIAN_EPOCH: RataDie = RataDie::new(-1);
/// In Julian calendar each 4 years sequence of leap days repeated.\
const ONE_PERIOD_TO_DAYS: i64 = 365 * 4 + 1;

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1684-L1687>
#[inline(always)]
pub const fn is_leap_year(year: i32) -> bool {
year % 4 == 0
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1689-L1709>
/// # Returns
/// day of the year in the Gregorian calendar:
/// + `1..=365` for a non leap year
/// + `1..=366` for a leap year
pub const fn day_of_year(year: i32, month: u8, day: u8) -> u16 {
const DAYS_BEFORE_FEB: u8 = 31;
const DAYS_BEFORE_MAR: u16 = DAYS_BEFORE_FEB as u16 + 28;

#[allow(clippy::comparison_chain)]
if month > 2 {
let days_before_month = super::iso::calc_day_before_month_in_pseudo_year(month, false);
// shift back from pseudo calendar
// ⚠️ `is_leap_year` differ from `iso` so we need to copy-paste \(^∇^)/ this fn fully
// | also can be used macro/fn that take fn but .. readability!
let leap = is_leap_year(year) as u16 + DAYS_BEFORE_MAR;
days_before_month as u16 + day as u16 + leap
} else if month == 2 {
(day + DAYS_BEFORE_FEB) as u16
} else {
day as u16
}
}

/// Returns years passed from Jan 1st of year 1.
///
/// The Algo based on Cassio Neri & Lorenz Schneider algo for Gregorian calendar.
pub const fn fixed_from_julian(year: i32, month: u8, day: u8) -> RataDie {
let mut fixed =
JULIAN_EPOCH.to_i64_date() - 1 + 365 * (year as i64 - 1) + (year as i64 - 1).div_euclid(4);
debug_assert!(month > 0 && month < 13, "Month should be in range 1..=12.");
fixed += match month {
1 => 0,
2 => 31,
3 => 59,
4 => 90,
5 => 120,
6 => 151,
7 => 181,
8 => 212,
9 => 243,
10 => 273,
11 => 304,
12 => 334,
_ => -1,
};
// Only add one if the month is after February (month > 2), since leap days are added to the end of February
if month > 2 && is_leap_year(year) {
fixed += 1;
}
RataDie::new(fixed + (day as i64))

let day_of_year = day_of_year(year, month, day) as i64;

let year = year as i64;
let non_leap = ((year & 0b11) != 0) as i64;
// Leap years every 4 year: `365 * year + year / 4`
let rata_die_year = non_leap + ((ONE_PERIOD_TO_DAYS * year) >> 2);

const SHIFT: i64 = -368;
RataDie::new(rata_die_year + day_of_year + SHIFT)
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1711-L1738>
pub fn julian_from_fixed(date: RataDie) -> Result<(i32, u8, u8), I32CastError> {
let approx = (4 * date.to_i64_date() + 1464).div_euclid(1461);
let year = i64_to_i32(approx)?;
let prior_days = date
- fixed_from_julian(year, 1, 1)
- if is_leap_year(year) && date > fixed_from_julian(year, 2, 28) {
1
} else {
0
};
let adjusted_year = if prior_days >= 365 {
year.saturating_add(1)
} else {
year
};
let adjusted_prior_days = prior_days.rem_euclid(365);
debug_assert!((0..365).contains(&adjusted_prior_days));
let month = if adjusted_prior_days < 31 {
1
} else if adjusted_prior_days < 59 {
2
} else if adjusted_prior_days < 90 {
3
} else if adjusted_prior_days < 120 {
4
} else if adjusted_prior_days < 151 {
5
} else if adjusted_prior_days < 181 {
6
} else if adjusted_prior_days < 212 {
7
} else if adjusted_prior_days < 243 {
8
} else if adjusted_prior_days < 273 {
9
} else if adjusted_prior_days < 304 {
10
} else if adjusted_prior_days < 334 {
11
} else {
12
};
let day = (date - fixed_from_julian(adjusted_year, month, 1) + 1) as u8; // as days_in_month is < u8::MAX
debug_assert!(day <= 31, "Day assertion failed; date: {date:?}, adjusted_year: {adjusted_year}, prior_days: {prior_days}, month: {month}, day: {day}");
/// Returns (years, months, days) passed from Jan 1st of year 1.
///
/// The Algo based on Cassio Neri & Lorenz Schneider algo for Gregorian calendar
pub const fn julian_from_fixed(date: RataDie) -> Result<(i32, u8, u8), I32CastError> {
// ⚠️ To better understand the algo see `iso::iso_from_fixed`

const ONE_PERIOD_TO_DAYS: i64 = 365 * 4 + 1;
const AMOUNT_OF_PERIODS: i64 = (1 << 50) / 4 + 1;
const YEAR_SHIFT: i64 = 1;
const SHIFT: i64 =
307 + ((YEAR_SHIFT + 3) / 4) + YEAR_SHIFT * 365 + ONE_PERIOD_TO_DAYS * AMOUNT_OF_PERIODS;
const SHIFT_TO_YEARS: i64 = 4 * AMOUNT_OF_PERIODS + YEAR_SHIFT;

if let Some(err) = check_rata_die_have_i32_year(date) {
return Err(err);
}

let date = date.to_i64_date();

let shifted_rata_die = (date + SHIFT) as u64;
// ⚠️ in the initial algo there stay `(shifted_rata_die << 2) | 3`
// But because of the SHIFT choose (`YEAR_SHIFT`)
// We can remove `| 3`
let prepared_days = shifted_rata_die << 2;
let year = (prepared_days / (ONE_PERIOD_TO_DAYS as u64)) as i64;
let day_of_period = prepared_days % (ONE_PERIOD_TO_DAYS as u64);

// In our case we should not do `| 3`
let prepared = day_of_period;
const APPROX_NUM_C: u64 = 2939745;
let approx_prepared = APPROX_NUM_C * prepared;
let day_of_year = (approx_prepared as u32) / (APPROX_NUM_C as u32) / 4;

Ok((adjusted_year, month, day))
let need_to_shift_months = day_of_year >= 306;
let year = (year - SHIFT_TO_YEARS) + (need_to_shift_months as i64);
let (month, day) = crate::iso::calc_md_for_pseudo_year(day_of_year, need_to_shift_months);

Ok((year as i32, month, day))
}

/// Get a fixed date from the ymd of a Julian date.
@@ -115,3 +115,18 @@ pub const fn fixed_from_julian_book_version(book_year: i32, month: u8, day: u8)
day,
)
}

#[inline(always)]
const fn check_rata_die_have_i32_year(input: RataDie) -> Option<I32CastError> {
const MIN: i64 = fixed_from_julian(i32::MIN, 1, 1).to_i64_date();
const MAX: i64 = fixed_from_julian(i32::MAX, 12, 31).to_i64_date();

let input = input.to_i64_date();
if input < MIN {
Some(I32CastError::BelowMin)
} else if input > MAX {
Some(I32CastError::AboveMax)
} else {
None
}
}
19 changes: 19 additions & 0 deletions utils/calendrical_calculations/src/lib.rs
Original file line number Diff line number Diff line change
@@ -59,3 +59,22 @@ pub mod persian;
/// Representation of Rata Die (R.D.) dates, which are
/// represented as the number of days since ISO date 0001-01-01.
pub mod rata_die;

#[cfg(any(test, feature = "bench"))]
#[doc(hidden)]
mod tests;

#[cfg(feature = "bench")]
#[doc(hidden)]
/// Old implementations for bench comparing
pub mod bench_support {
/// Old Julian implementation
pub mod julian_old {
pub use crate::tests::julian_old_file::*;
}
/// Old Greorgian implementation
pub mod iso_old {
pub use crate::tests::iso_old_algos::*;
pub use crate::tests::iso_old_file::*;
}
}
297 changes: 297 additions & 0 deletions utils/calendrical_calculations/src/tests/iso.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
use super::helpful_consts::*;
use super::iso_old_algos as alg_old;
use super::iso_old_file as iso_old;
use crate::iso as iso_new;
use crate::rata_die::RataDie;
use core::ops::RangeInclusive;

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] helpful fns

fn calc_last_month_day(year: i32, month: u8) -> u8 {
let to = MONTH_DAYS[(month as usize) - 1];
if iso_old::is_leap_year(year) && (month == 2) {
to + 1
} else {
to
}
}

pub(crate) fn assert_year(year: i32, mut assert_f: impl FnMut(i32, u8, u8)) {
for month in 1..=12u8 {
for day in 1..=calc_last_month_day(year, month) {
assert_f(year, month, day)
}
}
}

fn assert_year_rata_die(year: i32, mut assert_f: impl FnMut(i32, u8, u8, RataDie)) {
for month in 1..=12u8 {
for day in 1..=calc_last_month_day(year, month) {
let rata_die = iso_old::fixed_from_iso(year, month, day);
assert_f(year, month, day, rata_die)
}
}
}

// [-] helpful fns
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] test correctness

#[test]
fn test_algo_correctness() {
// 1st Jan of 1 year:
let rata_die_initial = RataDie::new(1);

// Now we know that `iso_new::iso_from_fixed` is correct for YMD(1, 1, 1):
assert_eq!(iso_new::iso_from_fixed(rata_die_initial), Ok((1, 1, 1)));
// Now we know that `iso_new::day_of_week` is correct for YMD(2024, 11, 25):
assert_eq!(iso_new::day_of_week(2024, 11, 25), 1);

let delta = 9_999; // 3_999_999;

// This can be incorrect, but if it so, then on YMD(1, 1, 1) we will figured that out,
// because if it is incorrect we get incorrect value for `rata_die_i64` for the YMD(1, 1, 1)
// and `assert_eq!(iso_new::iso_from_fixed(rata_die), Ok((year, month, day)))` will panic
let mut rata_die_i64 = iso_new::fixed_from_iso(-delta, 1, 1).to_i64_date();

// This can be incorrect, but if it so, then on YMD(2024, 11, 25) we will figured that out
// (days of weeks goes cyclic on each other)
let mut day_of_week = iso_new::day_of_week(2024, 11, 25);

let mut year_day = 1;
// Because rata die's stay each after each we can verify that in a range
// all of `iso_new::iso_from_fixed` is correct, if that range contains the YMD(1, 1, 1):
for year in -delta..=delta {
assert_year(year, |year, month, day| {
if (month == 1) && (day == 1) {
year_day = 1;
}

let rata_die = RataDie::new(rata_die_i64);
assert_eq!(iso_new::iso_from_fixed(rata_die), Ok((year, month, day)));
assert_eq!(iso_new::iso_year_from_fixed(rata_die), year as i64);
assert_eq!(iso_new::fixed_from_iso(year, month, day), rata_die);
assert_eq!(iso_new::day_of_week(year, month, day), day_of_week);

assert_eq!(iso_new::iso_from_year_day(year, year_day), (month, day));
assert_eq!(iso_new::day_of_year(year, month, day), year_day);

if (month == 12) && (day == 31) {
assert_eq!(
year_day,
365 + iso_old::is_leap_year(year) as u16,
"YMD: {year} {month} {day}"
);
}

rata_die_i64 += 1;

day_of_week += 1;
if day_of_week == 8 {
day_of_week = 1;
}

year_day += 1;
});
}

// FNS: {iso_from_fixed, iso_year_from_fixed, fixed_from_iso, day_of_week, iso_from_year_day, day_of_year};
// So now we sure that all fns from FNS is correct (at least in range -delta..=delta)
}

// [-] test correctness
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] test the same result as prev algo

#[test]
fn test_is_leap_year_the_same() {
const N_YEAR: i32 = 99_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR; // i32::MIN..=i32::MAX

fn the_same_in_year(year: i32) {
assert_eq!(
iso_old::is_leap_year(year),
iso_new::is_leap_year(year),
"year = {year}"
);
}

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn test_fixed_from_iso_the_same() {
const N_YEAR: i32 = 9_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year(year, |year, month, day| {
assert_eq!(
iso_old::fixed_from_iso(year, month, day),
iso_new::fixed_from_iso(year, month, day),
"YMD: {year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn test_year_from_fixed_the_same() {
const N_YEAR: i32 = 9_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year_rata_die(year, |year, month, day, rata_die| {
assert_eq!(
year as i64,
iso_new::iso_year_from_fixed(rata_die),
"{year} {month} {day} | {rata_die:?}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);

const RATA_MIN: i64 = i64::MIN / 256;
const RATA_MAX: i64 = i64::MAX / 256;
const N_DAYS: i64 = 9_999;

for rata_die in RATA_MIN..(RATA_MIN + N_DAYS) {
let rata_die = RataDie::new(rata_die);
assert_eq!(
iso_old::iso_year_from_fixed(rata_die),
iso_new::iso_year_from_fixed(rata_die),
"{rata_die:?}"
);
}
for rata_die in (RATA_MAX - N_DAYS)..=RATA_MAX {
let rata_die = RataDie::new(rata_die);
assert_eq!(
iso_old::iso_year_from_fixed(rata_die),
iso_new::iso_year_from_fixed(rata_die),
"{rata_die:?}"
);
}
}

#[test]
fn test_from_fixed_eq() {
const N_YEAR: i32 = 1_999; // `iso_old::iso_from_fixed` is slow
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year_rata_die(year, |year, month, day, rata_die| {
assert_eq!(
iso_old::iso_from_fixed(rata_die),
iso_new::iso_from_fixed(rata_die),
"{year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);

const RATA_MIN: i64 = i64::MIN / 256;
const RATA_MAX: i64 = i64::MAX / 256;
const N_DAYS: i64 = 99_999;

for rata_die in RATA_MIN..(RATA_MIN + N_DAYS) {
let rata_die = RataDie::new(rata_die);
assert_eq!(
iso_old::iso_from_fixed(rata_die),
iso_new::iso_from_fixed(rata_die),
"{rata_die:?}"
);
}
for rata_die in (RATA_MAX - N_DAYS)..=RATA_MAX {
let rata_die = RataDie::new(rata_die);
assert_eq!(
iso_old::iso_from_fixed(rata_die),
iso_new::iso_from_fixed(rata_die),
"{rata_die:?}"
);
}
}

// New algos in the `iso` and prev. algos from others files:

#[test]
fn test_day_of_week_the_same() {
const N_YEAR: i32 = 2_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

fn the_same_in_year(year: i32) {
for month in 1..=12u8 {
for day in 1..=calc_last_month_day(year, month) {
assert_eq!(
alg_old::day_of_week(year, month, day),
iso_new::day_of_week(year, month, day),
"YMD: {year} {month} {day}"
);
}
}
}

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn test_from_year_day_the_same() {
const N_YEAR: i32 = 2_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

fn the_same_in_year(year: i32) {
let days = 365 + iso_new::is_leap_year(year) as u16;
for day_of_year in 1..=days {
assert_eq!(
alg_old::iso_from_year_day(year, day_of_year),
iso_new::iso_from_year_day(year, day_of_year),
"Y & DofY: {year} {day_of_year}"
);
}
}

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn test_day_of_year_the_same() {
const N_YEAR: i32 = 2_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year(year, |year, month, day| {
assert_eq!(
alg_old::day_of_year(year, month, day),
iso_new::day_of_year(year, month, day),
"YMD: {year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

// [-] test the same result as prev algo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
102 changes: 102 additions & 0 deletions utils/calendrical_calculations/src/tests/iso_old_algos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use crate::iso::is_leap_year;

type IsoWeekday = u8;

/// Prev algo from: `components\calendar\src\iso.rs`
// In the code removed: `date.0.`
// Next line WAS: `fn day_of_week(&self, date: &Self::DateInner) -> types::IsoWeekday {`
pub fn day_of_week(year: i32, month: u8, day: u8) -> IsoWeekday {
// For the purposes of the calculation here, Monday is 0, Sunday is 6
// ISO has Monday=1, Sunday=7, which we transform in the last step

// The days of the week are the same every 400 years
// so we normalize to the nearest multiple of 400
let years_since_400 = year.rem_euclid(400);
debug_assert!(years_since_400 >= 0); // rem_euclid returns positive numbers
let years_since_400 = years_since_400 as u32;
let leap_years_since_400 = years_since_400 / 4 - years_since_400 / 100;
// The number of days to the current year
// Can never cause an overflow because years_since_400 has a maximum value of 399.
let days_to_current_year = 365 * years_since_400 + leap_years_since_400;
// The weekday offset from January 1 this year and January 1 2000
let year_offset = days_to_current_year % 7;

// Corresponding months from
// https://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week#Corresponding_months
let month_offset = if is_leap_year(year) {
match month {
10 => 0,
5 => 1,
2 | 8 => 2,
3 | 11 => 3,
6 => 4,
9 | 12 => 5,
1 | 4 | 7 => 6,
_ => unreachable!(),
}
} else {
match month {
1 | 10 => 0,
5 => 1,
8 => 2,
2 | 3 | 11 => 3,
6 => 4,
9 | 12 => 5,
4 | 7 => 6,
_ => unreachable!(),
}
};
let january_1_2000 = 5; // Saturday
let day_offset = (january_1_2000 + year_offset + month_offset + day as u32) % 7;

// We calculated in a zero-indexed fashion, but ISO specifies one-indexed
(day_offset + 1) as u8
}

/// Count the number of days in a given month/year combo
const fn days_in_month(year: i32, month: u8) -> u8 {
// see comment to `<impl CalendarArithmetic for Iso>::month_days`
match month {
2 => 28 | (is_leap_year(year) as u8),
_ => 30 | (month ^ (month >> 3)),
}
}

/// Prev algo from: `components\calendar\src\iso.rs::Iso`
///
/// Return `(day, month)` for the given `year & `day_of_year`
pub fn iso_from_year_day(year: i32, year_day: u16) -> (u8, u8) {
let mut month = 1;
let mut day = year_day as i32;
while month <= 12 {
let month_days = days_in_month(year, month) as i32;
if day <= month_days {
break;
} else {
debug_assert!(month < 12); // don't try going to month 13
day -= month_days;
month += 1;
}
}
let day = day as u8; // day <= month_days < u8::MAX

(month, day)
}

/// Prev algo from: `components\calendar\src\iso.rs::Iso`
///
/// Return `day_of_the_year` (`1..=365`/`1..=366`)
pub fn day_of_year(year: i32, month: u8, day: u8) -> u16 {
// Cumulatively how much are dates in each month
// offset from "30 days in each month" (in non leap years)
let month_offset = [0, 1, -1, 0, 0, 1, 1, 2, 3, 3, 4, 4];
#[allow(clippy::indexing_slicing)] // date.0.month in 1..=12
let mut offset = month_offset[month as usize - 1];
if is_leap_year(year) && month > 2 {
// Months after February in a leap year are offset by one less
offset += 1;
}
let prev_month_days = (30 * (month as i32 - 1) + offset) as u16;

prev_month_days + day as u16
}
94 changes: 94 additions & 0 deletions utils/calendrical_calculations/src/tests/iso_old_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// This file is part of ICU4X.
//
// The contents of this file implement algorithms from Calendrical Calculations
// by Reingold & Dershowitz, Cambridge University Press, 4th edition (2018),
// which have been released as Lisp code at <https://github.com/EdReingold/calendar-code2/>
// under the Apache-2.0 license. Accordingly, this file is released under
// the Apache License, Version 2.0 which can be found at the calendrical_calculations
// package root or at http://www.apache.org/licenses/LICENSE-2.0.

use crate::helpers::{i64_to_i32, I32CastError};
use crate::rata_die::RataDie;

// The Gregorian epoch is equivalent to first day in fixed day measurement
const EPOCH: RataDie = RataDie::new(1);

/// Whether or not `year` is a leap year
#[inline] // real call will be more complex operation than inner code
pub fn is_leap_year(year: i32) -> bool {
year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)
}

// Fixed is day count representation of calendars starting from Jan 1st of year 1.
// The fixed calculations algorithms are from the Calendrical Calculations book.
//
/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1167-L1189>
pub fn fixed_from_iso(year: i32, month: u8, day: u8) -> RataDie {
let prev_year = (year as i64) - 1;
// Calculate days per year
let mut fixed: i64 = (EPOCH.to_i64_date() - 1) + 365 * prev_year;
// Calculate leap year offset
let offset = prev_year.div_euclid(4) - prev_year.div_euclid(100) + prev_year.div_euclid(400);
// Adjust for leap year logic
fixed += offset;
// Days of current year
fixed += (367 * (month as i64) - 362).div_euclid(12);
// Leap year adjustment for the current year
fixed += if month <= 2 {
0
} else if is_leap_year(year) {
-1
} else {
-2
};
// Days passed in current month
fixed += day as i64;
RataDie::new(fixed)
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1191-L1217>
pub(crate) fn iso_year_from_fixed(date: RataDie) -> i64 {
// Shouldn't overflow because it's not possbile to construct extreme values of RataDie
let date = date - EPOCH;

// 400 year cycles have 146097 days
let (n_400, date) = (date.div_euclid(146097), date.rem_euclid(146097));

// 100 year cycles have 36524 days
let (n_100, date) = (date.div_euclid(36524), date.rem_euclid(36524));

// 4 year cycles have 1461 days
let (n_4, date) = (date.div_euclid(1461), date.rem_euclid(1461));

let n_1 = date.div_euclid(365);

let year = 400 * n_400 + 100 * n_100 + 4 * n_4 + n_1;

if n_100 == 4 || n_1 == 4 {
year
} else {
year + 1
}
}

fn iso_new_year(year: i32) -> RataDie {
fixed_from_iso(year, 1, 1)
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1525-L1540>
pub fn iso_from_fixed(date: RataDie) -> Result<(i32, u8, u8), I32CastError> {
let year = iso_year_from_fixed(date);
let year = i64_to_i32(year)?;
// Calculates the prior days of the adjusted year, then applies a correction based on leap year conditions for the correct ISO date conversion.
let prior_days = date - iso_new_year(year);
let correction = if date < fixed_from_iso(year, 3, 1) {
0
} else if is_leap_year(year) {
1
} else {
2
};
let month = (12 * (prior_days + correction) + 373).div_euclid(367) as u8; // in 1..12 < u8::MAX
let day = (date - fixed_from_iso(year, month, 1) + 1) as u8; // <= days_in_month < u8::MAX
Ok((year, month, day))
}
190 changes: 190 additions & 0 deletions utils/calendrical_calculations/src/tests/julian.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
use super::helpful_consts::*;
use super::julian_old_file as old;
use crate::{helpers::I32CastError, julian as new, rata_die::RataDie};
use core::ops::RangeInclusive;

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] helpful fns

fn calc_last_month_day(year: i32, month: u8) -> u8 {
let to = MONTH_DAYS[(month as usize) - 1];
if new::is_leap_year(year) && (month == 2) {
to + 1
} else {
to
}
}

pub(crate) fn assert_year(year: i32, mut assert_f: impl FnMut(i32, u8, u8)) {
for month in 1..=12u8 {
for day in 1..=calc_last_month_day(year, month) {
assert_f(year, month, day)
}
}
}

fn assert_year_rata_die(year: i32, mut assert_f: impl FnMut(i32, u8, u8, RataDie)) {
for month in 1..=12u8 {
for day in 1..=calc_last_month_day(year, month) {
let rata_die = old::fixed_from_julian(year, month, day);
assert_f(year, month, day, rata_die)
}
}
}

// [-] helpful fns
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] test correctness

#[test]
fn test_algo_correctness() {
// In old algo there was a comment:
// ```
// Julian epoch is equivalent to fixed_from_iso of December 30th of 0 year
// 1st Jan of 1st year Julian is equivalent to December 30th of 0th year of ISO year
// ```
// ⚠️ IS IT TRUE? ⚠️

// firstly we must be sure that `crate::iso::fixed_from_iso` is correct
// (see `super::iso::test_algo_correctness`)
//
// 1st Jan of 1 year:
let rata_die_initial = crate::iso::fixed_from_iso(0, 12, 30);

// Now we know that `iso_new::julian_from_fixed` is correct for jYMD(1, 1, 1):
assert_eq!(new::julian_from_fixed(rata_die_initial), Ok((1, 1, 1)));

let delta = 9_999; // 3_999_999;

// This can be incorrect, but if it so, then on jYMD(1, 1, 1) we will figured that out,
// because if it is incorrect we get incorrect value for `rata_die_i64` for the jYMD(1, 1, 1)
// and `assert_eq!(iso_new::julian_from_fixed(rata_die), Ok((year, month, day)))` will panic
let mut rata_die_i64 = new::fixed_from_julian(-delta, 1, 1).to_i64_date();

let mut year_day = 1;
// Because rata die's stay each after each we can verify that in a range
// all of `iso_new::julian_from_fixed` is correct, if that range contains the jYMD(1, 1, 1):
for year in -delta..=delta {
assert_year(year, |year, month, day| {
if (month == 1) && (day == 1) {
year_day = 1;
}

let rata_die = RataDie::new(rata_die_i64);
assert_eq!(new::julian_from_fixed(rata_die), Ok((year, month, day)));
assert_eq!(new::fixed_from_julian(year, month, day), rata_die);

assert_eq!(new::day_of_year(year, month, day), year_day);

if (month == 12) && (day == 31) {
assert_eq!(
year_day,
365 + old::is_leap_year(year) as u16,
"YMD: {year} {month} {day}"
);
}

rata_die_i64 += 1;
year_day += 1;
});
}

// FNS: {julian_from_fixed, fixed_from_julian, day_of_year};
// So now we sure that all fns from FNS is correct (at least in range -delta..=delta)
}

// [-] test correctness
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [+] test the same result as prev algo

#[test]
fn day_of_year_the_same() {
const N_YEAR: i32 = 9_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year(year, |year, month, day| {
assert_eq!(
old::day_of_year(year, month, day),
new::day_of_year(year, month, day),
"YMD: {year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn fixed_from_julian_the_same() {
const N_YEAR: i32 = 9_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year(year, |year, month, day| {
assert_eq!(
old::fixed_from_julian(year, month, day),
new::fixed_from_julian(year, month, day),
"YMD: {year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MIN_YEAR_BOUND_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);
}

#[test]
fn julian_from_fixed_the_same() {
const N_YEAR: i32 = 9_999;
const N_RANGE: RangeInclusive<i32> = (-N_YEAR)..=N_YEAR;

let the_same_in_year = |year| {
assert_year_rata_die(year, |year, month, day, date| {
assert_eq!(
old::julian_from_fixed(date),
new::julian_from_fixed(date),
"YMD: {year} {month} {day}"
);
})
};

N_RANGE.for_each(the_same_in_year);
MAX_YEAR_BOUND_RANGE.for_each(the_same_in_year);

// There was a mistake in the prev algo for the date (i32::MIN, 1, 1):
((i32::MIN + 1)..=(i32::MIN + N_YEAR_BOUND)).for_each(the_same_in_year);
let rata_die = new::fixed_from_julian(i32::MIN, 1, 1);
let date = rata_die.to_i64_date();
for date in (date + 1)..=(date + 366) {
let date = RataDie::new(date);
assert_eq!(old::julian_from_fixed(date), new::julian_from_fixed(date),);
}

// The mistake:
assert_eq!(
old::julian_from_fixed(rata_die),
Err(I32CastError::BelowMin)
);
assert_eq!(new::julian_from_fixed(rata_die), Ok((i32::MIN, 1, 1)));

// Ok (should be BelowMin):
assert_eq!(
old::julian_from_fixed(RataDie::new(date - 1)),
Err(I32CastError::BelowMin)
);
assert_eq!(
new::julian_from_fixed(RataDie::new(date - 1)),
Err(I32CastError::BelowMin)
);
}

// [-] test the same result as prev algo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
106 changes: 106 additions & 0 deletions utils/calendrical_calculations/src/tests/julian_old_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// This file is part of ICU4X.
//
// The contents of this file implement algorithms from Calendrical Calculations
// by Reingold & Dershowitz, Cambridge University Press, 4th edition (2018),
// which have been released as Lisp code at <https://github.com/EdReingold/calendar-code2/>
// under the Apache-2.0 license. Accordingly, this file is released under
// the Apache License, Version 2.0 which can be found at the calendrical_calculations
// package root or at http://www.apache.org/licenses/LICENSE-2.0.

use crate::helpers::{i64_to_i32, I32CastError};
use crate::rata_die::RataDie;

// Julian epoch is equivalent to fixed_from_iso of December 30th of 0 year
// 1st Jan of 1st year Julian is equivalent to December 30th of 0th year of ISO year
const JULIAN_EPOCH: RataDie = RataDie::new(-1);

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1684-L1687>
#[inline(always)]
pub const fn is_leap_year(year: i32) -> bool {
year % 4 == 0
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1689-L1709>
pub const fn fixed_from_julian(year: i32, month: u8, day: u8) -> RataDie {
let mut fixed =
JULIAN_EPOCH.to_i64_date() - 1 + 365 * (year as i64 - 1) + (year as i64 - 1).div_euclid(4);
debug_assert!(month > 0 && month < 13, "Month should be in range 1..=12.");
fixed += day_of_year(year, month, day) as i64;
RataDie::new(fixed)
}

/// Lisp code reference: <https://github.com/EdReingold/calendar-code2/blob/1ee51ecfaae6f856b0d7de3e36e9042100b4f424/calendar.l#L1711-L1738>
pub fn julian_from_fixed(date: RataDie) -> Result<(i32, u8, u8), I32CastError> {
// incorrect for rata_data(i32::MIN, 1, 1)
let approx = (4 * date.to_i64_date() + 1464).div_euclid(1461);
let year = i64_to_i32(approx)?;

let prior_days = date
- fixed_from_julian(year, 1, 1)
- if is_leap_year(year) && date > fixed_from_julian(year, 2, 28) {
1
} else {
0
};
let adjusted_year = if prior_days >= 365 {
year.saturating_add(1)
} else {
year
};
let adjusted_prior_days = prior_days.rem_euclid(365);
debug_assert!((0..365).contains(&adjusted_prior_days));
let month = if adjusted_prior_days < 31 {
1
} else if adjusted_prior_days < 59 {
2
} else if adjusted_prior_days < 90 {
3
} else if adjusted_prior_days < 120 {
4
} else if adjusted_prior_days < 151 {
5
} else if adjusted_prior_days < 181 {
6
} else if adjusted_prior_days < 212 {
7
} else if adjusted_prior_days < 243 {
8
} else if adjusted_prior_days < 273 {
9
} else if adjusted_prior_days < 304 {
10
} else if adjusted_prior_days < 334 {
11
} else {
12
};
let day = (date - fixed_from_julian(adjusted_year, month, 1) + 1) as u8; // as days_in_month is < u8::MAX
debug_assert!(day <= 31, "Day assertion failed; date: {date:?}, adjusted_year: {adjusted_year}, prior_days: {prior_days}, month: {month}, day: {day}");

Ok((adjusted_year, month, day))
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

pub const fn day_of_year(year: i32, month: u8, day: u8) -> u16 {
let mut day_before_month = match month {
1 => 0,
2 => 31,
3 => 59,
4 => 90,
5 => 120,
6 => 151,
7 => 181,
8 => 212,
9 => 243,
10 => 273,
11 => 304,
12 => 334,
_ => 0,
};
// Only add one if the month is after February (month > 2), since leap days are added to the end of February
if month > 2 && is_leap_year(year) {
day_before_month += 1;
}
day_before_month + day as u16
}
21 changes: 21 additions & 0 deletions utils/calendrical_calculations/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// We also can compile parts of this file for bench purpose (to cmp algo perfs) => `#[cfg(test)]`

#[cfg(test)]
mod iso;
pub mod iso_old_algos;
pub mod iso_old_file;

#[cfg(test)]
mod julian;
pub mod julian_old_file;

#[cfg(test)]
pub mod helpful_consts {
use core::ops::RangeInclusive;

pub const N_YEAR_BOUND: i32 = 1234; // more than one cycle (400 years)
pub const MIN_YEAR_BOUND_RANGE: RangeInclusive<i32> = i32::MIN..=(i32::MIN + N_YEAR_BOUND);
pub const MAX_YEAR_BOUND_RANGE: RangeInclusive<i32> = (i32::MAX - N_YEAR_BOUND)..=i32::MAX;

pub const MONTH_DAYS: [u8; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
}