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