Compute NUM of days from UNION of time ranges (with DateTime::Span) - perl

I have a set of time ranges and need to get the Number of days from this set. I did it with DateTime::Span as follows:
use DateTime;
use DateTime::Span;
sub printHash {
my $hash = shift;
foreach my $key (keys %{$hash}) {
print "$key: $hash->{$key}\n";
}
}
$date1 = DateTime->new( year => 2002, month => 3, day => 11 );
$date2 = DateTime->new( year => 2002, month => 4, day => 12 );
$date3 = DateTime->new( year => 2003, month => 8, day => 26 );
$date4 = DateTime->new( year => 2003, month => 9, day => 15 );
$date5 = DateTime->new( year => 2004, month => 7, day => 6 );
$date6 = DateTime->new( year => 2004, month => 10, day => 15 );
$set1 = DateTime::Span->from_datetimes( start => $date1, end => $date2 );
$set2 = DateTime::Span->from_datetimes( start => $date3, end => $date4 );
$set3 = DateTime::Span->from_datetimes( start => $date5, end => $date6 );
$set123 = $set1->union( $set2, $set3 );
printHash(\%$set123);
#--------------------------------
set123:
set: [2002-03-11T00:00:00..2002-04-12T00:00:00],[2003-08-26T00:00:00..2003-09
T00:00:00],[2004-07-06T00:00:00..2004-10-15T00:00:00]
Is there a way to extract the Number of days in this $set123? Thanks a lot!
Update: I can do it with delta_days but it's actually I do need a UNION of the time ranges then find the Number of days within this UNION set. I find DateTime::Span works well to find the UNION but I just need to know how to extract the Number of days in it. Since I need a solution in a hurry, so please help! Thanks!

First, you're calling union incorrectly. It only takes one argument.
$set1->union($set2)->union($set3);
Secondly, the result of a union is actually a DateTime::SpanSet, not a DateTime::Span.
my $spanset = $set1->union($set2)->union($set3);
Finally, both ::Span and ::SpanSet provide a method called duration which return a DateTime::Duration object.
my $dur = $spanset->duration();
Unfortunately, it doesn't return the result as days, and it's impossible to convert what it does return into days. That means you need to iterate over the spans that make up the span set, and sum the size of those.
my $days = 0;
my $iter = $spanset->iterator();
while ( my $span = $iter->next ) {
my $date1 = $span->start;
my $date2 = $span->end;
$days += $date2->delta_days($date1)->in_units('days');
}
print("$days\n"); # 153

Actually, you don't need to use DateTime::Span. The delta_days method of DateTime does the trick nicely:
use strict;
use warnings;
use DateTime;
my $dt1=DateTime->new(year=>2002,month=>3,day=>11,time_zone=>"local");
my $dt2=DateTime->new(year=>2002,month=>4,day=>11,time_zone=>"local");
print $dt2->delta_days($dt1)->in_units("days") . "\n"; #31

Related

Calculate the difference between two dates as years-and-days but without months?

