/home/a220/proj/radnelac/src/calendar/iso.rs
Line | Count | Source |
1 | | // This Source Code Form is subject to the terms of the Mozilla Public |
2 | | // License, v. 2.0. If a copy of the MPL was not distributed with this |
3 | | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
4 | | |
5 | | use crate::calendar::gregorian::Gregorian; |
6 | | use crate::calendar::prelude::CommonDate; |
7 | | use crate::calendar::prelude::CommonWeekOfYear; |
8 | | use crate::calendar::prelude::Quarter; |
9 | | use crate::calendar::prelude::ToFromCommonDate; |
10 | | use crate::calendar::prelude::ToFromOrdinalDate; |
11 | | use crate::calendar::AllowYearZero; |
12 | | use crate::calendar::CalendarMoment; |
13 | | use crate::calendar::HasLeapYears; |
14 | | use crate::clock::TimeOfDay; |
15 | | use crate::common::math::TermNum; |
16 | | use crate::day_count::BoundedDayCount; |
17 | | use crate::day_count::CalculatedBounds; |
18 | | use crate::day_count::Epoch; |
19 | | use crate::day_count::Fixed; |
20 | | use crate::day_count::FromFixed; |
21 | | use crate::day_count::ToFixed; |
22 | | use crate::day_cycle::Weekday; |
23 | | use crate::CalendarError; |
24 | | use num_traits::FromPrimitive; |
25 | | use std::cmp::Ordering; |
26 | | use std::num::NonZero; |
27 | | |
28 | | /// Represents a date in the ISO-8601 week-date calendar |
29 | | /// |
30 | | /// This is essentially an alternative naming system for Gregorian dates. |
31 | | /// |
32 | | /// Despite being derived from the Gregorian calendar, **the ISO-8601 has a different year |
33 | | /// start and year end than the Gregorian.** If the Gregorian year X ends in the middle of |
34 | | /// the ISO week, the next days may be in Gregorian year X+1 and ISO year X. |
35 | | /// |
36 | | /// ## Further reading |
37 | | /// + [Wikipedia](https://en.wikipedia.org/wiki/ISO_week_date) |
38 | | /// + [Rachel by the Bay](https://rachelbythebay.com/w/2018/04/20/iso/) |
39 | | /// + describes the confusion of intermingling documentation for ISO and Gregorian dates |
40 | | #[derive(Debug, PartialEq, Clone, Copy)] |
41 | | pub struct ISO { |
42 | | year: i32, |
43 | | week: NonZero<u8>, |
44 | | day: Weekday, |
45 | | } |
46 | | |
47 | | impl ISO { |
48 | | /// Attempt to create a new ISO week date |
49 | 23.8k | pub fn try_new(year: i32, week: u8, day: Weekday) -> Result<Self, CalendarError> { |
50 | 23.8k | if week < 1 || week > 53 || (week == 53 && !Self::is_leap(year)66 ) { |
51 | | //This if statement is structured specifically to minimize calls to Self::is_leap. |
52 | | //Self::is_leap calls Gregorian calendar functions which may exceed the effective |
53 | | //bounds. |
54 | 0 | return Err(CalendarError::InvalidWeek); |
55 | 23.8k | } |
56 | 23.8k | Ok(ISO { |
57 | 23.8k | year: year, |
58 | 23.8k | week: NonZero::<u8>::new(week).expect("Checked in if"), |
59 | 23.8k | day: day, |
60 | 23.8k | }) |
61 | 23.8k | } |
62 | | |
63 | 304 | pub fn year(self) -> i32 { |
64 | 304 | self.year |
65 | 304 | } |
66 | | |
67 | 6.40k | pub fn week(self) -> NonZero<u8> { |
68 | 6.40k | self.week |
69 | 6.40k | } |
70 | | |
71 | | /// Note that the numeric values of the Weekday enum are not consistent with ISO-8601. |
72 | | /// Use day_num for the numeric day number. |
73 | 256 | pub fn day(self) -> Weekday { |
74 | 256 | self.day |
75 | 256 | } |
76 | | |
77 | | /// Represents Sunday as 7 instead of 0, as required by ISO-8601. |
78 | 0 | pub fn day_num(self) -> u8 { |
79 | 0 | (self.day as u8).adjusted_remainder(7) |
80 | 0 | } |
81 | | |
82 | 15.9k | pub fn new_year(year: i32) -> Self { |
83 | 15.9k | ISO::try_new(year, 1, Weekday::Monday).expect("Week 1 known to be valid") |
84 | 15.9k | } |
85 | | } |
86 | | |
87 | | impl PartialOrd for ISO { |
88 | 2.05k | fn partial_cmp(&self, other: &Self) -> Option<Ordering> { |
89 | 2.05k | if self == other { |
90 | 12 | Some(Ordering::Equal) |
91 | 2.04k | } else if self.year != other.year { |
92 | 1.17k | self.year.partial_cmp(&other.year) |
93 | 865 | } else if self.week != other.week { |
94 | 836 | self.week.partial_cmp(&other.week) |
95 | | } else { |
96 | 29 | let self_day = (self.day as i64).adjusted_remainder(7); |
97 | 29 | let other_day = (other.day as i64).adjusted_remainder(7); |
98 | 29 | self_day.partial_cmp(&other_day) |
99 | | } |
100 | 2.05k | } |
101 | | } |
102 | | |
103 | | impl AllowYearZero for ISO {} |
104 | | |
105 | | impl CalculatedBounds for ISO {} |
106 | | |
107 | | impl Epoch for ISO { |
108 | 0 | fn epoch() -> Fixed { |
109 | 0 | Gregorian::epoch() |
110 | 0 | } |
111 | | } |
112 | | |
113 | | impl HasLeapYears for ISO { |
114 | 114 | fn is_leap(i_year: i32) -> bool { |
115 | | //LISTING 5.3 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.) |
116 | 114 | let jan1 = Gregorian::try_year_start(i_year) |
117 | 114 | .expect("Year known to be valid") |
118 | 114 | .convert::<Weekday>(); |
119 | 114 | let dec31 = Gregorian::try_year_end(i_year) |
120 | 114 | .expect("Year known to be valid") |
121 | 114 | .convert::<Weekday>(); |
122 | 114 | jan1 == Weekday::Thursday || dec31 == Weekday::Thursday13 |
123 | 114 | } |
124 | | } |
125 | | |
126 | | impl FromFixed for ISO { |
127 | 7.95k | fn from_fixed(fixed_date: Fixed) -> ISO { |
128 | | //LISTING 5.2 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.) |
129 | 7.95k | let date = fixed_date.get_day_i(); |
130 | 7.95k | let approx = Gregorian::ordinal_from_fixed(Fixed::cast_new(date - 3)).year; |
131 | 7.95k | let next = ISO::new_year(approx + 1).to_fixed().get_day_i(); |
132 | 7.95k | let year = if date >= next { approx + 1170 } else { approx7.78k }; |
133 | 7.95k | let current = ISO::new_year(year).to_fixed().get_day_i(); |
134 | 7.95k | let week = (date - current).div_euclid(7) + 1; |
135 | 7.95k | debug_assert!(week < 55 && week > 0); |
136 | | //Calendrical Calculations stores "day" as 7 for Sunday, as per ISO. |
137 | | //However since we have an unambiguous enum, we can save such details for |
138 | | //functions that need it. We also adjust "to_fixed_unchecked" |
139 | 7.95k | let day = Weekday::from_u8(date.modulus(7) as u8).expect("In range due to modulus."); |
140 | 7.95k | ISO::try_new(year, week as u8, day).expect("Week known to be valid") |
141 | 7.95k | } |
142 | | } |
143 | | |
144 | | impl ToFixed for ISO { |
145 | 16.1k | fn to_fixed(self) -> Fixed { |
146 | | //LISTING 5.1 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.) |
147 | 16.1k | let g = CommonDate::new(self.year - 1, 12, 28); |
148 | 16.1k | let w = NonZero::<i16>::from(self.week); |
149 | | //Calendrical Calculations stores "day" as 7 for Sunday, as per ISO. |
150 | | //However since we have an unambiguous enum, we can save such details for |
151 | | //functions that need it. We also adjust "from_fixed_unchecked" |
152 | 16.1k | let day_i = (self.day as i64).adjusted_remainder(7); |
153 | 16.1k | let result = Gregorian::try_from_common_date(g) |
154 | 16.1k | .expect("month 12, day 28 is always valid for Gregorian") |
155 | 16.1k | .nth_kday(w, Weekday::Sunday) |
156 | 16.1k | .get_day_i() |
157 | 16.1k | + day_i; |
158 | 16.1k | Fixed::cast_new(result) |
159 | 16.1k | } |
160 | | } |
161 | | |
162 | | impl Quarter for ISO { |
163 | 512 | fn quarter(self) -> NonZero<u8> { |
164 | 512 | NonZero::new(((self.week().get() - 1) / 14) + 1).expect("(m - 1)/14 > -1") |
165 | 512 | } |
166 | | } |
167 | | |
168 | | /// Represents a date *and time* in the ISO Calendar |
169 | | pub type ISOMoment = CalendarMoment<ISO>; |
170 | | |
171 | | impl ISOMoment { |
172 | 0 | pub fn year(self) -> i32 { |
173 | 0 | self.date().year() |
174 | 0 | } |
175 | | |
176 | 768 | pub fn week(self) -> NonZero<u8> { |
177 | 768 | self.date().week() |
178 | 768 | } |
179 | | |
180 | | /// Note that the numeric values of the Weekday enum are not consistent with ISO-8601. |
181 | | /// Use day_num for the numeric day number. |
182 | 0 | pub fn day(self) -> Weekday { |
183 | 0 | self.date().day() |
184 | 0 | } |
185 | | |
186 | | /// Represents Sunday as 7 instead of 0, as required by ISO-8601. |
187 | 0 | pub fn day_num(self) -> u8 { |
188 | 0 | self.date().day_num() |
189 | 0 | } |
190 | | |
191 | 0 | pub fn new_year(year: i32) -> Self { |
192 | 0 | ISOMoment::new(ISO::new_year(year), TimeOfDay::midnight()) |
193 | 0 | } |
194 | | } |
195 | | |
196 | | #[cfg(test)] |
197 | | mod tests { |
198 | | use super::*; |
199 | | use crate::calendar::prelude::HasLeapYears; |
200 | | use crate::calendar::prelude::ToFromCommonDate; |
201 | | use crate::day_count::FIXED_MAX; |
202 | | use proptest::proptest; |
203 | | const MAX_YEARS: i32 = (FIXED_MAX / 365.25) as i32; |
204 | | |
205 | | #[test] |
206 | 1 | fn week_of_impl() { |
207 | 1 | let g = Gregorian::try_from_common_date(CommonDate::new(2025, 5, 15)) |
208 | 1 | .unwrap() |
209 | 1 | .to_fixed(); |
210 | 1 | let i = ISO::from_fixed(g); |
211 | 1 | assert_eq!(i.week().get(), 20); |
212 | 1 | } |
213 | | |
214 | | #[test] |
215 | 1 | fn epoch() { |
216 | 1 | let i0 = ISO::from_fixed(Fixed::cast_new(0)); |
217 | 1 | let i1 = ISO::from_fixed(Fixed::cast_new(-1)); |
218 | 1 | assert!(i0 > i1, "i0: {:?}, i1: {:?}"0 , i0, i1); |
219 | 1 | } |
220 | | |
221 | | proptest! { |
222 | | #[test] |
223 | | fn first_week(year in -MAX_YEARS..MAX_YEARS) { |
224 | | // https://en.wikipedia.org/wiki/ISO_week_date |
225 | | // > If 1 January is on a Monday, Tuesday, Wednesday or Thursday, it is in W01. |
226 | | // > If it is on a Friday, it is part of W53 of the previous year. If it is on a |
227 | | // > Saturday, it is part of the last week of the previous year which is numbered |
228 | | // > W52 in a common year and W53 in a leap year. If it is on a Sunday, it is part |
229 | | // > of W52 of the previous year. |
230 | | let g = Gregorian::try_from_common_date(CommonDate { |
231 | | year, |
232 | | month: 1, |
233 | | day: 1, |
234 | | }).unwrap(); |
235 | | let f = g.to_fixed(); |
236 | | let w = Weekday::from_fixed(f); |
237 | | let i = ISO::from_fixed(f); |
238 | | let expected_week: u8 = match w { |
239 | | Weekday::Monday => 1, |
240 | | Weekday::Tuesday => 1, |
241 | | Weekday::Wednesday => 1, |
242 | | Weekday::Thursday => 1, |
243 | | Weekday::Friday => 53, |
244 | | Weekday::Saturday => if Gregorian::is_leap(year - 1) {53} else {52}, |
245 | | Weekday::Sunday => 52, |
246 | | }; |
247 | | let expected_year: i32 = if expected_week == 1 { year } else { year - 1 }; |
248 | | assert_eq!(i.day(), w); |
249 | | assert_eq!(i.week().get(), expected_week); |
250 | | assert_eq!(i.year(), expected_year); |
251 | | if expected_week == 53 { |
252 | | assert!(ISO::is_leap(i.year())); |
253 | | } |
254 | | } |
255 | | |
256 | | #[test] |
257 | | fn fixed_week_numbers(y1 in -MAX_YEARS..MAX_YEARS, y2 in -MAX_YEARS..MAX_YEARS) { |
258 | | // https://en.wikipedia.org/wiki/ISO_week_date |
259 | | // > For all years, 8 days have a fixed ISO week number |
260 | | // > (between W01 and W08) in January and February |
261 | | // Month Days Weeks |
262 | | // January 04 11 18 25 W01 – W04 |
263 | | // February 01 08 15 22 29 W05 – W09 |
264 | | let targets = [ |
265 | | (1, 4), (1, 11), (1, 18), (1, 25), |
266 | | (2, 1), (2, 8), (2, 15), (2, 22), |
267 | | ]; |
268 | | for target in targets { |
269 | | let g1 = Gregorian::try_from_common_date(CommonDate { |
270 | | year: y1, |
271 | | month: target.0, |
272 | | day: target.1, |
273 | | }).unwrap(); |
274 | | let g2 = Gregorian::try_from_common_date(CommonDate { |
275 | | year: y2, |
276 | | month: target.0, |
277 | | day: target.1, |
278 | | }).unwrap(); |
279 | | assert_eq!(g1.convert::<ISO>().week(), g2.convert::<ISO>().week()); |
280 | | } |
281 | | } |
282 | | } |
283 | | } |