Zahn der Zeit (Linux-Magazin, Februar 2006)

Datumsberechnungen haben ihre Tücken, da Kalenderregeln historisch wachsen und politische Entscheidungen integrieren. Perls DateTime-Modul kennt sie alle.

Wenn ein Backup-Skript um zehn Uhr abends startet und um vier Uhr nachts erfolgreich abschließt, wie lange ist der Prozess dann gelaufen? Sechs Stunden? Kommt darauf an.

Was ist, wenn der Prozess in der Nacht vom 25. auf den 26. März 2006 irgendwo in Deutschland läuft? Dann wird um zwei Uhr nachts die Uhr um eine Stunde vorgestellt und die korrekte Antwort lautet daher: Fünf Stunden. Liefe derselbe Prozess zur gleichen Zeit in den USA, hieße die Antwort sechs Stunden, da dort die Sommerzeitumstellung eine Woche später erfolgt. Allerdings nicht im Bundesstaat Indiana, der bis dato keine Sommerzeit kennt. Dieses Jahr (2006) soll aber erstmals umgestellt werden ([2]). Derlei Wirrwarr kann das vom CPAN erhältliche Modul DateTime ([5]) glücklicherweise elegant verarbeiten.

Seit wann gibt es in Deutschland die Sommerzeit? Das Skript dsthist findet es heraus, indem es vom Jahr 2000 an rückwärts schreitet und jeweils durch alle Märztage rattert, um herauszufinden, ob irgendwann 3 Uhr herauskommt, wenn man eine Sekunde zur Uhrzeit 01:59:59 hinzuaddiert. Findet es keinen Schalttag, stoppt es, nachdem das Jahr ausgegeben wurde. Die Ausgabe zeigt die Antwort, 1981 war das erste Jahr:

    ...
    1984: DST
    1983: DST
    1982: DST
    1981: DST
    1980: No DST

Listing 1: dsthist

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 
    05 YEAR:
    06 for my $year (reverse 1964..2006) {
    07 
    08     for my $month (3..4) {
    09     for my $day (1..31) {
    10 
    11         next if $day > DateTime->last_day_of_month(
    12                          year => $year, month => $month)->day();
    13 
    14         my $dt = DateTime->new(
    15           year   => $year,
    16           month  => $month,
    17           day    => $day,
    18           hour   => 1,
    19           minute => 59,
    20           second => 59,
    21           time_zone => "Europe/Berlin",
    22         );
    23 
    24         $dt->add(seconds => 1);
    25 
    26         if($dt->hour() == 3) {
    27             print "$year: DST\n";
    28             next YEAR;
    29         }
    30     }
    31     }
    32     print "$year: No DST\n";
    33     last;
    34 }

Summer in the City

In Europa ist die Sommerzeit ja relativ einheitlich geregelt. Nicht so auf dem amerikanischen Kontinent, wo sie in den englischsprachigen Ländern ``Daylight Savings Time'' genannt wird, da man durch sie effizienter mit dem Tageslicht umgeht.

Aber nicht nur die vielen Länder des amerikanischen Kontinents haben ihre eigene Vorstellungen von Sommerzeit. Selbst einzelne Bundesstaaten der USA kochen ihr eigenes Süppchen und manche Landbezirke weichen sogar wieder von den Regelungen eines Bundesstaates ab. Und natürlich haben sich die Regelungen auch im Laufe der Geschichte verändert.

Listing dstchk holt alle Zeitzonen, die dem Modul DateTime::TimeZone (ebenfalls auf dem CPAN erhältlich) bekannt sind, mit all_names() hervor, fährt den ersten Januar des gegenwärtigen Jahres in der gerade untersuchten Zeitzone an und zählt sechs Monate hinzu. Kommt ein Datum heraus, dessen Stundenwert ungleich Null ist, deutet das darauf hin, dass in der ersten Jahreshälfte eine Zeitverschiebung eingetreten ist und damit die entsprechende Zeitzone irgendwann während dieser Zeitspanne in die Sommerzeit eingetreten ist.