I'm trying to calculate the difference between two dates (the early date is June 11th, 1847) as a the number of years plus the number of days. If the second date were June 9th of 2021, I'd like to get back 173 years and 363 days.
I've been using this, though it's not quite what I want.
my $firstdate = DateTime::Format::Strptime->new(pattern => '%Y%m%d')->parse_datetime($papers{$paper}->{'firstday'});
my $today = DateTime::Format::Strptime->new(pattern => '%Y%m%d')->parse_datetime($date);
my $diff = $today - $firstdate;
printf("%d years and %d days\n", $diff->in_units(qw( years days )));
The output looks something like this:
173 years and 29 days
Of course, it's obvious why this is the case if I put months back into the in_units() method... it's because it's calculating those separately, and with the months included it'd give output of 11 months. Plus 29 days, and that's the 363 I want.
Given the regrettable fact of the occasional leap year, this isn't so simple as using the modulus operator.
Is there an elegant way to do this, or barring that, is there a practical way to do this?
Here's a way using DateTime::Format::Duration to compute how many days are between two dates:
#!/usr/bin/env perl
use strict;
use warnings;
use DateTime;
use DateTime::Format::Strptime;
use DateTime::Format::Duration;
# Given a DateTime, return one for the start of its year
sub first_day_of_year {
my $dt = shift;
return DateTime->new(year => $dt->year, month => 1, day => 1);
}
# Given a DateTime, return one for the end of its year
sub last_day_of_year {
my $dt = shift;
return DateTime->new(year => $dt->year, month => 12, day => 31);
}
sub years_and_days {
my ($from, $to) = #_;
my $formatter = DateTime::Format::Duration->new(normalize => 1, pattern => '%j');
# Compute the years between two dates
my $diff = $to - $from;
my $years = $diff->in_units('years');
# Simple case: dates less than a year apart
if ($years == 0) {
return (0, $formatter->set_base($from)->format_duration($diff));
}
# Calculate the days by working out the days from the $from date to
# the end of its year and the days from the beginning of the $to
# date's year to it.
my $from_eoy = last_day_of_year $from;
my $to_soy = first_day_of_year $to;
my $first_days = $formatter->set_base($from_eoy)->format_duration($from_eoy - $from);
my $last_days = $formatter->set_base($to_soy)->format_duration($to - $to_soy);
my $days = $first_days + $last_days;
return ($years, $days);
}
my $parser = DateTime::Format::Strptime->new(pattern => '%Y%m%d');
my $firstdate = $parser->parse_datetime('18470611');
my $today = $parser->parse_datetime('20210609');
my ($years, $days) = years_and_days $firstdate, $today;
# 173 years and 363 days.
printf "%d years and %d days.\n", $years, $days;

perl's datetime subtraction with sign

I'm trying to use Perl's DateTime to subtract one day from another with a sign. I can get the days in between easily:
sub delta_days {
my $date1 = shift;
my $date2 = shift;
my $d1 = str_to_date_object($date1);
my $d2 = str_to_date_object($date2);
return $d2->delta_days($d1)->delta_days();
}
say delta_days('2021-10-21', '1980-8-20');
say delta_days('1980-8-20', '2021-10-21');
but both of these calls give the difference as 15037, without a sign.
Following the documentation https://metacpan.org/pod/DateTime
I see
# not DST
my $dt1 = DateTime->new(
year => 2003,
month => 5,
day => 6,
time_zone => 'America/Chicago',
);
# is DST
my $dt2 = DateTime->new(
year => 2003,
month => 11,
day => 6,
time_zone => 'America/Chicago',
);
my $dur = $dt2->subtract_datetime($dt1)->days();
I have checked a similar question in How to make DateTime::Duration output only in days? but I don't see how to get a signed difference there.
How can I get the difference in days with a sign?
Two issues: You're doing the subtraction on dates in the wrong order if you want a negative offset, and with the dates you're testing subtract_datetime() with, the duration object is going to have 0 days.
Compare with this version:
#!/usr/bin/env perl
use strict;
use warnings;
use feature qw/say/;
use DateTime;
use Data::Dumper;
# not DST
my $dt1 = DateTime->new(
year => 2003,
month => 5,
day => 6,
time_zone => 'America/Chicago',
);
# is DST
my $dt2 = DateTime->new(
year => 2003,
month => 11,
day => 6,
time_zone => 'America/Chicago',
);
say $dt1;
say $dt2;
my $dur = $dt1->subtract_datetime($dt2);
print Dumper({$dur->deltas});
which when run produces
2003-05-06T00:00:00
2003-11-06T00:00:00
$VAR1 = {
'minutes' => 0,
'nanoseconds' => 0,
'months' => -6,
'seconds' => 0,
'days' => 0
};
Note the -6 months offset. DateTime::Duration objects will not convert between months and days according to the documentation so this looks like a dead end.
The delta_days method you looked at first returns the actual difference in days, but as noted it's an absolute value. You can add a check to convert it to negative if the first date is before the second:
my $days = $dt1->delta_days($dt2)->in_units('days');
$days *= -1 if $dt1 < $dt2;
say $days; # Prints -184
and a variation of your delta_days() function:
sub delta_days {
my $date1 = shift;
my $date2 = shift;
my $d1 = str_to_date_object($date1);
my $d2 = str_to_date_object($date2);
return $d1->delta_days($d2)->in_units('days') * ($d1 < $d2 ? -1 : 1);
}
say delta_days('2021-10-21', '1980-8-20');
say delta_days('1980-8-20', '2021-10-21');

