Attached is the first Beta of DateTime::Event::Easter. Play with it. Poke it. If something doesn't work like you expect, or you want it to work differently, let me know.
The POD should explain how it all works. There's a few TODOs including ->set($dt) returning a DateTime::Set. Also, Astronomical Paschal Full Moon calculations need to be added. I'm working on finding a reliable source for the algorithm. Anyone with Calendrical Calcs tell me if there's an algorithm therein? ANNOUNCE: Once I release this I'll be releasing DateTime::Event::Catholic which will be similar in interface to DateTime::Event::Easter (and will in fact rely on ::Easter). Calculations will come initially from http://www.saradouglass.com/relfest.html, so if you think the name needs changing, or if the site I'm using for calculations is wrong, or missing info, please let me know. I'm just a little Baptist minister's kid who goes to Mass. This will really make dad's toenails curl :). I'll also add a method to find the nearest event without specifying what it is. (DateTime::Event::Easter's constructor allows you to work with associated days like Good Friday. With DateTime::Event::Catholic, I'll do the same, however I'd really like to be able to get the previous, following or closest event, whatever it is. Which means I'll put an 'any' option in .. should I do this for ::Easter as well?) Cheers! Rick -------------------------------------------------------- There are 10 kinds of people: those that understand binary, and those that don't. -------------------------------------------------------- The day Microsoft makes something that doesn't suck is the day they start selling vacuum cleaners --------------------------------------------------------
package DateTime::Event::Easter; use DateTime; use Carp; use Params::Validate qw( validate SCALAR BOOLEAN OBJECT ); use strict; use vars qw( $VERSION @ISA @EXPORT @EXPORT_OK $earliest $latest $zonestart $zoneend %official_easter_cache %astronomical_easter_cache ); require Exporter; @ISA = qw(Exporter); @EXPORT_OK = qw(easter); $VERSION = '0.01'; $earliest = '0321'; # Earliest Easter is March 21st $latest = '0425'; # Latest Easter is April 18 $zonestart = '0920'; # In the 'zone' we need to check both the $zoneend = '1025'; # previous and next to find the closest sub new { my $class = shift; my %args = validate( @_, { fullmoon => { type => SCALAR, default=>'official', optional=>1, regex => qr/^(official|astronomical)$/ }, day => { type => SCALAR, default=>'sunday', optional=>1 }, } ); my %self; my $offset; if ($args{day} =~/^palm/i) { $offset = -7; } elsif ($args{day} =~/saturday/i) { $offset = -1; } elsif ($args{day} =~/friday/i) { $offset = -1; } elsif ($args{day} =~/thursday/i) { $offset = -1; } elsif ($args{day} =~/^\-?\d+$/i) { $offset = $args{day}; } else { $offset = 0; } $self{offset} = DateTime::Duration->new(days=>$offset); return bless \%self, $class; } sub following { my $self = shift; my $dt = shift; croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 'DateTime'); my $dtmd = _monthday($dt); if ($self->is($dt)) { return easter($dt->year+1)+$self->{offset}; } elsif ($dtmd < $earliest) { return easter($dt->year)+$self->{offset}; } elsif ($dtmd > $latest) { return easter($dt->year + 1)+$self->{offset}; } else { my $thisyear = easter($dt->year)+$self->{offset}; return ($thisyear > $dt) ? $thisyear : easter($dt->year + 1)+$self->{offset}; } } sub previous { my $self = shift; my $dt = shift; croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 'DateTime'); my $dtmd = _monthday($dt); if ($self->is($dt)) { return easter($dt->year-1)+$self->{offset}; } elsif ($dtmd gt $latest) { return easter($dt->year)+$self->{offset}; } elsif ($dtmd lt $earliest) { return easter($dt->year - 1)+$self->{offset}; } else { my $thisyear = easter($dt->year)+$self->{offset}; return ($thisyear < $dt) ? $thisyear : easter($dt->year - 1)+$self->{offset}; } } sub closest { my $self = shift; my $dt = shift; croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 'DateTime'); my $dtmd = _monthday($dt); if ($dtmd gt $zoneend) { return easter($dt->year + 1)+$self->{offset}; } elsif ($dtmd lt $zonestart) { return easter($dt->year)+$self->{offset}; } else { my $previous = $self->previous($dt); my $previous_delta_duration = ($dt - $previous); my $following = $self->following($dt); my $following_delta_duration = ($dt - $following); return ($following < $previous) ? $following : $previous; } } sub is { my $self = shift; my $dt = shift; croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 'DateTime'); my $dtmd = _monthday($dt); #my $thisyear = easter($dt->year); #return ($thisyear->truncate(to=>'day') eq $dt->truncate(to=>'day')) ? 1 : 0; return (_monthday(easter($dt->year)+$self->{offset}) eq _monthday($dt)) ? 1 : 0; } sub set { my $self = shift; my %args = validate( @_, { from => { type => OBJECT }, to => { type => OBJECT }, inclusive => { type => SCALAR, default=>0 }, } ); my @set = (); if ($args{inclusive}) { if ($self->is($args{from})) { push(@set,$args{from}); } if ($self->is($args{to})) { push(@set,$args{to}); } } my $checkdate = $args{from}; $|++; while ($checkdate < $args{to}) { print "Checking $checkdate\n"; $checkdate = $self->following($checkdate); push(@set,$checkdate) if ($checkdate < $args{to}); } print join("\n# ", sort @set); return sort @set; # Later we'll use this: # return DateTime::Set->new( dates => [ sort @set ] ); } sub _monthday { my $dt = shift; return sprintf("%02d%02d", $dt->month, $dt->day); } sub _easter { my $self = shift; my $year = shift; return ($self->{fullmoon} eq 'astronomical') ? astronomical_easter($year) : official_easter($year); } sub official_easter { my $year = shift; croak "Year value '$year' should be numeric." if $year=~/\D/; # print "\nYear: $year\n"; if ($official_easter_cache{$year}) { # print "Have cache for $year ($official_easter_cache{$year})\n"; return $official_easter_cache{$year}; } my $golden_number = $year % 19; # print "G: $golden_number\n"; my $quasicentury = int($year / 100); # print "C: $quasicentury\n"; my $epact = ($quasicentury - int($quasicentury/4) - int(($quasicentury * 8 + 13)/25) + ($golden_number*19) + 15) % 30; # print "H: $epact\n"; my $interval = $epact - int($epact/28)*(1 - int(29/($epact+1)) * int((21 - $golden_number)/11) ); # print "I: $interval\n"; my $weekday = ($year + int($year/4) + $interval + 2 - $quasicentury + int($quasicentury/4)) % 7; # print "J: $weekday\n"; my $offset = $interval - $weekday; # print "L: $offset\n"; my $month = 3 + int(($offset+40)/44); # print "Month: $month\n"; my $day = $offset + 28 - 31* int($month/4); # print "Day: $day\n"; $official_easter_cache{$year} = DateTime->new(year=>$year, month=>$month, day=>$day); return $official_easter_cache{$year}; } *easter = \&official_easter; #alias so people can call 'easter($year)' externally sub astronomical_easter { my $year = shift; croak "Year value '$year' should be numeric." if $year=~/\D/; croak "We don't support the astronomical full-moon yet. Sorry"; } 1; __END__ =head1 NAME DateTime::Event::Easter - Returns Easter events for DateTime objects =head1 SYNOPSIS use DateTime::Event::Easter; $dt = DateTime->new( year => 2002, month => 3, day => 31, ); $easter_sunday = DateTime::Event::Easter->new(); $previous_easter_sunday = $easter_sunday->previous($dt); # Sun, 15 Apr 2001 00:00:00 UTC $following_easter_sunday = $easter_sunday->following($dt); # Sun, 20 Apr 2003 00:00:00 UTC $closest_easter_sunday = $easter_sunday->closest($dt); # Sun, 31 Mar 2002 00:00:00 UTC $is_easter_sunday = $easter_sunday->is($dt); # 1 $palm_sunday = DateTime::Event::Easter->new(day=>'Palm Sunday'); $dt2 = DateTime->new( year => 2012, month => 3, day => 31, ); @set = $palm_sunday->set(from=>$dt, to=>$dt2, inclusive=>1); # Sun, 13 Apr 2003 00:00:00 UTC # Sun, 04 Apr 2004 00:00:00 UTC # Sun, 20 Mar 2005 00:00:00 UTC # Sun, 09 Apr 2006 00:00:00 UTC # Sun, 01 Apr 2007 00:00:00 UTC # Sun, 16 Mar 2008 00:00:00 UTC # Sun, 05 Apr 2009 00:00:00 UTC # Sun, 28 Mar 2010 00:00:00 UTC # Sun, 17 Apr 2011 00:00:00 UTC =head1 DESCRIPTION The DateTime::Event::Easter module returns Easter events for DateTime objects. From a given datetime, it can tell you the previous, the following and the closest Easter event. The 'is' method will tell you if the given DateTime is an Easter Event. Easter Events can be Palm Sunday, Maundy Thursday, Good Friday, Black Saturday and Easter Sunday. If that's not enough, the module will also accept an offset so you can get the date for Pentecost (49 days after Easter Sunday) by passing 49. =head1 BACKGROUND Easter Sunday is the Sunday following the first full moon on or following the Official Vernal Equinox. The Official Vernal Equinox is March 21st. Easter Sunday is never on the full moon. Thus the earliest Easter can be is March 22nd. =head1 CONSTRUCTOR This class accepts the following options to its 'new' constructor: =over 4 =item * fullmoon => ([official]|astronomical) The Catholic and Protestant churches use the 'official' date for the Paschal Full Moon which can differ by a few days from the Astronomical Paschal Full Moon. The Orthodox churches use the Astronomical Paschal Full Moon for Easter calculations which is why Easter is sometimes celebrated on a different day. By default this module uses the Official Paschal Full Moon. In the future it is planned to allow an option of 'auto' here. This will take a look at the DateTime object's timezone to determine if a country follows the Official or the Astronomical method. =item * day => ([Easter Sunday]|Palm Sunday|Maundy Thursday|Good Friday|Black Saturday|I<n>) When constructed with a day parameter, the method can return associated Easter days other than Easter Sunday. The constructor also allows an integer to be passed here as an offset. For example, Maundy Thursday is the same as an offset of -3 (Three days before Easter Sunday) This option allows the following abreviations: day => ([Sunday]|Palm|Thursday|Friday|Saturday) Other offset which rely on Easter might be added at later times. On the other hand, some of them belong in another module DateTime::Event::ChurchYear. For the record here are some offsets you might use: =over 4 =item * 49 - Pentecost =back =back =head1 METHODS This class offers the following methods. =over 4 =item * following($dt) Returns the DateTime object for the Easter Event after $dt. This will not return $dt. =item * previous($dt) Returns the DateTime object for the Easter Event before $dt. This will not return $dt. =item * closest($dt) Returns the DateTime object for the Easter Event closest to $dt. This will return midnight of $dt if $dt is the Easter Event. =item * is($dt) Return positive (1) if $dt is the Easter Event, otherwise returns false (0) =item * set(from => $dt, to => $dt2, inclusive=>I<([0]|1)>) Returns a list of Easter Events between I<to> and I<from>. If the optional I<inclusive> parameter is true (non-zero), the to and from dates will be checked. In the (near) future, this method will return a DateTime::Set object. =back =head1 EXPORTS This class does not export any methods by default, however the following exports are supported. =over 4 =item * easter($year) Given a year, this method will return a DateTime object for Easter Sunday in that year. This method uses the Official Paschal Moon. =back =head1 AUTHOR Rick Measham <[EMAIL PROTECTED]> =head1 SEE ALSO L<DateTime>, perl(1).