Die Zonen liegen normalerweise im Format "Kontinent/Stadt" vor, das gilt zumindest für Europe/Berlin (ganz Deutschland), America/New_York (der Bundesstaat New York in den USA), America/Vancouver (der kanadische Bundesstaat British Columbia) und Pacific/Honolulu (das zur USA gehörende Hawaii). Ist aber zum Beispiel ein Landkreis in seiner gewählten Zeitzone oder Sommerzeitregelung irgendwann einmal von der des Bundesstaates abgewichen, wird weiter untergliedert. So bezeichnet America/Kentucky/Louisville den US- Bundesstaat Kentucky, dessen größte Stadt Louisville ist. (Die Hauptstadt von Kentucky ist, wie in den USA nicht ungewöhnlich, ein völlig unbekanntes Nest namens Frankfort.) Da allerdings ein Landkreis in Kentucky namens Monticello bis zum Jahr 2000 einer anderen Zeitzone angehörte, führt DateTime::TimeZone auch einen Eintrag für America/Kentucky/Monticello. Abbildung 1 zeigt die Ausgabe von dstchk, das Sommerzeitbezirke mit Term::ANSIColor grün anzeigt.

Abbildung 1: Welche Bezirke auf dem amerikanischen Kontinent verwenden Sommerzeit?

Listing 2: dstchk

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 use Term::ANSIColor qw(:constants);;
    05 
    06 for my $zone (
    07          DateTime::TimeZone::all_names()) {
    08 
    09   my $from = DateTime->now(
    10                      time_zone => $zone);
    11     
    12   $from->truncate(to => "year");
    13   my $to = $from->clone()->add(
    14                             months => 6);
    15     
    16   print "$zone: ";
    17 
    18   if( $to->hour() == 0 ) {
    19     print RED, "no", RESET, "\n";
    20   } else {
    21     print GREEN, "yes", RESET, "\n";
    22   }
    23 }

So kann DateTime in allen denkbaren Zeitzonen rechnen, sogar mit Daten der Vergangenheit, in Zeitzonen, die sich historisch entwickelt haben. Die Australien vorgelagerte Insel Lord Howe Island kapriziert sich sogar mit einer Sommerzeit, bei der die Uhr nur eine halbe Stunde verstellt wird. Listing lord_howe zeigt, dass sich eine lokale Ortszeit von 02:30:00 ergibt, falls man zu 2005-10-30 01:59:59 nur eine Sekunde addiert.

Listing 3: lord_howe

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 
    05 my $dt = DateTime->new(
    06   year   => 2005,
    07   month  => 10,
    08   day    => 30,
    09   hour   => 1,
    10   minute => 59,
    11   second => 59,
    12   time_zone => 'Australia/Lord_Howe',
    13 );
    14 
    15 $dt->add( DateTime::Duration->new(
    16             seconds => 1) );
    17 
    18     # 2005-10-30 02:30:00
    19 print $dt->date(), " ", 
    20       $dt->hms(), "\n";

Ein mit dem Konstruktor new erzeugtes DateTime-Objekt existiert zunächst in einer speziellen Zeitzone namens floating, falls der Parameter time_zone nicht explizit eine Zeitzone angibt. In diesem Zustand passt es sich temporär der Zeitzone anderer DateTime- Objekte an, wenn es mit ihnen verglichen wird. Wer bei der Zeitrechnung Sommerzeitspielchen ausschalten will, wählt entweder floating oder die ebenfalls sommerzeitfreie (wirkliche) Zone UTC (Universal Time Coordinated). Soll ein DateTime-Objekt hingegen in derselben Zeitzone beheimatet sein wie der Rechner, auf dem es läuft, sorgt time_zone => "local" dafür, dass DateTime mit allerlei ausgefuchsten Tricks die dort eingestellte Zeitzone errät:

    my $dt = DateTime->now(
       time_zone => "local");
    print $dt->time_zone()->name();

ergab auf dem in San Francisco stationierten Rechner des Perlmeister-Testlabors doch glatt ``America/Los_Angeles''. Nicht schlecht.

Der Erdball macht schlapp

Wieviele Sekunden sind in Deutschland am 1. Januar 1999 zwischen 00:59:00 und 01:00:00 Uhr vergangen? Eine Minute, also 60 Sekunden? Falsch! Tipp: Damals wurde in der Greenwich-Zeitzone UTC (eine Stunde vor der deutschen Zeitzone) eine Schaltsekunde eingelegt. Diese Minute war tatsächlich 61 Sekunden lang!