DateTime::subtract_datetime returns zero for difference in hours

Here is a minimal working example:
#!/usr/bin/perl
use strict;
use warnings;
use DateTime;
my $now = DateTime->new(day => 17,
month=> 10,
year => 2014,);
my $date = DateTime->new(day => 3,
month=> 10,
year => 2014,
);
my $dur = $now->subtract_datetime($date);
print 'hours = ', $dur->hours(), "\n";
When I run it on my server I get this:
hours = 0
It's incorrect. The correct answer is 14 days = 336 hours.
What's wrong with the code?
Not all days have 24 hours, so 14 days is not always 336 hours. If you want the difference in hours, you'll have to specifically ask for that (or in something that can be converted to hours such as minutes). delta_ms is the method to do that.
my $now = DateTime->new(day => 17,
month=> 10,
year => 2014,);
my $date = DateTime->new(day => 3,
month=> 10,
year => 2014,
);
my $dur = $now->delta_ms($date);
print 'hours = ', $dur->in_units('hours'), "\n";
Which returns what you want.
See How to make DateTime::Duration output only in days? for more discussion.

Determining the time at which a date starts

Say I want to create a daily planner, and I want to divide the day into 15 minute chunks.
Easy, right? Just start at midnight, and... Wrong! In America/Sao_Paulo, one day each year starts at 01:00 because of Daylight Saving Time changes.
Given a time zone and a date, how does one find the epoch time at which the day starts?
My first thought was to use the following, but it assumes each day has a 23:59. That's probably no better of an assumption than assuming each day has a midnight.
perl -MDateTime -E'
say
DateTime->new( year => 2013, month => 10, day => 20 )
->subtract( days => 1 )
->set( hour => 23, minute => 59 )
->set_time_zone("America/Sao_Paulo")
->add( minutes => 1 )
->strftime("%H:%M");
'
01:00
Is there a more robust or more direct alternative?
You'd think this is something that needs to be done commonly! I suspect there's a lot of buggy code out there...
Here's a solution that's coded with the intention of trying to get it incorporated into DateTime.
use strict;
use warnings;
use DateTime qw( );
use DateTime::TimeZone qw( );
# Assumption:
# There is no dt to which one can add time
# to obtain a dt with an earlier date.
sub day_start {
my $tz = shift;
my $dt = shift;
my $local_rd_days = ( $dt->local_rd_values() )[0];
my $seconds = $local_rd_days * 24*60*60;
my $min_idx;
if ( $seconds < $tz->max_span->[DateTime::TimeZone::LOCAL_END] ) {
$min_idx = 0;
} else {
$min_idx = #{ $tz->{spans} };
$tz->_generate_spans_until_match( $dt->utc_year()+1, $seconds, 'local' );
}
my $max_idx = $#{ $tz->{spans} };
my $utc_rd_days;
my $utc_rd_secs;
while (1) {
my $current_idx = int( ( $min_idx + $max_idx )/2 );
my $current = $tz->{spans}[$current_idx];
if ( $seconds < $current->[DateTime::TimeZone::LOCAL_START] ) {
$max_idx = $current_idx - 1;
}
elsif ( $seconds >= $current->[DateTime::TimeZone::LOCAL_END] ) {
$min_idx = $current_idx + 1;
}
else {
my $offset = $current->[DateTime::TimeZone::OFFSET];
# In case of overlaps, always prefer earlier span.
if ($current->[DateTime::TimeZone::IS_DST] && $current_idx) {
my $prev = $tz->{spans}[$current_idx-1];
$offset = $prev->[DateTime::TimeZone::OFFSET]
if $seconds >= $prev->[DateTime::TimeZone::LOCAL_START]
&& $seconds < $prev->[DateTime::TimeZone::LOCAL_END];
}
$utc_rd_days = $local_rd_days;
$utc_rd_secs = -$offset;
DateTime->_normalize_tai_seconds($utc_rd_days, $utc_rd_secs);
last;
}
if ($min_idx > $max_idx) {
$current_idx = $min_idx;
$current = $tz->{spans}[$current_idx];
if (int( $current->[DateTime::TimeZone::LOCAL_START] / (24*60*60) ) != $local_rd_days) {
my $err = 'Invalid local time for date';
$err .= " in time zone: " . $tz->name;
$err .= "\n";
die $err;
}
$utc_rd_secs = $current->[DateTime::TimeZone::UTC_START] % (24*60*60);
$utc_rd_days = int( $current->[DateTime::TimeZone::UTC_START] / (24*60*60) );
last;
}
}
my ($year, $month, $day) = DateTime->_rd2ymd($utc_rd_days);
my ($hour, $minute, $second) = DateTime->_seconds_as_components($utc_rd_secs);
return
$dt
->_new_from_self(
year => $year,
month => $month,
day => $day,
hour => $hour,
minute => $minute,
second => $second,
time_zone => 'UTC',
)
->set_time_zone($tz);
}
Test:
sub new_date {
my $y = shift;
my $m = shift;
my $d = shift;
return DateTime->new(
year => $y, month => $m, day => $d,
#_,
hour => 0, minute => 0, second => 0, nanosecond => 0,
time_zone => 'floating'
);
}
{
# No midnight.
my $tz = DateTime::TimeZone->new( name => 'America/Sao_Paulo' );
my $dt = day_start($tz, new_date(2013, 10, 20));
print($dt->iso8601(), "\n"); # 2013-10-20T01:00:00
$dt->subtract( seconds => 1 );
print($dt->iso8601(), "\n"); # 2013-10-19T23:59:59
}
{
# Two midnights.
my $tz = DateTime::TimeZone->new( name => 'America/Havana' );
my $dt = day_start($tz, new_date(2013, 11, 3));
print($dt->iso8601(), "\n"); # 2013-11-03T00:00:00
$dt->subtract( seconds => 1 );
print($dt->iso8601(), "\n"); # 2013-11-02T23:59:59
}
A practical example,
sub today_as_floating {
return
DateTime
->now( #_ )
->set_time_zone('floating')
->truncate( to => 'day' );
}
{
my $tz = DateTime::TimeZone->new( name => 'local' );
my $dt = today_as_floating( time_zone => $tz );
$dt = day_start($tz, $dt);
print($dt->iso8601(), "\n");
}
A reasonable approach would be to start at 12:00 PM (noon) on that day, and work backwards incrementally until the date changed. The same going forward to find the end of the day.
Noon is appropriate, because (AFAIK) all time zones that have DST changes transition in the middle of the night, to minimize the impact on human beings. Presumably, the vast majority of people are awake during the day, so governments would be foolish to set DST changes during business hours.
You would want to move in 15 minute increments to cover all bases. There are some time zones with :30 or :45 minute offsets, and some that only change by 30 minutes for DST.
Now if you are going back into antiquity, this isn't the best solution because many time zones had adjustments for other reasons than DST - such as initial synchronization with UTC, which could be by some odd minutes or seconds value. So this should work fine with reasonably present dates, but not for all past dates.
If you want something that is less linear, then the algorithm would have to determine the interval of the boundaries for the time zone rule that the date fell into, then use those to check if they fall on the day in question or not. In the source code for Datetime::TimeZone, I see that it defines an internal concept of a "span". You could use DateTime::TimeZone->_span_for_datetime to find the span that the date in question fell into, and then check the start and end dates from there.
I am not a Perl programmer, so I'll leave that exercise to you or someone else. Besides, I checked and the values in the span don't appear to be unix timestamps, so I'm not quite sure how to take it from there - and they appear to be undocumented/internal so I don't think that's necessarily a good idea in Perl anyway.
Time::Local's timelocal() function is clever enough to do the right thing here if you ask for the epoch time for midnight. For 2014, DST changes are as follows:
$ zdump -v America/Sao_Paulo | fgrep 2014
America/Sao_Paulo Sun Feb 16 01:59:59 2014 UTC = Sat Feb 15 23:59:59 2014 BRST isdst=1 gmtoff=-7200
America/Sao_Paulo Sun Feb 16 02:00:00 2014 UTC = Sat Feb 15 23:00:00 2014 BRT isdst=0 gmtoff=-10800
America/Sao_Paulo Sun Oct 19 02:59:59 2014 UTC = Sat Oct 18 23:59:59 2014 BRT isdst=0 gmtoff=-10800
America/Sao_Paulo Sun Oct 19 03:00:00 2014 UTC = Sun Oct 19 01:00:00 2014 BRST isdst=1 gmtoff=-7200
So midnight is "missing" on 2014-10-19. However, if we actually ask for the epoch time for that anyway, and then convert it back into a local time:
$ TZ=America/Sao_Paulo perl -MTime::Local -E 'say scalar localtime(timelocal(0, 0, 0, 19, 9, 114))'
Sun Oct 19 01:00:00 2014
And one second before:
$ TZ=America/Sao_Paulo perl -MTime::Local -E 'say scalar localtime(timelocal(0, 0, 0, 19, 9, 114)-1)'
Sat Oct 18 23:59:59 2014
[This functionality is now available from DateTimeX::Start]
Here's a solution using only DT's public methods:
sub day_start {
my ($y, $m, $d, $tz) = #_;
$tz = DateTime::TimeZone->new( name => $tz )
if !ref($tz);
my $dt = DateTime->new( year => $y, month => $m, day => $d );
my $target_day = ( $dt->utc_rd_values )[0];
my $min_epoch = int($dt->epoch()/60) - 24*60;
my $max_epoch = int($dt->epoch()/60) + 24*60;
while ($max_epoch > $min_epoch) {
my $epoch = ( $min_epoch + $max_epoch ) >> 1;
$dt = DateTime->from_epoch( epoch => $epoch*60, time_zone => $tz );
if (( $dt->local_rd_values )[0] < $target_day) {
$min_epoch = $epoch;
} else {
$max_epoch = $epoch;
}
}
return DateTime->from_epoch(epoch => $max_epoch*60, time_zone => $tz);
}
Since most dates do have a midnight, a check should be added at the top to bypass the search when it's not needed.
Assumptions:
There is no dt to which one can add time to obtain a dt with an earlier date.
In no time zone does a date starts more than 24*60*60 seconds before the date starts in UTC.
In no time zone does a date starts more than 24*60*60 seconds after the date starts in UTC.
Jumps in time zones only occur on times with zero seconds. (Optimization)
Test:
{
# No midnight.
my $tz = DateTime::TimeZone->new( name => 'America/Sao_Paulo' );
my $dt = day_start(2013, 10, 20, $tz);
print($dt->epoch, " ", $dt->iso8601, "\n"); # 1382238000 2013-10-20T01:00:00
$dt->subtract( seconds => 1 );
print($dt->epoch, " ", $dt->iso8601, "\n"); # 1382237999 2013-10-19T23:59:59
}
{
# Two midnights.
my $tz = DateTime::TimeZone->new( name => 'America/Havana' );
my $dt = day_start(2013, 11, 3, $tz);
print($dt->epoch, " ", $dt->iso8601, "\n"); # 1383451200 2013-11-03T00:00:00
$dt->subtract( seconds => 1 );
print($dt->epoch, " ", $dt->iso8601, "\n"); # 1383451199 2013-11-02T23:59:59
}
One (cumbersome) possible solution: figure out a conservative time (say, 23:00:00 or 23:50:00--the only important part is that no date past or future should roll over before this time), and then increment that time until the date changes:
#Assume $year/$month/$day contain the date one day prior to the target date
my $dt = DateTime->new(
time_zone => $tz,
year => $year,
month => $month,
day => $day,
hour => 23,
minute => 59,
second => 0,
);
while($dt->year == $year && $dt->month == $month && $dt->day == $day) {
$dt->add(seconds => 1);
}
#At this point $dt should, if I understand the functioning of DateTime correctly, contain the earliest "valid" time in the target date.
I'm 100% sure there is a better solution to this; the ideal would be if DateTime defaulted to the earliest valid time for a given time zone, given a date with no time--currently it defaults to zero for all of those values, and I'm not certain that it will correct the value if it's not valid for that TZ. If it does internally correct those values, then that solution would be vastly preferable; it might be worth contacting the maintainer of DateTime to see what the actual behaviour is, and if said behaviour is guaranteed in the future if it is currently the desired behaviour.
You can use DateTime::TimeZone directly to query for a valid local time. DateTime::TimeZone raises an exception if the local time does not exist due to a nearby offset change.
use DateTime;
use DateTime::TimeZone;
my $zone = DateTime::TimeZone->new(name => 'America/Sao_Paulo');
my $dt = DateTime->new(year => 2013, month => 10, day => 20);
sub valid_local_time {
eval { $zone->offset_for_local_datetime($dt) };
return $# !~ /^Invalid local time/;
}
while (!valid_local_time()) {
$dt->add(minutes => 15);
}
$dt->set_time_zone($zone);
sub local_time_lt {
my ($x, $y) = #_;
return $x->local_rd_as_seconds < $y->local_rd_as_seconds;
}
sub local_time_eq {
my ($x, $y) = #_;
return $x->local_rd_as_seconds == $y->local_rd_as_seconds;
}
my $copy = $dt->clone->subtract(seconds => 1);
if (local_time_lt($dt, $copy)) {
my $delta = $copy->local_rd_as_seconds - $dt->local_rd_as_seconds;
local_time_eq($dt, $copy->subtract(seconds => $delta))
or die qq/Could not determine start of day ($dt [${\$zone->name}])/;
$dt = $copy;
}
print $dt->strftime('%H:%M'), "\n";
Is everyone missing the really obvious way to do it? It's midnight on the current day. I.e. set the seconds, minutes and hours to zero, and take the mday, mon and year fields from localtime.
use POSIX qw( mktime tzset );
$ENV{TZ} = 'America/Sao_Paulo';
tzset();
my $epoch = mktime( 0, 0, 0, 20, 10-1, 2013-1900 );
print localtime($epoch)."\n"; # Sun Oct 20 01:00:00 2013

Future Date minus Now = Days perl

Novice here. Sorry and Thanks in advance.
I have a future date
(ie:2013-06-09 / $fields[12])
I need to subtract today
(ie:2013-03-08)
to get the number of days remaining.
I'd use DateTime. If you start with the date as a string, you could use DateTime::Format::Strptime to parse it.
use DateTime qw( );
use DateTime::Format::Strptime qw( );
my $format = DateTime::Format::Strptime->new(
pattern => '%Y-%m-%d',
time_zone => 'local',
on_error => 'croak',
);
my $ref = DateTime->today( time_zone => 'local' );
my $dt = $format->parse_datetime('2013-06-09');
my $days = $ref->delta_days($dt)->in_units('days');
print(
$dt < $ref ? "$days days ago\n" :
$dt > $ref ? "$days days from now\n" :
"today\n");
use the DateTime module:
use DateTime;
my $d1 = DateTime->new(
year => 2013,
month => 9,
day => 6
);
my $d2 = DateTime->now;
my $diff = $d2->delta_days($d1);
print $diff->delta_days, "\n"; # 182 (from 8/3/2013)