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 hrs2secsubroutines 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 @novand @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 @novand 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

About laurent_r

user-pic I am the author of the "Think Perl 6" book (O'Reilly, 2017) and I blog about Perl (5 and 6).