Wie man auf [3] nachlesen kann, ist eine Sekunde seit 1967 nicht mehr als Bruchteil eines Erdentages definiert, sondern über die viel konstantere Resonanz des Cäsiumatoms. Die Rotationsgeschwindigkeit der Erde hat aber während der letzten 40 Jahre langsam abgenommen. Da die Erde gegenwärtig geringfügig länger als 24 mal 3600 atomgenaue Sekunden für eine Rotation benötigt, wurden seit 1972 etwa alle 18 Monate eine Schaltsekunde in die offizielle Zeit eingefügt. Maßgebend sind der 30. Juni und der 31. Dezember. Falls sich etwa eine Sekunde angestaut hat, wird sie am Ende dieser Stichtage eingefügt. Allerdings scheint sich der Erdball neuerdings wieder konstanter zu drehen, nach 1998 war nur Ende 2005 ein Sekundenspritzerl notwendig. Drehte sich der Erdball auf einmal schneller, würden die gestrengen Zeitwächter einfach eine Schaltsekunde aus der offiziellen Zeit entfernen. Allerdings ist das in der kurzen Geschichte der Schaltsekunden noch nicht vorgekommen.

Listing leapsec arbeitet sich von 1960 bis 2005 vor und untersucht, ob jeweils am 30.6. oder am 31.12. eine Schaltsekunde eingelegt wurde. In der UTC-Zeitzone stellt es die Uhrzeit 23:59:00 ein, addiert 60 Sekunden und prüft, ob der Sekundenanteil den ungewöhnlichen Wert von 60 anzeigt. Ist dies der Fall, war die untersuchte Minute tatsächlich 61 Sekunden lang und eine Schaltsekunde wurde entlarvt. Anschließend setzt set_time_zone() die Zeitzone wieder auf die in Deutschland übliche mitteleuropäische Zeit plus -- falls gegeben -- Sommerzeit. Eine print-Anweisung gibt die lokale Uhrzeit aus und die Anzahl der bisher gefundenen Schaltsekunden aus.

Abbildung 2 zeigt die Ausgabe. Die unterschiedlichen lokalen Schalt-Uhrzeiten am 1.7. erklären sich aus der Sommerzeitumstellung anfang der 80er.

Abbildung 2: An welchen Tagen von 1960 bis heute wurden Schaltsekunden eingelegt?

Listing 4: leapsec

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 
    05 my $secs;
    06 
    07 for my $year (1960..2005) {
    08   for my $date ([30,6], [31,12]) {
    09     my $now = DateTime->new(
    10           year   => $year,
    11           month  => $date->[1],
    12           day    => $date->[0],
    13           hour   => 23,
    14           minute => 59,
    15           second =>  0, 
    16           time_zone => "UTC");
    17 
    18     my $later = $now->clone()->add(
    19             seconds => 60);
    20 
    21     $later->set_time_zone("Europe/Berlin");
    22 
    23     if($later->second() == 60) {
    24         print $later->dmy(), " ", 
    25               $later->hms(), ": ", 
    26               ++$secs, "\n";
    27     }
    28   }
    29 }

Löchrige Abstraktionen

Allerdings kümmert sich die auf Unix-Systemen übliche Zählung der Sekunden seit 1970 nicht um Schaltsekunden. Während in Deutschland am 1. Januar 1999 der Sekundenzeiger von 00:59:59 Uhr auf 00:59:60 schnellte, wurde der Zählerwert auf den Unix-Maschinen nach dem POSIX-Standard von 915148799 auf 915148800 erhöht. Während des nächsten (virtuellen) Hüpfers von 00:59:60 auf 01:00:00 änderte sich jedoch die Unix-Zeit nicht, beide Zeitpunkte werden korrekt von der Unix-Zeit 915148800 repräsentiert. Natürlich hat dafür kein Unix-Rechner die Zeit angehalten, die Differenz ergibt sich lediglich aus der Definition der Unix-Zeit im POSIX-Standard.

Wer also zwei Unix-Zeiten voneinander subtrahiert und daraus die dazwischen verstrichene UTC-Zeit berechnet, muss korrigierend eingreifen, falls dazwischen eine Schaltsekunde stattfand. Details zu diesem verwirrenden Verfahren stehen unter [6] und [7].

DateTime bietet die Klassenmethode from_epoch(epoch => $time) an, um aus einem Zähler ein DateTime-Objekt zu konstruieren. Umgekehrt exportiert die epoch()-Methode eines DateTime-Objekts wieder einen Zählerwert.

