oxedyne/fe2o3/fe2o3_datime/src/time/leap_second.rs
16.3 KiB, 81 runs
created by r1870400018:8564, 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 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 2 | //! Anthropic Claude |
| 3 | |
| 4 | use oxedyne_fe2o3_core::prelude::*; |
| 5 | |
| 6 | use std::cmp::Ordering; |
| 7 | |
| 8 | /// Leap seconds are announced by the IERS and are not carried in timezone data, |
| 9 | /// so the table is kept separately from the zone rules. |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | pub struct LeapSecondEntry { |
| 12 | pub utc_timestamp: i64, // seconds since the Unix epoch |
| 13 | pub tai_utc_offset: i32, // seconds, after this leap second |
| 14 | pub description: String, |
| 15 | } |
| 16 | |
| 17 | impl PartialOrd for LeapSecondEntry { |
| 18 | fn partial_cmp(&self, other: &Self) -> Option<Ordering> { |
| 19 | Some(self.cmp(other)) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | impl Ord for LeapSecondEntry { |
| 24 | fn cmp(&self, other: &Self) -> Ordering { |
| 25 | self.utc_timestamp.cmp(&other.utc_timestamp) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /// Every leap second from 1972 onwards, which is what a UTC to TAI conversion |
| 30 | /// needs. |
| 31 | /// |
| 32 | /// ```ignore |
| 33 | /// use oxedyne_fe2o3_datime::time::LeapSecondTable; |
| 34 | /// |
| 35 | /// let table = LeapSecondTable::standard(); |
| 36 | /// let utc_timestamp = 1609459200; // 2021-01-01 00:00:00 UTC |
| 37 | /// let tai_utc_offset = table.tai_utc_offset_at(utc_timestamp); |
| 38 | /// println!("TAI-UTC offset: {} seconds", tai_utc_offset); |
| 39 | /// ``` |
| 40 | #[derive(Clone, Debug)] |
| 41 | pub struct LeapSecondTable { |
| 42 | entries: Vec<LeapSecondEntry>, // ascending by utc_timestamp |
| 43 | enabled: bool, |
| 44 | } |
| 45 | |
| 46 | impl LeapSecondTable { |
| 47 | pub fn new() -> Self { |
| 48 | Self { |
| 49 | entries: Vec::new(), |
| 50 | enabled: true, |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// Every leap second from 1972 to 2017, which is all of them so far. The table |
| 55 | /// needs extending whenever the IERS announces another. |
| 56 | pub fn standard() -> Self { |
| 57 | let mut table = Self::new(); |
| 58 | |
| 59 | // Historical leap seconds from 1972-2017 |
| 60 | // Source: IERS Bulletin C and NIST leap second data |
| 61 | table.add_entry(78796800, 10, "1972-07-01: First leap second"); |
| 62 | table.add_entry(94694400, 11, "1973-01-01: +1 second"); |
| 63 | table.add_entry(126230400, 12, "1974-01-01: +1 second"); |
| 64 | table.add_entry(157766400, 13, "1975-01-01: +1 second"); |
| 65 | table.add_entry(189302400, 14, "1976-01-01: +1 second"); |
| 66 | table.add_entry(220924800, 15, "1977-01-01: +1 second"); |
| 67 | table.add_entry(252460800, 16, "1978-01-01: +1 second"); |
| 68 | table.add_entry(283996800, 17, "1979-01-01: +1 second"); |
| 69 | table.add_entry(315532800, 18, "1980-01-01: +1 second"); |
| 70 | table.add_entry(362793600, 19, "1981-07-01: +1 second"); |
| 71 | table.add_entry(394329600, 20, "1982-07-01: +1 second"); |
| 72 | table.add_entry(425865600, 21, "1983-07-01: +1 second"); |
| 73 | table.add_entry(489024000, 22, "1985-07-01: +1 second"); |
| 74 | table.add_entry(567993600, 23, "1988-01-01: +1 second"); |
| 75 | table.add_entry(631152000, 24, "1990-01-01: +1 second"); |
| 76 | table.add_entry(662688000, 25, "1991-01-01: +1 second"); |
| 77 | table.add_entry(709948800, 26, "1992-07-01: +1 second"); |
| 78 | table.add_entry(741484800, 27, "1993-07-01: +1 second"); |
| 79 | table.add_entry(773020800, 28, "1994-07-01: +1 second"); |
| 80 | table.add_entry(820454400, 29, "1996-01-01: +1 second"); |
| 81 | table.add_entry(867715200, 30, "1997-07-01: +1 second"); |
| 82 | table.add_entry(915148800, 31, "1999-01-01: +1 second"); |
| 83 | table.add_entry(1136073600, 32, "2006-01-01: +1 second"); |
| 84 | table.add_entry(1230768000, 33, "2009-01-01: +1 second"); |
| 85 | table.add_entry(1341100800, 34, "2012-07-01: +1 second"); |
| 86 | table.add_entry(1435708800, 35, "2015-07-01: +1 second"); |
| 87 | table.add_entry(1483228800, 36, "2017-01-01: +1 second (most recent)"); |
| 88 | |
| 89 | table |
| 90 | } |
| 91 | |
| 92 | /// Every TAI-UTC offset then reads zero, so the two scales coincide. |
| 93 | pub fn disabled() -> Self { |
| 94 | Self { |
| 95 | entries: Vec::new(), |
| 96 | enabled: false, |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub fn add_entry(&mut self, utc_timestamp: i64, tai_utc_offset: i32, description: &str) { |
| 101 | self.entries.push(LeapSecondEntry { |
| 102 | utc_timestamp, |
| 103 | tai_utc_offset, |
| 104 | description: description.to_string(), |
| 105 | }); |
| 106 | |
| 107 | // Keep entries sorted by timestamp |
| 108 | self.entries.sort(); |
| 109 | } |
| 110 | |
| 111 | /// Seconds to add to UTC to reach TAI. Zero before 1972, or when handling is off. |
| 112 | pub fn tai_utc_offset_at(&self, utc_timestamp: i64) -> i32 { |
| 113 | if !self.enabled { |
| 114 | return 0; |
| 115 | } |
| 116 | |
| 117 | // Before 1972, there were no leap seconds (TAI-UTC was 10 seconds) |
| 118 | if utc_timestamp < 78796800 { // 1972-07-01 |
| 119 | return 10; // Initial TAI-UTC offset before leap seconds began |
| 120 | } |
| 121 | |
| 122 | // Find the most recent leap second entry before or at this timestamp |
| 123 | match self.entries.binary_search_by_key(&utc_timestamp, |entry| entry.utc_timestamp) { |
| 124 | Ok(index) => self.entries[index].tai_utc_offset, |
| 125 | Err(index) => { |
| 126 | if index == 0 { |
| 127 | 10 // Before first leap second |
| 128 | } else { |
| 129 | self.entries[index - 1].tai_utc_offset |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | pub fn utc_to_tai(&self, utc_timestamp: i64) -> i64 { |
| 136 | utc_timestamp + self.tai_utc_offset_at(utc_timestamp) as i64 |
| 137 | } |
| 138 | |
| 139 | /// Harder than the other direction: during a positive leap second, one UTC time |
| 140 | /// answers to two TAI times. |
| 141 | pub fn tai_to_utc(&self, tai_timestamp: i64) -> Outcome<i64> { |
| 142 | if !self.enabled { |
| 143 | return Ok(tai_timestamp); |
| 144 | } |
| 145 | |
| 146 | // Approximate UTC time (ignoring leap seconds) |
| 147 | let approx_utc = tai_timestamp - 36; // Rough estimate using latest offset |
| 148 | |
| 149 | // Find the correct offset by checking around the approximate time |
| 150 | let offset = self.tai_utc_offset_at(approx_utc); |
| 151 | let precise_utc = tai_timestamp - offset as i64; |
| 152 | |
| 153 | // Verify our calculation is correct |
| 154 | let verification_tai = self.utc_to_tai(precise_utc); |
| 155 | if verification_tai == tai_timestamp { |
| 156 | Ok(precise_utc) |
| 157 | } else { |
| 158 | // Handle edge cases around leap second boundaries |
| 159 | self.handle_leap_second_boundary(tai_timestamp, precise_utc) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | fn handle_leap_second_boundary(&self, tai_timestamp: i64, initial_utc: i64) -> Outcome<i64> { |
| 164 | // Check timestamps within ±2 seconds to handle leap second ambiguity |
| 165 | for offset in -2..=2 { |
| 166 | let test_utc = initial_utc + offset; |
| 167 | if self.utc_to_tai(test_utc) == tai_timestamp { |
| 168 | return Ok(test_utc); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | Err(err!("Cannot convert TAI timestamp {} to UTC: ambiguous leap second boundary", tai_timestamp; Invalid, Input)) |
| 173 | } |
| 174 | |
| 175 | pub fn is_enabled(&self) -> bool { |
| 176 | self.enabled |
| 177 | } |
| 178 | |
| 179 | pub fn set_enabled(&mut self, enabled: bool) { |
| 180 | self.enabled = enabled; |
| 181 | } |
| 182 | |
| 183 | pub fn leap_second_count(&self) -> usize { |
| 184 | self.entries.len() |
| 185 | } |
| 186 | |
| 187 | pub fn latest_leap_second(&self) -> Option<&LeapSecondEntry> { |
| 188 | self.entries.last() |
| 189 | } |
| 190 | |
| 191 | /// In chronological order. |
| 192 | pub fn all_leap_seconds(&self) -> &[LeapSecondEntry] { |
| 193 | &self.entries |
| 194 | } |
| 195 | |
| 196 | pub fn is_leap_second(&self, utc_timestamp: i64) -> bool { |
| 197 | if !self.enabled { |
| 198 | return false; |
| 199 | } |
| 200 | |
| 201 | self.entries.iter().any(|entry| entry.utc_timestamp == utc_timestamp) |
| 202 | } |
| 203 | |
| 204 | pub fn leap_second_at(&self, utc_timestamp: i64) -> Option<&LeapSecondEntry> { |
| 205 | if !self.enabled { |
| 206 | return None; |
| 207 | } |
| 208 | |
| 209 | self.entries.iter().find(|entry| entry.utc_timestamp == utc_timestamp) |
| 210 | } |
| 211 | |
| 212 | pub fn validate_leap_second(&self, year: i32, month: u8, day: u8, hour: u8, minute: u8) -> bool { |
| 213 | if !self.enabled || hour != 23 || minute != 59 { |
| 214 | return false; // Leap seconds only occur at 23:59:60 UTC |
| 215 | } |
| 216 | |
| 217 | // Convert date to UTC timestamp using proper calendar arithmetic |
| 218 | let utc_timestamp = match self.date_to_utc_timestamp(year, month, day, hour, minute, 60) { |
| 219 | Ok(ts) => ts, |
| 220 | Err(_) => return false, |
| 221 | }; |
| 222 | |
| 223 | self.is_leap_second(utc_timestamp) |
| 224 | } |
| 225 | |
| 226 | fn date_to_utc_timestamp(&self, year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Result<i64, &'static str> { |
| 227 | // Validate input parameters |
| 228 | if month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60 { |
| 229 | return Err("Invalid date/time parameters"); |
| 230 | } |
| 231 | |
| 232 | // Calculate days since Unix epoch using proper calendar algorithm |
| 233 | let days_since_epoch = ok!(self.calculate_days_since_epoch(year, month, day)); |
| 234 | |
| 235 | // Convert to seconds and add time components |
| 236 | let timestamp = days_since_epoch as i64 * 86400 + |
| 237 | hour as i64 * 3600 + |
| 238 | minute as i64 * 60 + |
| 239 | second as i64; |
| 240 | |
| 241 | Ok(timestamp) |
| 242 | } |
| 243 | |
| 244 | fn calculate_days_since_epoch(&self, year: i32, month: u8, day: u8) -> Result<i32, &'static str> { |
| 245 | // Validate month and day ranges |
| 246 | if month < 1 || month > 12 || day < 1 { |
| 247 | return Err("Invalid month or day"); |
| 248 | } |
| 249 | |
| 250 | // Check day validity for the given month/year |
| 251 | let days_in_month = self.days_in_month(year, month); |
| 252 | if day > days_in_month { |
| 253 | return Err("Day out of range for month"); |
| 254 | } |
| 255 | |
| 256 | // Use Julian day number algorithm for accurate calculation |
| 257 | // Convert to Julian day number first |
| 258 | let jdn = self.gregorian_to_jdn(year, month, day); |
| 259 | |
| 260 | // Unix epoch is January 1, 1970 = JDN 2440588 |
| 261 | let unix_epoch_jdn = 2440588i64; |
| 262 | let days_since_epoch = jdn - unix_epoch_jdn; |
| 263 | |
| 264 | // Check for reasonable range (avoid overflow) |
| 265 | if days_since_epoch < i32::MIN as i64 || days_since_epoch > i32::MAX as i64 { |
| 266 | return Err("Date too far from Unix epoch"); |
| 267 | } |
| 268 | |
| 269 | Ok(days_since_epoch as i32) |
| 270 | } |
| 271 | |
| 272 | fn gregorian_to_jdn(&self, year: i32, month: u8, day: u8) -> i64 { |
| 273 | let (y, m) = if month <= 2 { |
| 274 | (year - 1, month as i32 + 12) |
| 275 | } else { |
| 276 | (year, month as i32) |
| 277 | }; |
| 278 | |
| 279 | let a = y / 100; |
| 280 | let b = 2 - a + a / 4; |
| 281 | |
| 282 | let jdn = (365.25 * (y + 4716) as f64) as i64 + |
| 283 | (30.6001 * (m + 1) as f64) as i64 + |
| 284 | day as i64 + b as i64 - 1524; |
| 285 | |
| 286 | jdn |
| 287 | } |
| 288 | |
| 289 | fn days_in_month(&self, year: i32, month: u8) -> u8 { |
| 290 | match month { |
| 291 | 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, |
| 292 | 4 | 6 | 9 | 11 => 30, |
| 293 | 2 => if self.is_leap_year(year) { 29 } else { 28 }, |
| 294 | _ => 0, // Invalid month |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | fn is_leap_year(&self, year: i32) -> bool { |
| 299 | (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) |
| 300 | } |
| 301 | |
| 302 | pub fn statistics(&self) -> LeapSecondStatistics { |
| 303 | LeapSecondStatistics { |
| 304 | total_leap_seconds: self.entries.len(), |
| 305 | enabled: self.enabled, |
| 306 | first_leap_second: self.entries.first().map(|e| e.utc_timestamp), |
| 307 | latest_leap_second: self.entries.last().map(|e| e.utc_timestamp), |
| 308 | current_tai_utc_offset: self.entries.last().map(|e| e.tai_utc_offset).unwrap_or(10), |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | impl Default for LeapSecondTable { |
| 314 | fn default() -> Self { |
| 315 | Self::standard() |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | #[derive(Clone, Debug)] |
| 320 | pub struct LeapSecondStatistics { |
| 321 | pub total_leap_seconds: usize, |
| 322 | pub enabled: bool, |
| 323 | pub first_leap_second: Option<i64>, |
| 324 | pub latest_leap_second: Option<i64>, |
| 325 | pub current_tai_utc_offset: i32, // seconds |
| 326 | } |
| 327 | |
| 328 | #[derive(Clone, Debug)] |
| 329 | pub struct LeapSecondConfig { |
| 330 | pub enabled: bool, |
| 331 | pub allow_leap_second_parsing: bool, |
| 332 | pub validate_leap_seconds: bool, |
| 333 | pub custom_table: Option<LeapSecondTable>, // None uses the standard table |
| 334 | } |
| 335 | |
| 336 | impl Default for LeapSecondConfig { |
| 337 | fn default() -> Self { |
| 338 | Self { |
| 339 | enabled: true, |
| 340 | allow_leap_second_parsing: true, |
| 341 | validate_leap_seconds: true, |
| 342 | custom_table: None, |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | impl LeapSecondConfig { |
| 348 | pub fn disabled() -> Self { |
| 349 | Self { |
| 350 | enabled: false, |
| 351 | allow_leap_second_parsing: false, |
| 352 | validate_leap_seconds: false, |
| 353 | custom_table: None, |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | /// Second 60 parses, but is not checked against the table. |
| 358 | pub fn permissive() -> Self { |
| 359 | Self { |
| 360 | enabled: true, |
| 361 | allow_leap_second_parsing: true, |
| 362 | validate_leap_seconds: false, |
| 363 | custom_table: None, |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | pub fn get_table(&self) -> LeapSecondTable { |
| 368 | if !self.enabled { |
| 369 | LeapSecondTable::disabled() |
| 370 | } else if let Some(ref table) = self.custom_table { |
| 371 | table.clone() |
| 372 | } else { |
| 373 | LeapSecondTable::standard() |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | #[cfg(test)] |
| 379 | mod tests { |
| 380 | use super::*; |
| 381 | |
| 382 | #[test] |
| 383 | fn test_leap_second_table_creation() { |
| 384 | let table = LeapSecondTable::standard(); |
| 385 | assert!(table.is_enabled()); |
| 386 | assert!(table.leap_second_count() > 0); |
| 387 | |
| 388 | let disabled = LeapSecondTable::disabled(); |
| 389 | assert!(!disabled.is_enabled()); |
| 390 | assert_eq!(disabled.leap_second_count(), 0); |
| 391 | } |
| 392 | |
| 393 | #[test] |
| 394 | fn test_tai_utc_offset() { |
| 395 | let table = LeapSecondTable::standard(); |
| 396 | |
| 397 | // Before first leap second (1972-07-01) |
| 398 | assert_eq!(table.tai_utc_offset_at(0), 10); |
| 399 | |
| 400 | // After first leap second |
| 401 | assert_eq!(table.tai_utc_offset_at(78796800), 10); // Exactly at first leap second |
| 402 | assert_eq!(table.tai_utc_offset_at(78796801), 10); // Just after first leap second |
| 403 | |
| 404 | // After most recent leap second (2017-01-01) |
| 405 | assert_eq!(table.tai_utc_offset_at(1483228800), 36); // 2017-01-01 |
| 406 | assert_eq!(table.tai_utc_offset_at(1600000000), 36); // 2020 (no leap seconds since 2017) |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn test_utc_tai_conversion() -> Outcome<()> { |
| 411 | let table = LeapSecondTable::standard(); |
| 412 | |
| 413 | let utc_timestamp = 1483228800; // 2017-01-01 00:00:00 UTC |
| 414 | let tai_timestamp = table.utc_to_tai(utc_timestamp); |
| 415 | assert_eq!(tai_timestamp, utc_timestamp + 36); |
| 416 | |
| 417 | // Round trip conversion |
| 418 | let converted_utc = res!(table.tai_to_utc(tai_timestamp)); |
| 419 | assert_eq!(converted_utc, utc_timestamp); |
| 420 | Ok(()) |
| 421 | } |
| 422 | |
| 423 | #[test] |
| 424 | fn test_leap_second_detection() { |
| 425 | let table = LeapSecondTable::standard(); |
| 426 | |
| 427 | // 2017-01-01 leap second |
| 428 | assert!(table.is_leap_second(1483228800)); |
| 429 | |
| 430 | // Not a leap second |
| 431 | assert!(!table.is_leap_second(1483228801)); |
| 432 | |
| 433 | // The leap second the table records as "2017-01-01" was inserted at the |
| 434 | // end of the preceding day, as 2016-12-31T23:59:60Z, which is the instant |
| 435 | // 1483228800 the entry holds. The date carrying the sixtieth second is |
| 436 | // therefore 2016-12-31, not 2017-01-01. |
| 437 | assert!(table.validate_leap_second(2016, 12, 31, 23, 59)); |
| 438 | assert!(!table.validate_leap_second(2017, 1, 1, 23, 59)); // a day too late |
| 439 | assert!(!table.validate_leap_second(2017, 1, 2, 23, 59)); // not a leap second date |
| 440 | assert!(!table.validate_leap_second(2016, 12, 31, 12, 0)); // wrong time |
| 441 | } |
| 442 | |
| 443 | #[test] |
| 444 | fn test_leap_second_config() { |
| 445 | let config = LeapSecondConfig::default(); |
| 446 | assert!(config.enabled); |
| 447 | assert!(config.allow_leap_second_parsing); |
| 448 | |
| 449 | let disabled = LeapSecondConfig::disabled(); |
| 450 | assert!(!disabled.enabled); |
| 451 | assert!(!disabled.allow_leap_second_parsing); |
| 452 | |
| 453 | let table = disabled.get_table(); |
| 454 | assert!(!table.is_enabled()); |
| 455 | } |
| 456 | |
| 457 | #[test] |
| 458 | fn test_leap_second_statistics() { |
| 459 | let table = LeapSecondTable::standard(); |
| 460 | let stats = table.statistics(); |
| 461 | |
| 462 | assert!(stats.total_leap_seconds > 0); |
| 463 | assert!(stats.enabled); |
| 464 | assert!(stats.first_leap_second.is_some()); |
| 465 | assert!(stats.latest_leap_second.is_some()); |
| 466 | assert_eq!(stats.current_tai_utc_offset, 36); // As of 2017 |
| 467 | } |
| 468 | |
| 469 | #[test] |
| 470 | fn test_leap_second_boundary_handling() -> Outcome<()> { |
| 471 | let table = LeapSecondTable::standard(); |
| 472 | |
| 473 | // Test conversion around leap second boundaries |
| 474 | let leap_second_utc = 1483228800; // 2017-01-01 leap second |
| 475 | let tai_at_leap = table.utc_to_tai(leap_second_utc); |
| 476 | |
| 477 | // Converting back should work |
| 478 | let converted_back = res!(table.tai_to_utc(tai_at_leap)); |
| 479 | assert_eq!(converted_back, leap_second_utc); |
| 480 | Ok(()) |
| 481 | } |
| 482 | } |