Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_datime/src/calendar/islamic.rs

14.0 KiB, 59 runs

created by r1870400018:8395, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Islamic (Hijri) calendar conversion algorithms.
2//!
3//! This module provides accurate conversion algorithms between the Islamic
4//! calendar and the Gregorian calendar using established astronomical algorithms.
5//! The Islamic calendar is a lunar calendar with 12 months that can have either
6//! 29 or 30 days based on lunar observations.
7//!
8//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
9//! Anthropic Claude
10
11use oxedyne_fe2o3_core::prelude::*;
12
13/// The epoch is 16 July 622 CE Julian, which is 19 July 622 CE in the proleptic
14/// Gregorian calendar: the first day of Muharram in year 1 AH.
15pub struct IslamicCalendar;
16
17impl IslamicCalendar {
18 // The civil (Friday) epoch, 16 July 622 Julian. The astronomical variant is
19 // a day earlier at 1948439; this crate uses the civil one, as calendar.rs
20 // does, and the two must not drift apart again.
21 const ISLAMIC_EPOCH_JDN: i64 = 1948440;
22 const ISLAMIC_YEAR_LENGTH: f64 = 354.36708; // mean days
23 const ISLAMIC_MONTH_LENGTH: f64 = 29.530589; // mean days
24 const ISLAMIC_MONTHS: [&'static str; 12] = [
25 "Muharram", // 1
26 "Safar", // 2
27 "Rabi' al-awwal", // 3
28 "Rabi' al-thani", // 4
29 "Jumada al-awwal", // 5
30 "Jumada al-thani", // 6
31 "Rajab", // 7
32 "Sha'ban", // 8
33 "Ramadan", // 9
34 "Shawwal", // 10
35 "Dhu al-Qi'dah", // 11
36 "Dhu al-Hijjah", // 12
37 ];
38
39 /// The algorithm from Calendrical Calculations, Reingold and Dershowitz.
40 pub fn gregorian_to_islamic(gregorian_year: i32, gregorian_month: u8, gregorian_day: u8) -> Outcome<(i32, u8, u8)> {
41 // Convert Gregorian date to Julian Day Number
42 let jdn = res!(Self::gregorian_to_jdn(gregorian_year, gregorian_month, gregorian_day));
43
44 // Convert JDN to Islamic date
45 Self::jdn_to_islamic(jdn)
46 }
47
48 pub fn islamic_to_gregorian(islamic_year: i32, islamic_month: u8, islamic_day: u8) -> Outcome<(i32, u8, u8)> {
49 // Convert Islamic date to Julian Day Number
50 let jdn = res!(Self::islamic_to_jdn(islamic_year, islamic_month, islamic_day));
51
52 // Convert JDN to Gregorian date
53 Self::jdn_to_gregorian(jdn)
54 }
55
56 fn islamic_to_jdn(islamic_year: i32, islamic_month: u8, islamic_day: u8) -> Outcome<i64> {
57 if islamic_month < 1 || islamic_month > 12 {
58 return Err(err!("Islamic month must be between 1 and 12, got {}", islamic_month; Invalid, Input));
59 }
60
61 if islamic_day < 1 || islamic_day > 30 {
62 return Err(err!("Islamic day must be between 1 and 30, got {}", islamic_day; Invalid, Input));
63 }
64
65 if islamic_year < 1 {
66 return Err(err!("Islamic year must be 1 or later, got {}", islamic_year; Invalid, Input));
67 }
68
69 // Days in the complete years before this one, counted by the thirty-year
70 // cycle the calendar is actually defined on: 11 leap years of 355 days
71 // and 19 common ones of 354, which is 10,631 days a cycle. Counting them
72 // off a mean year length instead drifts by a day at a time, and
73 // disagrees with is_islamic_leap_year, which decides the length of Dhu
74 // al-Hijjah a few lines below from the same cycle.
75 let years_before = (islamic_year - 1) as i64;
76 let mut days_for_years = (years_before / 30) * (30 * 354 + 11);
77 for year in 1..=(years_before % 30) {
78 days_for_years += if Self::is_islamic_leap_year(year as i32) { 355 } else { 354 };
79 }
80
81 // Months alternate 30 and 29 days from Muharram, so the months before
82 // this one hold 29 each plus one more for every odd-numbered one.
83 let month = islamic_month as i64;
84 let days_for_months = 29 * (month - 1) + month / 2;
85
86 let total_days = days_for_years + days_for_months + (islamic_day as i64 - 1);
87
88 Ok(Self::ISLAMIC_EPOCH_JDN + total_days)
89 }
90
91 fn jdn_to_islamic(jdn: i64) -> Outcome<(i32, u8, u8)> {
92 // Days since Islamic epoch
93 let days_since_epoch = jdn - Self::ISLAMIC_EPOCH_JDN;
94
95 if days_since_epoch < 0 {
96 return Err(err!("Date {} is before Islamic epoch", jdn; Invalid, Input));
97 }
98
99 // Estimate the year
100 let estimated_year = ((days_since_epoch as f64) / Self::ISLAMIC_YEAR_LENGTH).floor() as i32 + 1;
101
102 // Find the correct year by iterating around the estimate
103 let mut year = estimated_year;
104 loop {
105 let year_start_jdn = res!(Self::islamic_year_start_jdn(year));
106 let next_year_start_jdn = res!(Self::islamic_year_start_jdn(year + 1));
107
108 if jdn >= year_start_jdn && jdn < next_year_start_jdn {
109 break;
110 } else if jdn < year_start_jdn {
111 year -= 1;
112 } else {
113 year += 1;
114 }
115
116 // Prevent infinite loops
117 if (year - estimated_year).abs() > 2 {
118 return Err(err!("Failed to find Islamic year for JDN {}", jdn; Invalid, Input));
119 }
120 }
121
122 // Find the month and day within the year
123 let year_start_jdn = res!(Self::islamic_year_start_jdn(year));
124 let days_in_year = jdn - year_start_jdn;
125
126 // Estimate the month
127 let estimated_month = ((days_in_year as f64) / Self::ISLAMIC_MONTH_LENGTH).floor() as u8 + 1;
128 let estimated_month = estimated_month.min(12).max(1);
129
130 // Find the correct month
131 let mut month = estimated_month;
132 loop {
133 let month_start_jdn = res!(Self::islamic_month_start_jdn(year, month));
134 let next_month_start_jdn = if month == 12 {
135 res!(Self::islamic_year_start_jdn(year + 1))
136 } else {
137 res!(Self::islamic_month_start_jdn(year, month + 1))
138 };
139
140 if jdn >= month_start_jdn && jdn < next_month_start_jdn {
141 break;
142 } else if jdn < month_start_jdn && month > 1 {
143 month -= 1;
144 } else if jdn >= next_month_start_jdn && month < 12 {
145 month += 1;
146 } else {
147 return Err(err!("Failed to find Islamic month for JDN {} in year {}", jdn, year; Invalid, Input));
148 }
149 }
150
151 // Calculate the day
152 let month_start_jdn = res!(Self::islamic_month_start_jdn(year, month));
153 let day = (jdn - month_start_jdn + 1) as u8;
154
155 Ok((year, month, day))
156 }
157
158 fn islamic_year_start_jdn(islamic_year: i32) -> Outcome<i64> {
159 Self::islamic_to_jdn(islamic_year, 1, 1)
160 }
161
162 fn islamic_month_start_jdn(islamic_year: i32, islamic_month: u8) -> Outcome<i64> {
163 Self::islamic_to_jdn(islamic_year, islamic_month, 1)
164 }
165
166 /// Months alternate 30 and 29 days, adjusted for leap years in the 30-year cycle.
167 pub fn days_in_islamic_month(islamic_year: i32, islamic_month: u8) -> Outcome<u8> {
168 if islamic_month < 1 || islamic_month > 12 {
169 return Err(err!("Islamic month must be between 1 and 12, got {}", islamic_month; Invalid, Input));
170 }
171
172 // Months 1, 3, 5, 7, 9, 11 have 30 days
173 // Months 2, 4, 6, 8, 10 have 29 days
174 // Month 12 has 29 days in normal years, 30 in leap years
175
176 let base_days = if islamic_month % 2 == 1 {
177 30 // Odd months
178 } else if islamic_month < 12 {
179 29 // Even months except Dhu al-Hijjah
180 } else {
181 // Dhu al-Hijjah (month 12)
182 if Self::is_islamic_leap_year(islamic_year) {
183 30
184 } else {
185 29
186 }
187 };
188
189 Ok(base_days)
190 }
191
192 /// Eleven years of each 30-year cycle are leap years: 2, 5, 7, 10, 13, 16, 18, 21,
193 /// 24, 26 and 29.
194 pub fn is_islamic_leap_year(islamic_year: i32) -> bool {
195 let cycle_year = ((islamic_year - 1) % 30) + 1;
196 matches!(cycle_year, 2 | 5 | 7 | 10 | 13 | 16 | 18 | 21 | 24 | 26 | 29)
197 }
198
199 pub fn islamic_month_name(islamic_month: u8) -> Outcome<&'static str> {
200 if islamic_month < 1 || islamic_month > 12 {
201 return Err(err!("Islamic month must be between 1 and 12, got {}", islamic_month; Invalid, Input));
202 }
203
204 Ok(Self::ISLAMIC_MONTHS[(islamic_month - 1) as usize])
205 }
206
207 fn gregorian_to_jdn(year: i32, month: u8, day: u8) -> Outcome<i64> {
208 let m = month as i32;
209 let (y, m) = if m <= 2 {
210 (year - 1, m + 12)
211 } else {
212 (year, m)
213 };
214
215 let a = y / 100;
216 let b = 2 - a + a / 4;
217
218 let jdn = (365.25 * (y + 4716) as f64) as i64 +
219 (30.6001 * (m + 1) as f64) as i64 +
220 day as i64 + b as i64 - 1524;
221
222 Ok(jdn)
223 }
224
225 fn jdn_to_gregorian(jdn: i64) -> Outcome<(i32, u8, u8)> {
226 let a = jdn + 32044;
227 let b = (4 * a + 3) / 146097;
228 let c = a - (146097 * b) / 4;
229 let d = (4 * c + 3) / 1461;
230 let e = c - (1461 * d) / 4;
231 let m = (5 * e + 2) / 153;
232
233 let day = (e - (153 * m + 2) / 5 + 1) as u8;
234 let month_num = (m + 3 - 12 * (m / 10)) as u8;
235 let year = (100 * b + d - 4800 + m / 10) as i32;
236
237 Ok((year, month_num, day))
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_islamic_leap_years() {
247 // Test known leap years in the 30-year cycle
248 assert!(IslamicCalendar::is_islamic_leap_year(2));
249 assert!(IslamicCalendar::is_islamic_leap_year(5));
250 assert!(IslamicCalendar::is_islamic_leap_year(7));
251 assert!(IslamicCalendar::is_islamic_leap_year(10));
252 assert!(IslamicCalendar::is_islamic_leap_year(13));
253 assert!(IslamicCalendar::is_islamic_leap_year(16));
254 assert!(IslamicCalendar::is_islamic_leap_year(18));
255 assert!(IslamicCalendar::is_islamic_leap_year(21));
256 assert!(IslamicCalendar::is_islamic_leap_year(24));
257 assert!(IslamicCalendar::is_islamic_leap_year(26));
258 assert!(IslamicCalendar::is_islamic_leap_year(29));
259
260 // Test non-leap years
261 assert!(!IslamicCalendar::is_islamic_leap_year(1));
262 assert!(!IslamicCalendar::is_islamic_leap_year(3));
263 assert!(!IslamicCalendar::is_islamic_leap_year(4));
264 assert!(!IslamicCalendar::is_islamic_leap_year(30));
265
266 // Test leap years in second cycle (years 31-60)
267 assert!(IslamicCalendar::is_islamic_leap_year(32)); // 2 + 30
268 assert!(IslamicCalendar::is_islamic_leap_year(35)); // 5 + 30
269 }
270
271 #[test]
272 fn test_islamic_month_names() -> Outcome<()> {
273 assert_eq!(res!(IslamicCalendar::islamic_month_name(1)), "Muharram");
274 assert_eq!(res!(IslamicCalendar::islamic_month_name(9)), "Ramadan");
275 assert_eq!(res!(IslamicCalendar::islamic_month_name(12)), "Dhu al-Hijjah");
276
277 // Test invalid month
278 assert!(IslamicCalendar::islamic_month_name(0).is_err());
279 assert!(IslamicCalendar::islamic_month_name(13).is_err());
280
281 Ok(())
282 }
283
284 #[test]
285 fn test_days_in_islamic_month() -> Outcome<()> {
286 // Test odd months (30 days)
287 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1445, 1)), 30); // Muharram
288 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1445, 3)), 30); // Rabi' al-awwal
289
290 // Test even months (29 days)
291 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1445, 2)), 29); // Safar
292 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1445, 4)), 29); // Rabi' al-thani
293
294 // Dhu al-Hijjah takes a thirtieth day in a leap year of the cycle. The
295 // cycle position is ((year - 1) % 30) + 1, so 1445 sits at 5 and is a
296 // leap year, and 1446 sits at 6 and is not -- the other way round from
297 // how this read before, which had 1446 at 26.
298 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1445, 12)), 30);
299 assert_eq!(res!(IslamicCalendar::days_in_islamic_month(1446, 12)), 29);
300
301 Ok(())
302 }
303
304 #[test]
305 fn test_islamic_gregorian_conversion() -> Outcome<()> {
306 // Test known conversion: Islamic New Year 1445 AH
307 // Should be approximately July 19, 2023 CE
308 let (greg_year, greg_month, greg_day) = res!(IslamicCalendar::islamic_to_gregorian(1445, 1, 1));
309
310 // Allow some variation due to astronomical calculations
311 assert!(greg_year == 2023);
312 assert!(greg_month == 7);
313 assert!(greg_day >= 18 && greg_day <= 20);
314
315 // Test round-trip conversion
316 let (islamic_year, islamic_month, islamic_day) = res!(IslamicCalendar::gregorian_to_islamic(greg_year, greg_month, greg_day));
317
318 // Should be close to original date
319 assert!(islamic_year == 1445);
320 assert!(islamic_month == 1);
321 assert!(islamic_day >= 1 && islamic_day <= 3); // Allow some variation
322
323 Ok(())
324 }
325
326 #[test]
327 fn test_islamic_epoch() -> Outcome<()> {
328 let (greg_year, greg_month, greg_day) = res!(IslamicCalendar::islamic_to_gregorian(1, 1, 1));
329
330 // 1 Muharram 1 AH is 16 July 622 in the Julian calendar, which is where
331 // the epoch is always quoted from. This function answers in the proleptic
332 // Gregorian calendar, and the two are three days apart in the seventh
333 // century, so the answer is the 19th. It is exact, not approximate: both
334 // are the same instant, JDN 1948440.
335 assert_eq!((greg_year, greg_month, greg_day), (622, 7, 19));
336
337 Ok(())
338 }
339
340 #[test]
341 fn test_gregorian_to_islamic_recent_dates() -> Outcome<()> {
342 // Test some recent dates for approximate accuracy
343
344 // January 1, 2024 should be around Jumada al-thani 1445
345 let (islamic_year, islamic_month, _islamic_day) = res!(IslamicCalendar::gregorian_to_islamic(2024, 1, 1));
346 assert_eq!(islamic_year, 1445);
347 assert!(islamic_month >= 5 && islamic_month <= 7); // Allow some variation
348
349 Ok(())
350 }
351}