Perl Weekly Challenge 37: Week Days in Each Month and Daylight Gain/loss
These are some answers to the Week 37 of the Perl Weekly Challenge organized by Mohammad S. Anwar.
Spoiler Alert: This weekly challenge deadline is due in a couple of days (December 8, 2019). This blog post offers some solutions to this challenge, please don’t read on if you intend to complete the challenge on your own.
Challenge # 1: Week Days in Each Month
Write a script to calculate the total number of weekdays (Mon-Fri) in each month of the year 2019.
Jan: 23 days
Feb: 20 days
Mar: 21 days
Apr: 22 days
May: 23 days
Jun: 20 days
Jul: 23 days
Aug: 22 days
Sep: 21 days
Oct: 23 days
Nov: 21 days
Dec: 22 days
Although the challenge speaks only of year 2019, I’ll expand it a bit to compute the total number of weekdays in each month of any year passed as a parameter (defaulted to 2019 if no year is passed).
Days in Week in Perl 5
There are many core or CPAN modules to deal with dates, many of which can be used for the task. However, it is a programming challenge. To me, if you let a module do the work for you, then you don’t really solve the challenge yourself. And you don’t learn much from the challenge. Plus you don’t really get the fun of solving something by yourself.
However, I don’t want to be dogmatic on that: this doesn’t mean that I don’t want to use any module. I just don’t want to let all the work been done by a module. In fact, I’ll use a Perl core module here, Time::timegm, but only to find the day in the week of the first day of the year.
Of course, if you have a module providing the weekday of any date, then you could just iterate over every day of every month of a year and call the appropriate function of that module (such as timegm
) for each such day and easily compute the number of week days in any month of any year. But that’s not fun. So, I’ll use the module to find out the weekday of Jan. 1st and then then calculate everything by myself throughout the year under scrutiny.
For this, I need a @months
array giving me the number of days in each month. This is straight forward common knowledge, except for the fact that leap years have 29 days in February. So we need to figure out if the year passed as a parameter is leap. In the Julian calendar (named after Julius Caesar), a year is leap if it is evenly divided by 4. Fairly simple. But the Julian calendar is slightly inaccurate and drifts of about 3 days every 4 centuries as compared to astronomy (the date of the equinoxes). That’s why Pope Gregory XIII introduced what is now known as the Gregorian Calendar in October 1582. According to the Gregorian calendar that we commonly use today, years evenly divided by 4 are not leap if they are also evenly divided by 100, unless they are also evenly divided by 400. For example, the years 1700, 1800, 1900, 2100 and 2200 are not leap years, but the years 1600, 2000, and 2400 are leap years. Having said that, I should point out that the Julian rule for leap years is consistent with the Gregorian rule for all years between 1901 and 2099, so that a simple is_leap
subroutine implemented as follows:
sub is_leap {
my $year = shift;
return $year % 4 == 0;
}
would be appropriate for the 1901-2099 year range. So, this simple Julian rule is probably sufficient for any business application written nowadays. If, however, you want to make sure you’re accurate over a longer range of dates, then you need to implement the Gregorian calendar rule, which could lead to this new is_leap
subroutine:
#!/usr/bin/perl
use strict;
use warnings;
use feature qw/say/;
sub is_leap {
my $year = shift;
return $year % 4 ? 0 : # not evenly divided by 4 -> not leap
$year % 100 ? 1 : # divided evenly by 4 but not 100 -> leap
$year % 400 ? 0 : # divided by 100 but not 400 -> not leap
1; # divided by 400
}
say is_leap(shift) ? "Yes" : "No";
Just for fun, we’ll use a slightly different implementation in our program below.
Once we have the number of days for each month of any year (including February) and the weekday of Jan. 1st, then it is fairly easy to compute the day in the week of any date in the year. But we don’t really need to do that for every single date: Any month, including February, has four weeks, and thus 20 weekdays, between the 1st and the 28th day. Thus, we only need to figure out the day in week of days between the 29th day and the month end. And we also know that the day in week of 29th day of a month is the same as the day in week of the 1st of the month.
So we need to iterate over every month, initialize the number of weekdays of that month to 20 and add one for each day after the 28th that falls on a weekday, and at the same time keep track of the weekday for these days, so that we can then start the next month knowing which day is the 1st day of that new month. In the timegm
function, days in week (the 7th returned value) are represented by a number between 0 and 6, with 0 being Sunday. So, a date is a weekday of its day in week value is not 0 and not 6. In the inner for
loop, we just add one for every new day, so that our calculated day in week might be temporarily 7, 8 of 9. Because of that, a value of 7 will also be a Sunday (and 8 and 9 will be, respectively, Monday and Tuesday. So, we count days in week 0, 6 and 7 as non weekdays. Once we have finished the inner loop, we subtract 7 from the current day in week value if needed to re-normalize the day in week to a 0..6
range.
#!/usr/bin/perl
use strict;
use warnings;
use feature qw/say/;
use Time::Local;
my $yr = shift // 2019;
my @months = (0, 31, is_leap($yr) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31);
my $start_date = timegm( 0, 0, 0, 1, 0, $yr - 1900 ); # Jan 1st
my $day_in_week = (gmtime $start_date)[6];
for my $month (1..12) {
my $weekdays = 20;
for my $day (29..$months[$month]) {
$weekdays ++ unless $day_in_week =~ /[607]/;
$day_in_week ++;
}
printf "%02d/%d has $weekdays week days.\n", $month, $yr;
$day_in_week -= 7 if $day_in_week > 6;
}
sub is_leap {
my $yr = shift;
return 0 if $yr % 4; # no if not divisible by 4
return 1 if $yr % 100; # yes if divisible by 4 but not by 100
return 0 if $yr % 400; # no if divisible by 100 and not by 400
return 1; # yes if divisibe by 400
}
Note that the @months
array has 13 items, the first one being 0 (it could be anything). The reason is that we’re using subscripts 1 to 12 for the months, so that the item at index 0 is simply not used.
This program displays the following output:
$ perl weekdays.pl 2019
01/2019 has 23 week days.
02/2019 has 20 week days.
03/2019 has 21 week days.
04/2019 has 22 week days.
05/2019 has 23 week days.
06/2019 has 20 week days.
07/2019 has 23 week days.
08/2019 has 22 week days.
09/2019 has 21 week days.
10/2019 has 23 week days.
11/2019 has 21 week days.
12/2019 has 22 week days.
It also works fine if we pass another year:
$ perl weekdays.pl 2020
01/2020 has 23 week days.
02/2020 has 20 week days.
03/2020 has 22 week days.
04/2020 has 22 week days.
05/2020 has 21 week days.
06/2020 has 22 week days.
07/2020 has 23 week days.
08/2020 has 21 week days.
09/2020 has 22 week days.
10/2020 has 22 week days.
11/2020 has 21 week days.
12/2020 has 23 week days.
And, with no parameter passed, it displays the weekdays for 2019 (the default value).
Days in Week in Raku (formerly known as Perl 6)
We could easily do the same in Raku, but Raku has expressive and efficient built-in features for date manipulations in the Date class.
This is an example under the REPL:
> my $date = Date.new(2019, 1, 1)
2019-01-01
> say $date.month;
1
> say $date.day-of-week;
2
So, Jan., 1st, 2019 fell on a Tuesday (day in week 2), and it is the first month (January).
Thus, using the methods demonstrated above, we could write simple a one-liner (formatted here over 2 lines to make more readable on this blog post) to find the result:
$ perl6 -e 'my @a; for Date.new(2019, 1, 1) .. Date.new(2019, 12, 31) -> $day
> { @a[$day.month]++ if $day.day-of-week == (1..5).any}; say @a[1..12];
'
(23 20 21 22 23 20 23 22 21 23 21 22)
For every date in the year, we increment a counter for the date’s month if that data is a weekday. Note the use of the (1..5).any
junction to simplify comparisons with the 1..5
range.
We could even add a little bit of sugar to improve the output:
$ perl6 -e 'my @a; for Date.new(2019, 1, 1) .. Date.new(2019, 12, 31) -> $day
> { @a[$day.month]++ if $day.day-of-week == (1..5).any};
> for @a[1..12].kv -> $k, $v {printf "%02d/2019: %d week days\n", $k+1, $v};
> '
01/2019: 23 week days
02/2019: 20 week days
03/2019: 21 week days
04/2019: 22 week days
05/2019: 23 week days
06/2019: 20 week days
07/2019: 23 week days
08/2019: 22 week days
09/2019: 21 week days
10/2019: 23 week days
11/2019: 21 week days
12/2019: 22 week days
But that’s perhaps getting a bit long for a one-liner. Let’s do a real program.
We will use the same general method as in Perl 5, i.e. iterating on the days after the 28th day of any month to find the number of weekdays in that interval, except that it can be simplified thanks to the Date
class numerous method. First, Raku has a is-leap-year methodmethodis-leap-year), so we don’t need to implement it ourselves. But, in fact, we don’t even need to use this is-leap-year
method, since the Date
class also provides a days-in-month methodmethoddays-in-month) returning directly what we really need: the number of days in a given month.
The program is very simple and significantly shorter than its Perl 5 counterpart:
use v6;
sub MAIN (UInt $yr = 2019) {
for 1..12 -> $mth {
my $weekdays = 20;
for 29..Date.new($yr, $mth, 1).days-in-month -> $day {
$weekdays++ if
Date.new($yr, $mth, $day).day-of-week == (1..5).any;
}
printf "%02d/%d has $weekdays week days.\n", $mth, $yr;
}
}
This program displays the following output:
$ perl6 weekdays.p6 2019
01/2019 has 23 week days.
02/2019 has 20 week days.
03/2019 has 21 week days.
04/2019 has 22 week days.
05/2019 has 23 week days.
06/2019 has 20 week days.
07/2019 has 23 week days.
08/2019 has 22 week days.
09/2019 has 21 week days.
10/2019 has 23 week days.
11/2019 has 21 week days.
12/2019 has 22 week days.
And it works fine with another year passed as an argument. If no argument is passed, the program correctly displays the result for the default input value, year 2019.
Task 2: Daylight Loss or Gain
Write a script to find out the Daylight gain/loss in the month of December 2019 as compared to November 2019 in the city of London. You can find out sunrise and sunset data for November 2019 and December 2019 for London.
A look at the links provided reveals that the linked pages provide not only sunrise and sunset data, but also daylight duration, which is really the input data we’re looking for. Not only is it going to be slightly easier to use directly daylight duration, but daylight values are also sixty times more accurate: sunrise and sunset have an accuracy of a minute, whereas daylight duration are precise to the second (so that, in fact, it won’t really be easier, since our calculations will need to be more accurate (and that’s a bit of a pain in the neck when values are given in sexagesimal or base-60 notation).
Otherwise, the requirement is not very clear, but I’ll take it to mean that we want to compute the daylight gain or loss between each day of December 2019 and the corresponding day in November 2019. Since November has only 30 days, we won’t be able to say anything about December 31, 2019, as there is no corresponding day in November. We will also compute the average daylight gain or loss (well, it’s obviously a loss, but we’ll assume we don’t know and will let the computer program find this out).
My final comment is that I haven’t used a Perl program for scraping data on the Internet for the last 15 years or so, and I don’t want to try to re-learn that in just a couple of days. Therefore, I just copied and pasted the data into a text file and edited it to remove useless data; and I’ll use that text file as input for my programs.
Daylight in Perl 5
In order to compute differences between values in sexagesimal notation (in base 60, i.e. expressed in hours/minutes/seconds), there are at least two strategies: you could implement a sexagesimal subtraction, with special rules for carry or borrow, or you could convert everything to seconds, perform standard arithmetic operations on values in seconds, and convert the result back into HMS sexagesimal values if needed. I chose the second solution (although, thinking again about it, the first solution might have been slightly simpler, but most software implementations of such problems rely on timestamps expressed in seconds elapsed since an arbitrary time origin often called the epoch). Anyway, the sec3hrs
and hrs2sec
subroutines perform the necessary conversions from and to seconds.
The program first reads through the input data and stores the daylight data into a @nov
and a @dec
arrays. Then it loops through the 1..30
range and, for each day, subtract the November daylight value from the December daylight value. The program also keeps track of a cumulative $total_diff
change to be able to compute an average change over 30 days.
#!/usr/bin/perl
use strict;
use warnings;
use feature qw/say/;
sub hrs2sec {
my ($hrs, $min, $sec) = split /:/, shift;
return $hrs * 3600 + $min * 60 + $sec;
}
sub sec2hrs {
my $sec = shift;
my $hrs = int $sec / 3600;
$sec = $sec % 3600;
my $min = int $sec / 60;
$sec = $sec % 60;
return sprintf "$hrs:%02d:%02d", $min, $sec;
}
my (@nov, @dec);
my $aref = \@nov;
while (<DATA>) {
chomp;
$aref = \@dec if /^Dec/;
next unless /^\d/;
my ($date, $duration) = (split /\s+/)[0, 3];
$aref->[$date] = hrs2sec $duration;
}
my $total_diff;
say "Daylight change between:";
for my $i (1..30) {
my $diff = $dec[$i] - $nov[$i];
$total_diff += $diff;
my $dif_hrs = sec2hrs abs $diff;
$dif_hrs = "- $dif_hrs" if $diff < 0;
printf "%02d Nov and %02d Dec: $dif_hrs\n", $i, $i;
}
say "Average change between Nov and Dec: ", $total_diff < 0 ? "- " : "", sec2hrs (abs $total_diff / 30);
__DATA__
Sunrise Sunset Length
Nov 2019
1 06h53 16h34 9:40:44
2 06h55 16h32 9:37:10
3 06h56 16h30 9:33:37
4 06h58 16h28 9:30:07
5 07h00 16h27 9:26:38
6 07h02 16h25 9:23:11
7 07h03 16h23 9:19:45
8 07h05 16h22 9:16:22
9 07h07 16h20 9:13:01
10 07h09 16h18 9:09:42
11 07h10 16h17 9:06:25
12 07h12 16h15 9:03:11
13 07h14 16h14 8:59:59
14 07h16 16h12 8:56:50
15 07h17 16h11 8:53:44
16 07h19 16h10 8:50:40
17 07h21 16h08 8:47:39
18 07h22 16h07 8:44:42
19 07h24 16h06 8:41:48
20 07h26 16h05 8:38:57
21 07h27 16h04 8:36:09
22 07h29 16h03 8:33:25
23 07h31 16h01 8:30:45
24 07h32 16h00 8:28:09
25 07h34 15h59 8:25:36
26 07h35 15h59 8:23:08
27 07h37 15h58 8:20:44
28 07h38 15h57 8:18:24
29 07h40 15h56 8:16:09
30 07h41 15h55 8:13:59
Dec 2019
1 07h43 15h55 8:11:53
2 07h44 15h54 8:09:53
3 07h46 15h53 8:07:57
4 07h47 15h53 8:06:07
5 07h48 15h53 8:04:22
6 07h49 15h52 8:02:42
7 07h51 15h52 8:01:08
8 07h52 15h51 7:59:40
9 07h53 15h51 7:58:17
10 07h54 15h51 7:57:00
11 07h55 15h51 7:55:50
12 07h56 15h51 7:54:45
13 07h57 15h51 7:53:46
14 07h58 15h51 7:52:54
15 07h59 15h51 7:52:07
16 08h00 15h51 7:51:27
17 08h00 15h51 7:50:54
18 08h01 15h52 7:50:27
19 08h02 15h52 7:50:06
20 08h02 15h52 7:49:52
21 08h03 15h53 7:49:44
22 08h04 15h53 7:49:43
23 08h04 15h54 7:49:48
24 08h04 15h54 7:50:00
25 08h05 15h55 7:50:19
26 08h05 15h56 7:50:44
27 08h05 15h57 7:51:15
28 08h06 15h57 7:51:53
29 08h06 15h58 7:52:37
30 08h06 15h59 7:53:27
31 08h06 16h00 7:54:24
The program is fairly straight forward. Note that in order to use the same loop to populate both the @nov
and @dec
arrays, the program uses an aref
array ref pointing to either of the arrays, depending the part of the input data we’re reading.
This program displays the following output:
$ perl day_light.pl
Daylight change between:
01 Nov and 01 Dec: - 1:28:51
02 Nov and 02 Dec: - 1:27:17
03 Nov and 03 Dec: - 1:25:40
04 Nov and 04 Dec: - 1:24:00
05 Nov and 05 Dec: - 1:22:16
06 Nov and 06 Dec: - 1:20:29
07 Nov and 07 Dec: - 1:18:37
08 Nov and 08 Dec: - 1:16:42
09 Nov and 09 Dec: - 1:14:44
10 Nov and 10 Dec: - 1:12:42
11 Nov and 11 Dec: - 1:10:35
12 Nov and 12 Dec: - 1:08:26
13 Nov and 13 Dec: - 1:06:13
14 Nov and 14 Dec: - 1:03:56
15 Nov and 15 Dec: - 1:01:37
16 Nov and 16 Dec: - 0:59:13
17 Nov and 17 Dec: - 0:56:45
18 Nov and 18 Dec: - 0:54:15
19 Nov and 19 Dec: - 0:51:42
20 Nov and 20 Dec: - 0:49:05
21 Nov and 21 Dec: - 0:46:25
22 Nov and 22 Dec: - 0:43:42
23 Nov and 23 Dec: - 0:40:57
24 Nov and 24 Dec: - 0:38:09
25 Nov and 25 Dec: - 0:35:17
26 Nov and 26 Dec: - 0:32:24
27 Nov and 27 Dec: - 0:29:29
28 Nov and 28 Dec: - 0:26:31
29 Nov and 29 Dec: - 0:23:32
30 Nov and 30 Dec: - 0:20:32
Average change between Nov and Dec: - 0:58:20
Note that all the change values are negative, meaning that we have a daylight loss for any corresponding day between December and November (I expected it, since the winter solstice, the shortest day in the year, occurs on December 22, i.e. in the last third of December, but it’s better to have hard data proving it).
Daylight in Raku
In order to compute differences between values in sexagesimal notation (in base 60, i.e. expressed in hours/minutes/seconds), we will convert everything to seconds, perform arithmetic operations on values in seconds, and convert the result back into HMS sexagesimal values if needed. The sec3hrs
and hrs2sec
subroutines perform the necessary conversions from and to seconds.
The program first reads through the input data and stores the daylight data into an @nov
and an @dec
arrays. Then it loops through the 1..30
range and, for each day, subtract the November daylight value from the December daylight value. The program also computes an average change over 30 days.
Compared to the Perl 5 implementation, the data for November and December 2019 are stored into separate text files (same format as above), because Raku doesn’t have the __DATA__
features; it should have much more feature-rich capabilities using pod (plain old documentation) sections, but these are not implemented yet. Otherwise, the sec2hrs
subroutine is much simpler because it uses the multiple modulo polymod method to convert directly seconds into hours, minutes and seconds. Also, we used the Z-
zip) metaoperator along with the -
subtract operator to compute all the duration differences in just one single statement. Overall, these changes make the actual code twice shorter than the P5 implementation:
use v6;
sub hrs2sec ($hms) {
my ($hrs, $min, $sec) = split /\:/, $hms;
return $hrs * 60² + $min * 60 + $sec;
}
sub sec2hrs (Numeric $sec) {
my @duration = $sec.abs.polymod(60, 60);
my $fmt = ($sec < 0 ?? "-" !! "") ~ "%d:%02d:%02d";
return sprintf $fmt, @duration[2, 1, 0];
}
my @nov = 'november_2019.txt'.IO.lines[0..29].map({(.split(/\s+/))[3]});
my @dec = 'december_2019.txt'.IO.lines[0..29].map({(.split(/\s+/))[3]});
my @diff = @dec.map({hrs2sec $_}) Z- @nov.map({hrs2sec $_});
say "Daylight changes between Dec and Nov:";
for @diff.kv -> $k, $v { printf "%2d: %s\n", $k + 1, sec2hrs( $v) };
say "\nAverage change between Nov and Dec: ", sec2hrs ([+] @diff) / 30;
This program displays more or less the same output at the P5 program:
$ perl6 day_light.p6
Daylight changes between Dec and Nov:
1: -1:28:51
2: -1:27:17
3: -1:25:40
4: -1:24:00
5: -1:22:16
6: -1:20:29
7: -1:18:37
8: -1:16:42
9: -1:14:44
10: -1:12:42
11: -1:10:35
12: -1:08:26
13: -1:06:13
14: -1:03:56
15: -1:01:37
16: -0:59:13
17: -0:56:45
18: -0:54:15
19: -0:51:42
20: -0:49:05
21: -0:46:25
22: -0:43:42
23: -0:40:57
24: -0:38:09
25: -0:35:17
26: -0:32:24
27: -0:29:29
28: -0:26:31
29: -0:23:32
30: -0:20:32
Average change between Nov and Dec: -0:58:20
Wrapping up
The next week Perl Weekly Challenge is due to start soon. If you want to participate in this challenge, please check https://perlweeklychallenge.org/ and make sure you answer the challenge before 23:59 BST (British summer time) on Sunday, December 15, 2019. And, please, also spread the word about the Perl Weekly Challenge if you can.
Leave a comment