Ortszeit statt UTC

Setzt den OpenSpec-Change phase-3-ortszeit um (21/21 Aufgaben) und zieht
damit die in Phase 3 eingegangene Abweichung zurueck: DATE$, TIME$, TIMER
und NOW rechnen in der Zeitzone des Rechners, TIMER zaehlt ab der lokalen
Mitternacht.

Versatzermittlung
- jiff 0.2.35 als Abhaengigkeit von tb-runtime
- datetime::utc_versatz(zeitpunkt) ist der einzige Aufrufpunkt der
  Bibliothek; die Kalenderrechnung darunter bleibt Eigenimplementierung
  und sieht sie nie -- zwei Kalendermodelle nebeneinander waeren eine
  dauerhafte Fehlerquelle
- Der Versatz gilt je Zeitpunkt, nicht je Programmlauf: ein Programm, das
  ueber eine Zeitumstellung hinweg laeuft, folgt ihr

Zonenzustand
- Zeitzone { System, Fest(i32), Unbekannt } im RtState
- Fest(n) macht Tests und Korpusprogramme unabhaengig von der Zone des
  Testrechners; TZ waere prozessweit und set_var seit Rust 2024 unsafe

Rueckfallebene
- Ohne ermittelbare Zone gilt UTC, das Programm laeuft weiter
- tbc run meldet den Rueckfall einmal beim Start auf der Fehlerausgabe,
  nicht je Abfrage -- ein stiller Wechsel waere ein Verstoss gegen den
  Guiding Principle

Korpus
- Neue Direktive ' tb-tz: +-HH:MM im Harness
- zeitzone.bas prueft Ortszeit, TIMER ab lokaler Mitternacht und den
  Programmversatz gegen eine gesetzte Uhrzeit
- datumzeit.bas auf die Direktive umgestellt; Sollausgabe unveraendert

Nebenbefund
- TIMER trennt jetzt ganze Sekunden (Ortszeit) vom Bruchteil
  (Systemuhr). Ohne die Trennung waere TIMER in Zonen mit halbstuendigem
  Versatz gesprungen.

Dokumentation: die Abweichung "Uhr laeuft in UTC" entfaellt aus
sprachreferenz.md; bibliothek.md und PLAN.md nachgezogen, jiff steht im
Stufe-2-Ideenspeicher neben crossterm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 07:01:53 +02:00
parent d0461bea34
commit a0fb41420e
15 changed files with 433 additions and 65 deletions

View File

