tags:

views:

57

answers:

3

I've run into a strange timewarp while doing some math with time, and it has left me stumped. Or, well, I've found the reason (or atleast a plausible one), but don't quite know what to do about it, or if there is indeed anything that can be done.

The issue in plain words is, that when adding time in larger units than 1 week (it's multipliers excluded) it seems to be impossible to be exact. And by exact I mean, that when I add 1 years worth of seconds to NOW, I end up 1 year and some hours from NOW.

I can understand that adding 1 (or more) months will do this, as a months length varies, but a year should be more or less the same, shouldn't it?

Now I know you'll want to know how I do this, so here follows (pseudoish) code:

class My_DateInterval {
    public $length; // Interval length in seconds

    public function __construct($interval) {
        $this->length = 0;

        preg_match(
            '/P(((?<years>([0-9]{1,}))Y)|((?<months>([0-9]{1,}))M)|((?<weeks>([0-9]{1,}))W)|((?<days>([0-9]{1,}))D)){0,}(T((?<hours>([0-9]{1,2})){1}H){0,1}((?<minutes>([0-9]{1,2}){1})M){0,1}((?<seconds>([0-9]{1,2}){1})S){0,1}){0,1}/', 
            $interval, $timeparts
        );

        if (is_numeric($timeparts['years'])) $this->length += intval($timeparts['years']) * 31556926; // 1 year in seconds
        if (is_numeric($timeparts['months'])) $this->length += intval($timeparts['months']) * 2629743.83; // 1 month in seconds
        if (is_numeric($timeparts['weeks'])) $this->length += intval($timeparts['weeks']) * 604800; // 1 week in seconds
        if (is_numeric($timeparts['days'])) $this->length += intval($timeparts['days']) * 86400; // 1 day in seconds
        if (is_numeric($timeparts['hours'])) $this->length += intval($timeparts['hours']) * 3600; // 1 hour in seconds
        if (is_numeric($timeparts['minutes'])) $this->length += intval($timeparts['minutes']) * 60; // 1 minute in seconds
        if (is_numeric($timeparts['seconds'])) $this->length += intval($timeparts['seconds']);

        $this->length = round($this->length);
    }
}

class My_DateTime extends DateTime {
    public function __construct($time, $tz = null) {
        parent::__contruct($time, $tz);
    }

    public function add(My_DateInterval $i) {
        $this->modify($i->length . ' seconds');
    }
}

$d = new My_DateTime();
$i = new My_DateInterval('P1Y');
$d->add($i);

Doing some debug printouts of the interval length and before/after values show that it's all good, in the sense that "it works as expected and all checks out", but the issue stated above still stands: there is an anomaly in the exactness of it all which I'd very much would like to get right, if possible.

In so many words: How to do exact mathematics with time units greater than 1 week. (I.e. 1 month / 1 year).

PS. For those wondering why I'm using My_* classes is because PHP 5.3 just isn't widespread enough, but I'm trying to keep things in a way that migrating to built-in utility classes will be as smooth as possible.

+2  A: 

A year is 365.25 days, roughly. Hence we have leap years.

Months have variable lengths.

Hence the semantics of what adding a year and adding a month probably don't correspond to adding a fixed number of seconds.

For example adding a month to 14th Feb 2007 would probably be expected to yield 14th March 2007 and to 14th Feb 2008 would ive 14th March 2008, adding 28 or 29 days respectively.

This stuff gets gnarly, especially when we add in different calendars, much of the world doesn't even have a February! Then add "Seven Working Days" - you need to take a public holiday calendar into account.

Are there no libraries you can find for this?

djna
Yeah, this is pretty much what I suspected. I suppose there might be libraries for this, but I really won't need them, I just need to revise my approach instead.Thanks!
nikc
+1  A: 

I can understand that adding 1 (or more) months will do this, as a months length varies, but a year should be more or less the same, shouldn't it?

Above, when you're adding a year's worth of seconds, you're starting with the number of days in (what I guess is) and average year (i.e., 365.2421.. * 24 * 60 * 60). So your calculation implicitly defines a year as a certain number of days.

With this definition, December 31 is a little less than 6 hours too long. So your "clock" goes from Dec 31 23:59 to 29:59 (whatever that is) before rolling over to 00:00 on January 1st. Something similar will happen with months, since you're also defining them as a certain number of seconds instead of 28 - 31 days.

If the purpose of your calculation was timing the difference between events on a generic "average" calendar, then your method will work fine. But if you need to have a correspondence with a real calendar, it's going to be off.

The simplest way to do it is to use a fake julian calendar. Keep track of each division of time (year, month, day, etc.). Define days to be 24 hours exactly. Define a year as 365 days. Add 1 day if the year is divisible by 4, but not when it's divisible by 100 unless it's also divisible by 400.

When you want to add or subtract, do the "math" manually ... every time you increment, check for "overflow". If you overflow the day of the month, reset it and increment the month (then check the month for overflow, etc., etc.).

Your calendar will be able to correspond exactly to a real calendar, for almost everything.

Most computer date implementations do basically this (to varying degrees of complexity). In javascript, for example (because my web inspector is handy):

> new Date(2010,0,1,0,0,0) - new Date(2009,0,1,0,0,0)
31536000000

As you can see, that's exactly 365 days worth of milliseconds. No mess, no fuss.

By they way, getting time right is HARD. The math is in base 60. It's all special cases (leap years, daylight savings, time zones).

Have fun.

Seth
Yep, thanks. I really have no interest in making my own calendar calculations, since they've already been done (better than I could). I was just trying to solve a problem in a stupid manner, but as always it required making the issue public before realizing it.
nikc
Programming stupidity is best when shared :)
Seth
A: 

Just in case someone is interested, the working solution was so simple it (as usual) makes me feel stupid. Instead of translating the interval length into seconds, a wordier version works correctly with PHP internals.

class My_DateInterval {
    public $length; // strtotime supported string format
    public $years;
    public $months;
    public $weeks;
    public $days;
    public $hours;
    public $minutes;
    public $seconds;

    public function __construct($interval) {
        $this->length = 0;

        preg_match(
            '/P(((?<years>([0-9]{1,}))Y)|((?<months>([0-9]{1,}))M)|((?<weeks>([0-9]{1,}))W)|((?<days>([0-9]{1,}))D)){0,}(T((?<hours>([0-9]{1,2})){1}H){0,1}((?<minutes>([0-9]{1,2}){1})M){0,1}((?<seconds>([0-9]{1,2}){1})S){0,1}){0,1}/', 
            $interval, $timeparts
        );

        $this->years = intval($timeparts['years']);
        $this->months = intval($timeparts['months']);
        $this->weeks = intval($timeparts['weeks']);
        $this->days = intval($timeparts['days']);
        $this->hours = intval($timeparts['hours']);
        $this->minutes = intval($timeparts['minutes']);
        $this->seconds = intval($timeparts['seconds']);

        $length = $this->toString();
    }

    public function toString() {
        if (empty($this->length)) {
            $this->length =  sprintf('%d years %d months %d weeks %d days %d hours %d minutes %d seconds',
                $this->years,
                $this->months,
                $this->weeks,
                $this->days,
                $this->hours,
                $this->minutes,
                $this->seconds
            );
        }

        return $this->length;
    }
}
nikc