4

In Perl a function called timelocal exists to convert times to epoch.

example: my $epoch = timelocal($sec, $min, $hour, $mday, $mon, $year)

This function however appears to be inherently flawed when dealing with times very far in the past (before the year 999) - see the section on Year Value Interpretation. To make things worse, the way it processes 2 digit years complicates things even more...

Given a time before the year 999 how can I accurately convert it to its corresponding epoch value?

tjwrona1992
  • 8,614
  • 8
  • 35
  • 98

3 Answers3

6

Given a time before the year 999 how can I accurately convert it to its corresponding epoch value?

You can't with Time::Local. timegm (which is used by timelocal) contains the following:

if ( $year >= 1000 ) {
    $year -= 1900;
}
elsif ( $year < 100 and $year >= 0 ) {
    $year += ( $year > $Breakpoint ) ? $Century : $NextCentury;
}

If the year is between 0 and 100, it's automatically converted to a year in the current century as described in the documentation; years between 100 and 999 are treated as offsets from 1900. You can't get around that without hacking the source.


If your perl was compiled to use 64-bit integers*, you can use the DateTime module instead:

use strict;
use warnings 'all';
use 5.010;

use DateTime;

my $dt = DateTime->new(
    year       => 1,
    month      => 1,
    day        => 1,
    hour       => 0,
    minute     => 0,
    second     => 0,
    time_zone  => 'UTC'
);

say $dt->epoch;

Output:

-62135596800

Note that the Gregorian calendar wasn't even adopted until 1582, so DateTime uses what's called the "proleptic Gregorian calendar" by simply extending it backwards from 1582.


* With 32-bit integers, dates too far in the past or future will cause integer overflow. Your perl supports 64-bit integers if use64bitint=define appears in the output to perl -V (with a capital 'V').

ThisSuitIsBlackNot
  • 23,492
  • 9
  • 63
  • 110
1

Looking at the votes and reviews, the DateTime module would seem to be the authoritative, go-to module for this sort of stuff. Unfortunately its $dt->epoch() documentation comes with these caveats;

Since the epoch does not account for leap seconds, the epoch time for 
1972-12-31T23:59:60 (UTC) is exactly the same as that for 1973-01-01T00:00:00.

This module uses Time::Local to calculate the epoch, which may or may not 
handle epochs before 1904 or after 2038 (depending on the size of your system's 
integers, and whether or not Perl was compiled with 64-bit int support).

It would appear these are the limits you going to have to work within.

Having said that, this comment is probably a sensible warning for users who

  1. Are using a machine with 32-bit ints; or
  2. Have a low error tolerance even for "old" dates

The first is going to be a problem if you have a 32-bit machine. The range (in years) for a signed 32-bit based epoch is around 2^31 / (3600*24*365) or (only) 68 years to/from 1970 (presuming a unix epoch). For a 64 bit int however, it becomes 290,000 years to/from 1970 - which would be ok, I presume. :-)

Only you can say if the second issue is going to be a problem. Herewith are the results of a back-of-the-envelope examination of the degree of error;

$ perl -MDateTime -E 'say DateTime->new( year => 0 )->epoch / (365.25 * 24 * 3600)'
-1969.96030116359  # Year 0ad is 1969.96 years before 1970
$ perl -MDateTime -E 'say DateTime->new( year => -1000 )->epoch / (365.25*24*3600)'
-2969.93839835729  # year 1000bc is 2969.94 years before 1970
$ perl -MDateTime -E 'say ((DateTime->new( year => -1000 )->epoch - DateTime->new( year => 0 )->epoch ) / (365.25*24*3600))'
-999.978097193703  # 1,000bc has an error of 0.022 years
$ perl -MDateTime -E 'say 1000*365.25 + ((DateTime->new( year => -1000 )->epoch - DateTime->new( year => 0 )->epoch ) / (24 * 3600))'
8  # ... or 8 days
$

NOTE: I don't know how much of this "error" is due to the way I'm examining it - a year is not 365.25 days. In fact, let me correct that - I took a better definition of days in a year from here and we get;

$ perl -MDateTime -E 'say 1000*365.242189  + ((DateTime->new( year => -1000 )->epoch - DateTime->new( year => 0 )->epoch ) / (24 * 3600))'
0.189000000013039

So, an error of something-like 0.2 days when working with dates around 1,000bc.

In short, if you have 64 bit machine, you should be fine.

Marty
  • 2,788
  • 11
  • 17
  • Now that's interesting. The code for calculating the epoch is `( $self->{utc_rd_days} - 719163 ) * SECONDS_PER_DAY + $self->{utc_rd_secs}`, where `utc_rd_days` are the number of days since January 1st, year 1. With 32-bit ints, 719163 * 86400 would overflow. – ThisSuitIsBlackNot Mar 23 '16 at 00:25
  • Also, your calculations are off. The length of a year in the Gregorian calendar is 365.2425 days. In your last calculation, you're using the length of the tropical year. – ThisSuitIsBlackNot Mar 23 '16 at 01:28
  • Thank you for pointing that out. It's all a bit relative - you see I started off using 365.25 - the idea, of course, is just to get a feel for it to determine if things like ignoring leap seconds can be ignored. Turns out the thing is, IMO, very accurate. I'm glad someone found it interesting - I spent too much time on it and two other answers appeared in the mean time. – Marty Mar 23 '16 at 01:33
  • There were no leap seconds before 1972, so for the purposes of this question, they can be ignored. But I'm glad you pointed out the limitations with 32-bit ints, I need to add that to my answer. – ThisSuitIsBlackNot Mar 23 '16 at 01:37
1

The Gregorian calendar repeat completely every 146,097 days, which equals 400 years. We can map a year less than 1000 to an equivalent year within the cycle. The following implementation map a year less than 1000 to a year in the third cycle, for example 0001 maps to 1201.

#!/usr/bin/perl
use strict;
use warnings;
use Time::Local qw[timegm timelocal];

use constant CYCLE_YEARS   => 400;
use constant CYCLE_DAYS    => 146097;
use constant CYCLE_SECONDS => CYCLE_DAYS * 86400;

sub mytimelocal {
    my ($sec, $min, $hour, $mday, $month, $year) = @_;

    my $adjust = 0;
    if ($year < 1000) {
        my $cycles = 3 - int($year/CYCLE_YEARS) + ($year < 0);
        $year   += $cycles * CYCLE_YEARS;
        $adjust  = $cycles * CYCLE_SECONDS;
    }
    return timelocal($sec, $min, $hour, $mday, $month, $year) - $adjust;
}


use Test::More tests => CYCLE_DAYS;

use constant STD_OFFSET => 
  timelocal(0, 0, 0, 1, 0, 1200) - timegm(0, 0, 0, 1, 0, 1200);

my $seconds = -5 * CYCLE_SECONDS; # -0030-01-01
while ($seconds < -4 * CYCLE_SECONDS) { # 0370-01-01
    my @tm  = gmtime($seconds);
       $tm[5] += 1900;
    my $got = mytimelocal(@tm);
    my $exp = $seconds + STD_OFFSET;
    is($got, $exp, scalar gmtime($seconds));
    $seconds += 86400;
}
chansen
  • 2,446
  • 15
  • 20