@@ -94,6 +94,12 @@ fn cmd_run(args: &[String]) -> ExitCode {
// Im Rohmodus ist Strg+C kein Signal mehr — der Abbruch kommt als
// Ereignis und muss den Lauf beenden können.
vm.set_poll_interrupt(true);
// Der Rückfall auf UTC wird einmal beim Start gemeldet, nicht je
// Zeitabfrage — ein stiller Wechsel wäre ein Verstoß gegen den
// Guiding Principle.
if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt {
eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC.");
}
// Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost:
// Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot.

View File

@@ -57,6 +57,28 @@ fn screen_groessen(name: &str, src: &str) -> Vec<(usize, usize)> {
wert.split(',').map(|w| groesse_lesen(name, w)).collect()
}
/// Optionale Zeitzone `' tb-tz: ±HH:MM`. Ohne Angabe gilt die Zone des
/// Rechners — Zeitprogramme im Korpus setzen sie, damit ihre Sollausgabe
/// unabhängig vom Standort des Testrechners bleibt.
fn tz_direktive(name: &str, src: &str) -> Option<tb_runtime::datetime::Zeitzone> {
let wert = direktive(src, "tb-tz:")?;
let (vorzeichen, rest) = match wert.strip_prefix('-') {
Some(r) => (-1i32, r),
None => (1i32, wert.trim_start_matches('+')),
};
let (h, m) = rest
.split_once(':')
.unwrap_or_else(|| panic!("{name}: tb-tz erwartet ±HH:MM, fand {wert:?}"));
let parse = |s: &str| {
s.trim()
.parse::<i32>()
.unwrap_or_else(|_| panic!("{name}: {s:?} ist keine Zahl"))
};
Some(tb_runtime::datetime::Zeitzone::Fest(
vorzeichen * (parse(h) * 3600 + parse(m) * 60),
))
}
/// Optionale Größenänderung mitten im Lauf:
/// `' tb-resize: nach <n> auf <cols>x<rows>`.
fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
@@ -112,6 +134,9 @@ fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
let mut vm = Vm::new(module);
vm.rt.screen.resize(cols, rows);
if let Some(z) = tz_direktive(&name, &src) {
vm.rt.zeitzone = z;
}
// `' tb-tempdir` — das Programm arbeitet mit Dateien.
let _temp = direktive(&src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
let mut host = CaptureHost::default();

View File

@@ -8,5 +8,6 @@ authors.workspace = true
[dependencies]
unicode-width.workspace = true
jiff.workspace = true
thiserror.workspace = true
log.workspace = true

View File

@@ -45,8 +45,11 @@ pub struct RtState {
pub dir_index: usize,
/// Versatz der Programmuhr in Sekunden. `DATE$ =` und `TIME$ =` können
/// die Systemuhr nicht stellen; sie verschieben stattdessen diesen
/// Versatz (dokumentierte Abweichung).
/// Versatz (dokumentierte Abweichung). Er wirkt auf die Ortszeit.
pub uhr_offset: i64,
/// Woher der UTC-Versatz kommt. Vorgabe ist die Zone des Rechners;
/// ist sie nicht ermittelbar, steht hier `Unbekannt` und es gilt UTC.
pub zeitzone: dt::Zeitzone,
rng: u32,
rnd_last: f32,
pub command: String,
@@ -70,6 +73,7 @@ impl Default for RtState {
dir_treffer: Vec::new(),
dir_index: 0,
uhr_offset: 0,
zeitzone: dt::Zeitzone::ermitteln(),
// Startzustand des Vorbild-PRNG; die exakte
// PRNG-Kompatibilität ist Aufgabe in PLAN.md Phase 3
// („RND/RANDOMIZE — kompatibler PRNG").
@@ -1444,23 +1448,24 @@ fn bi_input_s(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result<
// ---- Sonstiges ------------------------------------------------------------------
fn bi_timer(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
// Sekunden seit Mitternacht (UTC-basiert; lokale Zeitzone: Phase 3).
let secs = std::time::SystemTime::now()
fn bi_timer(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
// Sekunden seit der **lokalen** Mitternacht, mit Bruchteil.
let bruchteil = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64() % 86_400.0)
.map(|d| d.as_secs_f64().fract())
.unwrap_or(0.0);
Ok(Some(Value::Sng(secs as f32)))
let ganze = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset).rem_euclid(86_400);
Ok(Some(Value::Sng(ganze as f32 + bruchteil as f32)))
}
fn bi_date(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
let secs = dt::jetzt_sekunden() + st.uhr_offset;
let secs = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset);
let (y, m, d) = dt::civil_from_days(secs.div_euclid(86_400));
s_ok(format!("{m:02}-{d:02}-{y:04}"))
}
fn bi_time(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
let s = (dt::jetzt_sekunden() + st.uhr_offset).rem_euclid(86_400);
let s = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset).rem_euclid(86_400);
s_ok(format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60))
}
@@ -1468,7 +1473,7 @@ fn bi_date_set(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result<Op
let t = arg_str(a, 0)?;
let ziel = dt::datevalue(&t).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
// Nur den Datumsanteil verschieben, die Tageszeit bleibt.
let jetzt = dt::jetzt_serial(st.uhr_offset);
let jetzt = dt::jetzt_serial(st.zeitzone, st.uhr_offset);
let diff_tage = ziel.floor() - jetzt.floor();
st.uhr_offset += (diff_tage * 86_400.0) as i64;
Ok(None)
@@ -1477,14 +1482,14 @@ fn bi_date_set(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result<Op
fn bi_time_set(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
let t = arg_str(a, 0)?;
let ziel = dt::timevalue(&t).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
let jetzt = dt::jetzt_serial(st.uhr_offset);
let jetzt = dt::jetzt_serial(st.zeitzone, st.uhr_offset);
let diff = ziel - (jetzt - jetzt.floor());
st.uhr_offset += (diff * 86_400.0).round() as i64;
Ok(None)
}
fn bi_now(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result<Option<Value>, RuntimeError> {
Ok(Some(Value::Dbl(dt::jetzt_serial(st.uhr_offset))))
Ok(Some(Value::Dbl(dt::jetzt_serial(st.zeitzone, st.uhr_offset))))
}
fn bi_date_serial(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result<Option<Value>, RuntimeError> {

View File

@@ -4,11 +4,11 @@
//! 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.
//!
//! ponytail: Die Uhr läuft in UTC — die Standardbibliothek kennt keine
//! Zeitzonen, und eine Zeitzonenbibliothek wäre die erste schwere
//! Abhängigkeit der Laufzeit. Die Abweichung steht in der Sprachreferenz.
//! Ceiling: sobald lokale Zeit gefordert ist, hier eine Zeitzonenquelle
//! einhängen — der Rest des Moduls rechnet bereits auf Sekunden seit Epoche.
//! 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;
@@ -95,18 +95,74 @@ 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).
pub fn jetzt_sekunden() -> i64 {
/// 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)
}
/// `NOW` — aktueller Zeitpunkt als serieller Wert.
pub fn jetzt_serial(offset_sekunden: i64) -> f64 {
let s = jetzt_sekunden() + offset_sekunden;
s as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64
/// 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 — die Rückfallebene ist über
/// [`Zeitzone::ermitteln`] erkennbar.
pub fn versatz(&self, zeitpunkt: i64) -> i32 {
match self {
Zeitzone::System => utc_versatz(zeitpunkt).unwrap_or(0),
Zeitzone::Fest(n) => *n,
Zeitzone::Unbekannt => 0,
}
}
/// Einmal beim Start: `System`, wenn die Zone lesbar ist, sonst
/// `Unbekannt`. Damit ist der Rückfall auf UTC sichtbar statt still.
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`,
@@ -187,6 +243,47 @@ mod tests {
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)));

View File

@@ -660,3 +660,72 @@ fn lokaler_handler_hat_vorrang() {
END SUB\n";
assert_eq!(out(src), "Lokal\n");
}
// ---- Ortszeit (Spec konsolen-basisbibliothek) ------------------------------
use tb_runtime::datetime::Zeitzone;
/// Programm mit festgelegter Zeitzone laufen lassen.
fn out_mit_zone(src: &str, zone: Zeitzone) -> String {
use tb_runtime::host::CaptureHost;
let module = tb_vm::compile_source("TEST", src).unwrap_or_else(|d| panic!("{d:?}"));
let mut vm = Vm::new(module);
vm.rt.zeitzone = zone;
let mut host = CaptureHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
tb_runtime::snapshot::text(&vm.rt.screen)
}
/// `TIME$` und `HOUR(NOW)` nennen dieselbe Stunde — sie rechnen beide
/// über den Zonenzustand.
#[test]
fn time_und_now_stimmen_ueberein() {
let t = out_mit_zone("PRINT LEFT$(TIME$, 2); HOUR(NOW)", Zeitzone::Fest(7200));
let zeile = t.lines().next().unwrap();
let aus_time: i32 = zeile[..2].parse().unwrap();
let aus_now: i32 = zeile[2..].trim().parse().unwrap();
assert_eq!(aus_time, aus_now, "{zeile:?}");
}
/// Ein fester Versatz verschiebt die Uhr um genau diesen Betrag.
#[test]
fn fester_versatz_verschiebt_die_uhr() {
let src = "PRINT INT(TIMER)";
let ohne: f64 = out_mit_zone(src, Zeitzone::Unbekannt).trim().parse().unwrap();
let mit: f64 = out_mit_zone(src, Zeitzone::Fest(3600)).trim().parse().unwrap();
// Modulo Tageslänge, damit ein Mitternachtsübergang nichts kaputt macht.
let diff = (mit - ohne).rem_euclid(86_400.0);
assert!((diff - 3600.0).abs() <= 2.0, "Differenz {diff}");
}
/// `TIMER` zählt ab der lokalen Mitternacht, nicht ab der UTC-Mitternacht.
#[test]
fn timer_zaehlt_ab_lokaler_mitternacht() {
let t = out_mit_zone("PRINT INT(TIMER / 3600); VAL(LEFT$(TIME$, 2))", Zeitzone::Fest(-18000));
let zahlen: Vec<f64> = t
.split_whitespace()
.filter_map(|w| w.parse().ok())
.collect();
assert_eq!(zahlen.len(), 2, "{t:?}");
assert_eq!(zahlen[0], zahlen[1], "TIMER-Stunde ≠ TIME$-Stunde: {t:?}");
}
/// `TIME$ =` wirkt auf die Ortszeit und alle davon abgeleiteten Werte.
#[test]
fn programmversatz_wirkt_auf_alle_zeitfunktionen() {
let t = out_mit_zone(
"TIME$ = \"10:20:30\"\nDATE$ = \"01-02-1993\"\nPRINT TIME$; \" \"; DATE$; HOUR(NOW); YEAR(NOW)",
Zeitzone::Fest(3600),
);
assert!(t.starts_with("10:20:30 01-02-1993 10 1993"), "{t:?}");
}
/// Ohne ermittelbare Zone gilt UTC — ohne Fehler.
#[test]
fn rueckfall_auf_utc_laeuft_fehlerfrei() {
let t = out_mit_zone(
"PRINT LEN(DATE$); LEN(TIME$); TIMER >= 0; YEAR(NOW) > 2000",
Zeitzone::Unbekannt,
);
assert_eq!(t.trim(), "10 8 -1 -1", "{t:?}");
}