Files
TerminalBasic/crates/tb-runtime/src/datetime.rs

301 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Datums- und Zeitfunktionen der Laufzeitbibliothek.
//!
//! Serielle Datumswerte folgen der Zählweise des Vorbilds: der ganzzahlige
//! Anteil zählt Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900), der
//! Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag.
//!
//! Die Zeitzone kommt von außen: [`utc_versatz`] ist der **einzige** Punkt,
//! an dem dieses Modul eine Bibliothek befragt (Entscheidung 2026-09-04,
//! design.md D1/D2). Die Kalenderrechnung darunter ist Eigenimplementierung
//! und sieht die Bibliothek nie — zwei Kalendermodelle nebeneinander wären
//! eine dauerhafte Fehlerquelle.
/// Tage zwischen dem 1.1.1970 (Unix) und dem 30.12.1899 (Serie 0).
const EPOCH_OFFSET_TAGE: i64 = 25_569;
/// Tage → (Jahr, Monat, Tag) ab Unix-Epoche (Howard Hinnant).
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(y + i64::from(m <= 2), m, d)
}
/// (Jahr, Monat, Tag) → Tage ab Unix-Epoche.
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = y - i64::from(m <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
let doy = (153 * mp + 2) / 5 + u64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
/// Gültige Kalenderangabe? (Monat 112, Tag im Monat.)
pub fn gueltig(y: i64, m: i64, d: i64) -> bool {
if !(1..=12).contains(&m) || d < 1 {
return false;
}
let tage = tage_im_monat(y, m as u32);
d <= tage as i64
}
fn tage_im_monat(y: i64, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if schaltjahr(y) => 29,
2 => 28,
_ => 0,
}
}
fn schaltjahr(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
/// Serieller Wert aus Jahr/Monat/Tag.
pub fn serial_aus_ymd(y: i64, m: u32, d: u32) -> f64 {
(days_from_civil(y, m, d) + EPOCH_OFFSET_TAGE) as f64
}
/// (Jahr, Monat, Tag) aus einem seriellen Wert.
pub fn ymd_aus_serial(s: f64) -> (i64, u32, u32) {
civil_from_days(s.floor() as i64 - EPOCH_OFFSET_TAGE)
}
/// Tageszeit eines seriellen Werts als (Stunde, Minute, Sekunde).
pub fn hms_aus_serial(s: f64) -> (u32, u32, u32) {
// Auf die Sekunde runden, damit 0.5 exakt 12:00:00 ergibt.
let sekunden = ((s - s.floor()) * 86_400.0).round() as i64;
let sekunden = sekunden.rem_euclid(86_400);
(
(sekunden / 3600) as u32,
((sekunden / 60) % 60) as u32,
(sekunden % 60) as u32,
)
}
/// Wochentag: 1 = Sonntag … 7 = Samstag.
pub fn weekday(s: f64) -> u32 {
// Der 1.1.1970 war ein Donnerstag (Index 4 in dieser Zählung).
let tage = s.floor() as i64 - EPOCH_OFFSET_TAGE;
(tage.rem_euclid(7) as u32 + 4) % 7 + 1
}
/// Tageszeit als serieller Bruchteil.
pub fn serial_aus_hms(h: i64, m: i64, s: i64) -> f64 {
(h * 3600 + m * 60 + s) as f64 / 86_400.0
}
/// Sekunden seit der Unix-Epoche (UTC) — der rohe Zeitpunkt.
pub fn jetzt_utc() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// UTC-Versatz der Systemzone zum Zeitpunkt `zeitpunkt` (Sekunden seit der
/// Unix-Epoche), in Sekunden. `None`, wenn sich die Zone nicht ermitteln
/// lässt — dann gilt UTC.
///
/// Der Versatz gilt **je Zeitpunkt**, nicht je Programmlauf: ein Programm,
/// das über eine Zeitumstellung hinweg läuft, folgt ihr, und ein Sprung in
/// einen anderen Teil des Jahres bekommt den dort gültigen Versatz.
///
/// Einziger Aufrufpunkt der Zeitzonenbibliothek (design.md D1/D2).
pub fn utc_versatz(zeitpunkt: i64) -> Option<i32> {
let ts = jiff::Timestamp::from_second(zeitpunkt).ok()?;
let zone = jiff::tz::TimeZone::try_system().ok()?;
Some(zone.to_offset(ts).seconds())
}
/// Wohin die Zeitfunktionen rechnen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Zeitzone {
/// Zone des Rechners (Vorgabe).
#[default]
System,
/// Fester Versatz in Sekunden — für Tests und den Korpus.
Fest(i32),
/// Zone nicht ermittelbar; es gilt UTC.
Unbekannt,
}
impl Zeitzone {
/// Versatz zu einem Zeitpunkt. Bei `System` ohne ermittelbare Zone
/// fällt der Wert auf 0 (UTC) zurück. BASIC und Einbetter verwenden
/// [`crate::builtins::RtState::zeitpunkt`] für Zeit und aktuellen Status.
pub fn versatz(&self, zeitpunkt: i64) -> i32 {
match self {
Zeitzone::System => utc_versatz(zeitpunkt).unwrap_or(0),
Zeitzone::Fest(n) => *n,
Zeitzone::Unbekannt => 0,
}
}
/// Momentaufnahme: `System`, wenn die Zone lesbar ist, sonst `Unbekannt`.
/// Die Laufzeit behält `System` als Quelle, damit sie nach Ausfällen
/// erneut auflösen kann; `RtState::zeitpunkt` liefert den aktuellen Status.
pub fn ermitteln() -> Zeitzone {
match utc_versatz(jetzt_utc()) {
Some(_) => Zeitzone::System,
None => Zeitzone::Unbekannt,
}
}
}
/// Sekunden seit der Unix-Epoche in **Ortszeit** gelesen: der rohe
/// Zeitpunkt plus Zonenversatz plus programmeigener Versatz. Alles
/// Weitere (`DATE$`, `TIME$`, `TIMER`, `NOW`) rechnet darauf.
pub fn jetzt_sekunden(zone: Zeitzone, offset_sekunden: i64) -> i64 {
let utc = jetzt_utc();
utc + zone.versatz(utc) as i64 + offset_sekunden
}
/// `NOW` — aktueller Zeitpunkt als serieller Wert (Ortszeit).
pub fn jetzt_serial(zone: Zeitzone, offset_sekunden: i64) -> f64 {
jetzt_sekunden(zone, offset_sekunden) as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64
}
/// `DATEVALUE` — Datum aus einem Text lesen (`MM-TT-JJJJ`, `MM/TT/JJJJ`,
/// zweistelliges Jahr wird als 19xx/20xx gedeutet wie beim Vorbild).
pub fn datevalue(t: &str) -> Option<f64> {
let teile: Vec<&str> = t
.trim()
.split(['-', '/', '.'])
.filter(|p| !p.is_empty())
.collect();
if teile.len() != 3 {
return None;
}
let m: i64 = teile[0].trim().parse().ok()?;
let d: i64 = teile[1].trim().parse().ok()?;
let mut y: i64 = teile[2].trim().parse().ok()?;
if y < 100 {
y += if y < 80 { 2000 } else { 1900 };
}
if !gueltig(y, m, d) {
return None;
}
Some(serial_aus_ymd(y, m as u32, d as u32))
}
/// `TIMEVALUE` — Tageszeit aus einem Text lesen (`HH:MM[:SS]`).
pub fn timevalue(t: &str) -> Option<f64> {
let teile: Vec<&str> = t.trim().split(':').collect();
if !(2..=3).contains(&teile.len()) {
return None;
}
let h: i64 = teile[0].trim().parse().ok()?;
let m: i64 = teile[1].trim().parse().ok()?;
let s: i64 = teile.get(2).map_or(Ok(0), |x| x.trim().parse()).ok()?;
if !(0..24).contains(&h) || !(0..60).contains(&m) || !(0..60).contains(&s) {
return None;
}
Some(serial_aus_hms(h, m, s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serienwert_und_rueckumwandlung() {
let s = serial_aus_ymd(1992, 3, 15);
assert_eq!(ymd_aus_serial(s), (1992, 3, 15));
}
#[test]
fn zaehlweise_des_vorbilds() {
// Serie 2 = 1. Januar 1900.
assert_eq!(serial_aus_ymd(1900, 1, 1), 2.0);
assert_eq!(ymd_aus_serial(2.0), (1900, 1, 1));
}
#[test]
fn wochentag_sonntag_ist_eins() {
// 15. März 1992 war ein Sonntag.
assert_eq!(weekday(serial_aus_ymd(1992, 3, 15)), 1);
// 16. März 1992 war ein Montag.
assert_eq!(weekday(serial_aus_ymd(1992, 3, 16)), 2);
}
#[test]
fn tageszeit() {
let s = serial_aus_hms(13, 45, 30);
assert_eq!(hms_aus_serial(s), (13, 45, 30));
assert_eq!(hms_aus_serial(0.5), (12, 0, 0));
}
#[test]
fn gueltigkeitspruefung() {
assert!(gueltig(1992, 2, 29)); // Schaltjahr
assert!(!gueltig(1993, 2, 29));
assert!(!gueltig(1992, 13, 1));
assert!(!gueltig(1992, 4, 31));
}
#[test]
fn versatz_liegt_im_gueltigen_bereich() {
// Zonenversätze liegen zwischen 14 h und +14 h.
if let Some(v) = utc_versatz(1_000_000_000) {
assert!((-50_400..=50_400).contains(&v), "Versatz {v} unplausibel");
}
}
#[test]
fn versatz_gilt_je_zeitpunkt() {
// 15.01.2021 und 15.07.2021 als Unix-Zeitpunkte.
let januar = 1_610_668_800;
let juli = 1_626_307_200;
let (a, b) = (utc_versatz(januar), utc_versatz(juli));
// Beide Abfragen liefern dasselbe Ergebnis-Muster: entweder kennt
// die Maschine ihre Zone oder nicht.
assert_eq!(a.is_some(), b.is_some());
if let (Some(a), Some(b)) = (a, b) {
// In einer Zone mit Sommerzeit unterscheiden sie sich um genau
// die Umstellungsspanne, sonst gar nicht.
let d = (a - b).abs();
assert!(
d == 0 || (1800..=7200).contains(&d),
"Spanne {d} unplausibel"
);
}
}
#[test]
fn fester_versatz_verschiebt_genau() {
let ohne = jetzt_sekunden(Zeitzone::Unbekannt, 0);
let mit = jetzt_sekunden(Zeitzone::Fest(3600), 0);
// Die Systemuhr kann zwischen beiden Aufrufen eine Sekunde weiter
// sein; die Verschiebung bleibt eine Stunde.
assert!((mit - ohne - 3600).abs() <= 1);
}
#[test]
fn programmversatz_kommt_hinzu() {
let a = jetzt_sekunden(Zeitzone::Fest(0), 0);
let b = jetzt_sekunden(Zeitzone::Fest(0), 120);
assert!((b - a - 120).abs() <= 1);
}
#[test]
fn text_in_serienwert() {
assert_eq!(datevalue("03-15-1992"), Some(serial_aus_ymd(1992, 3, 15)));
assert_eq!(datevalue("3/15/92"), Some(serial_aus_ymd(1992, 3, 15)));
assert_eq!(datevalue("13-01-1992"), None);
assert_eq!(timevalue("13:45:30"), Some(serial_aus_hms(13, 45, 30)));
assert_eq!(timevalue("13:45"), Some(serial_aus_hms(13, 45, 0)));
assert_eq!(timevalue("25:00"), None);
}
}