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 | |
| 11 | use 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. |
| 15 | pub struct IslamicCalendar; |
| 16 | |
| 17 | impl 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)] |
| 242 | mod 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 | } |