Listing leapreveal zeigt, was passiert, wenn man zum 1.1.1990 einfach die aufmultiplizierten Sekunden für 5.000 Tage hinzuzählt: Das Ergebnis ist 2003-09-09T23:59:53, also fehlen 7 Sekunden bis zum vollen Tag. Addiert man hingegen mit add(days => 5000) einfach 5.000 Tage, ist das Ergebnis 2003-09-10T00:00:00. DateTime verarbeitet Zeiteinheiten wie Tage und Sekunden strikt getrennt und rechnet normalerweise eine Zeitdauer wie ``5000 Tage'' nicht in Sekunden um. Wer sich dennoch dafür interessiert und mit wohligem Schauer die dann durchsiebte Abstraktion ansehen will, kann mit der Methode $to->subtract_datetime_absolute($from) das DateTime- Objekt $from von einem DateTime-Objekt $to abziehen und erhält ein DateTime::Duration-Objekt, dessen seconds()-Methode die tatsächlich vergangene Anzahl von Sekunden liefert.

Listing 5: leapreveal

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use Sysadm::Install qw(:all);
    04 
    05 use DateTime;
    06 
    07 my $dt = DateTime->new(
    08   year      => 1990,
    09   time_zone => 'UTC'
    10 );
    11 
    12 $dt->add(seconds => 3600*24*5000);
    13 print "$dt\n";

Superman

Eine weitere Kuriosität ist die Datumsgrenze. Fliegt man gen Osten, wird es -- nach der Ortszeit in den durchquerten Zeitzonen -- immer später am Tag. Irgendwo muss es also einen Punkt geben, an dem das Datum auf den vorhergehenden Tag umspringt, sonst wäre es leicht möglich, mit einem schnellen Flugzeug in die Zukunft zu reisen. Diese Datumsgrenze ([4]) zieht sich von Nord nach Süd durch den pazifischen Ozean, leicht östlich der Süd-Ost-Asien vorgelagerten Inselwelt.

Listing daytrip zeigt, was passiert, wenn man mit einem schnellen Flugzeug von Japan (westlich der Datumsgrenze) nach Hawaii (östlich davon) fliegt:

    Abflug:  Sonntag, den 29.01.2006 um 07:30
    Ankunft: Samstag, den 28.01.2006 um 19:00

Man fliegt man am Sonntag morgen los, kommt aber, obwohl der Flug sechseinhalb Stunden dauert, einen Tag früher an. Am Samstag abend, noch vor Bekanntgabe der Lottozahlen. Schade nur, dass dies nur für die jeweilige Ortszeit gilt.

Listing 6: daytrip

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 use DateTime::Format::Strptime;
    05 
    06 my $format = 
    07     DateTime::Format::Strptime->new(
    08   pattern   => "%A, den %d.%m.%Y um %H:%M",
    09   locale    => "de_DE",
    10   time_zone => 'Asia/Tokyo',
    11 );
    12 
    13 my $dt = $format->parse_datetime("
    14     Sonntag, den 29.01.2006 um 07:30");
    15 
    16 $dt->set_formatter($format);
    17 
    18 print "Abflug:  $dt\n";
    19 
    20 $dt->add( DateTime::Duration->new(
    21     hours   => 6, 
    22     minutes => 30) );
    23 
    24 $dt->set_time_zone( 'Pacific/Honolulu' );
    25 print "Ankunft: $dt\n";

Viele Sprachen sprechen

Listing daytrip zeigt auch, wie DateTime mit unterschiedlichen Datumsformaten umgeht. Sowohl beim Parsen eines Datumsstrings mit parse_datetime() als auch bei der Ausgabe nutzt es Formatierer aus der Klassenhierarchie DateTime::Format::*. Ein besonders flexibler Formatierer ist DateTime::Format::Strptime der, ähnlich wie die C-Funktion strptime(), einen Formatstring mit arrangierten Platzhaltern entgegennimmt. %A steht dabei für den ausgeschriebenen Monatsnamen, %d für das Datum, %H für die Stunde, und so weiter. Der Parameter locale ist auf den Wert "de_DE" gesetzt. de wählt Deutsch als Sprache. Der zweite Teil des Locales bestimmt das Land und dessen spezielle Regeln.

Listing locales zeigt einige weitere Beispiele: en_GB und en_US sind die Locales für die englische Sprache in Großbritannien und den USA. fr_FR wählt Französisch, und es_ES und es_MX formatiert das Datum in Spanisch für Spanien und Mexiko. Ist der Formatierer einmal mit dem richtigen Locale initialisiert, wird er dem DateTime-Objekt mit set_formatter untergejubelt. Ab dann werden ``stringifizierte'' DateTime-Objekte entsprechend den im Formatierer festgelegten Regeln in Strings umgewandelt. Abbildung 3 zeigt einige Beispiele für unterschiedliche Formatierungen ein und desselben DateTime-Objektes.

Listing 7: locales

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 use DateTime::Format::Strptime;
    05 
    06 my $dt = DateTime->now();
    07 
    08 for my $locale (qw(en_GB en_US de_DE fr_FR
    09                    es_ES es_MX)) {
    10 
    11   $dt->set_locale($locale);
    12 
    13   my $format = 
    14     DateTime::Format::Strptime->new(
    15       pattern   => $dt->locale()->
    16                      long_datetime_format()
    17   );
    18 
    19   $dt->set_formatter($format);
    20   print "$locale: $dt\n";
    21 }

Abbildung 3: Unterschiedliche Sprachen und Landesbräuche bei der Übersetzung und Formatierung eines Datumsstrings.

Schaltjahre

Dass alle vier Jahre ein Schaltjahr ist, aber nicht, wenn es durch 100 teilbar ist, aber dann doch wieder, falls die Division durch 400 aufgeht, ist jedem Programmierer spätestens seit dem Jahr 2000 bekannt.

DateTime beherrscht die Regeln natürlich, deswegen eine etwas kompliziertere Aufgabe: Wie lang ist die Liste aller Freitage, die zwischen 1980 und 2020 auf einen 29. Februar fielen?

Elegant löst man diese Rechnung mit zwei Sets von DateTime-Objekten: eines, das alle Freitage enthält und ein anderes, das alle Tage enthält, die auf einen 29. Februar fallen. Ein Objekt der Klasse DateTime::Set hält eine möglicherweise unendliche Menge von DateTime-Objekten. Erzeugt wird ein Set am einfachsten mit Hilfe des Moduls DateTime::Event::Recurrence vom CPAN. Der Konstruktor

    DateTime::Event::Recurrence->
       yearly(days => 29, months => 2);

liefert ein Objekt vom Typ DateTime::Set zurück, das alle 29. Februare als abstrakte Beschreibung enthält. Gleichfalls definiert Listing frifeb29 mit weekly(days => 5) ein weiteres Set, das alle Freitage (der 5. Wochentag) führt. Die Schnittmenge wird durch die Methode intersection() bestimmt und liefert ein Set mit allen Tagen, an denen der 29. Februar auf einen Freitag fällt.

Damit der in Zeile 13 definierte Iterator des Ergebnis-Sets weiß, wo er anfangen soll zu laufen, legt der Parameter start ein Startdatum fest und auch das Ende wird mit einem DateTime-Objekt spezifiziert. Die while-Schleife ab Zeile 18 stößt den Iterator mit next() an und treibt ihn weiter, bis das Ende des untersuchten Zeitraums erreicht ist. Ergebnis: Im Jahr 1980 fiel der 29. Februar auf einen Freitag und auch 2008 wird es wieder soweit sein.

Listing 8: frifeb29

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use DateTime;
    04 use DateTime::Event::Recurrence;
    05 
    06 my $feb29 = DateTime::Event::Recurrence->
    07            yearly(days => 29, months => 2);
    08 my $fri   = DateTime::Event::Recurrence->
    09            weekly(days => 5);
    10 
    11 my $set = $fri->intersection($feb29);
    12 
    13 my $it = $set->iterator(
    14     start => DateTime->new(year => 1980),
    15     end   => DateTime->new(year => 2020),
    16 );
    17 
    18 while(my $dt = $it->next()) {
    19     $dt->set_locale("de_DE");
    20     print $dt->day_name(), ", den ", 
    21           $dt->day(), ".", 
    22           $dt->month(), ".",
    23           $dt->year(), "\n";
    24 }

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2006/04/Perl

[2]
``What time is it in Indiana?'', http://www.mccsc.edu/time.html

[3]
Zum Thema ``Schaltsekunde'': http://de.wikipedia.org/wiki/Schaltsekunde

[4]
Zum Thema ``Datumsgrenze'': http://de.wikipedia.org/wiki/Datumsgrenze

[5]
Homepage des von Dave Rolsky geleiteten Datetime-Projekts: http://datetime.perl.org

[6]
``Unix Time'', http://en.wikipedia.org/wiki/Unix_time

[7]
``UTC, TAI, and UNIX time'', http://cr.yp.to/proto/utctai.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.