Perl Weekly Challenge: Week 178

Challenge 1:

Quater-imaginary Base

Write a script to convert a given number (base 10) to quater-imaginary base number and vice-versa. For more informations, please checkout wiki page.

For example,

$number_base_10 = 4
$number_quater_imaginary_base = 10300

This is the hardest challenge I've tried in a long time. It involves complex numbers and the like and for me a complex number is any that can't be counted on fingers and toes. Mathematicians have a slightly different definition and they use complex numbers for all kinds of interesting purposes.

Take the subject of this challenge for instance. Expressing a number in this format means its digits will be from 0 - 3 only which means it can be encoded in e.g. computer memory in only 2 bits instead of the standard 4 for base 10 or 16. While storage space is not at such a premium as it was at the time that the great Donald Knuth came up with the quater-imaginary base idea, I can see how it might still be useful today.

This offered little comfort in trying to implement especially when the referenced wikipedia page is a wall of forbidding maths. Raku does have built in support for complex numbers and base conversion so I hoped it might be as simple as:

4.base(2i).say;

Unfortunately .base() only supports bases from 2 - 36. So I decided to punt and use a Module instead which is something I usually try to avoid. Base::Any supports imaginary bases so I was able to make this a one-liner after all.

raku -MBase::Any -e 'to-base(@*ARGS[0].Int, 2i).say;'

(Full code on Github.)

Unfortunately, it was not quite that simple in Perl. There are plenty of modules for converting between bases on CPAN but none of them support imaginary bases. Math::GSL::Complex looked like it might have done the trick but I was unable to get it to work.

It was then after reading the wiki page yet again I noticed that it said that you could also convert a base 10 number to quater-imaginary by converting it to base -4. My joy was short lived as none of the CPAN modules seem to support negative bases either. I searched elsewhere and found some code for negative bases on RosettaCode This is my adapted version:

It works by converting each base 10 digit to base -4 and adding it to an array. This array will be backwards so as a last step it is reverse()d. Also a 0 is placed between each digit when the array is join()ed back into a string. Surprisingly, this is all it takes to get a correct answer.

sub quaterImaginaryBase {
    my($n) = @_;
    my @result;
    my $r = 0;

    while ($n) {
        $r = $n % -4;
        $n = floor($n / -4);
        if ($r < 0) {
            $n++;
            $r += 4;
        }
        push @result, todigits($r, 4) || 0;
    }

    return join '0', reverse @result;
}

(Full code on Github.)

EDIT: after I submitted this, I realized the spec says "...and vice-versa". My code only converts to quater-imaginary base not from. Oh well.

Challenge 2:

Business Date

You are given $timestamp (date with time) and $duration in hours.

Write a script to find the time that occurs $duration business hours after $timestamp. For the sake of this task, let us assume the working hours is 9am to 6pm, Monday to Friday. Please ignore timezone too.

For example,

Suppose the given timestamp is 2022-08-01 10:30 and the duration is 4 hours.
Then the next business date would be 2022-08-01 14:30.

Similar if the given timestamp is 2022-08-01 17:00 and the duration is 3.5 hours.
Then the next business date would be 2022-08-02 11:30.

While the second challenge required more code, I was on firmer ground so it actually took me a lot less time. I will start with the Perl version first this time.

I used the DateTime module (and indirectly DateTime::Duration. There are just too many corner cases in calendar code to risk rolling your own.

The first step is to parse the timestamp into its individual components. There is a module for that, namely DateTime::Format::Strptime but using a regex is simple enough.

my ($year, $month, $day, $hour, $minute);
if ($timestamp =~ / ^ (\d{4}) [-] (\d{2}) \- (\d{2}) [ ] (\d{2}) [:] (\d{2}) $/msx) {
    ($year, $month, $day, $hour, $minute) = @{^CAPTURE};
} else {
    die "Bad timestamp format\n";
}

If the timestamp is ok, it is used to populate a DateTime object.

my $start = DateTime->new(
    year   => $year,
    month  => $month,
    day    => $day,
    hour   => $hour,
    minute => $minute
);

Another DateTime is created to represent the end of a working day.

my $endOfDay = $start->clone->set(hour => 18, minute => 0);

Yet another won represents the end of the duration i.e. $timestamp + $duration.

my $endOfDuration = $start->clone->add(hours => $duration);

If the duration ends before the end of the day all we need to do is print that time. The .strftime() method formats it appropriately.

if ($endOfDuration <= $endOfDay) {
    say $endOfDuration->strftime('%F %H:%M');
} else {

If it goes over time, we need to find out how much over and apply that difference to the next business day.

    my $difference = $endOfDuration - $endOfDay;
    say nextBusinessDay($start)->add($difference)->strftime('%F %H:%M');
}

How do we know the next business day? The aptly named nextBusinessDay() function takes care of that.

sub nextBusinessDay {

It takes the current day as input.

    my ($dt) = @_; 

A DateTime object is created which initally has the same value as the input.

    my $next = $dt->clone;

If it is a Friday...

    if ($dt->day_of_week == 5) {
        $next->add(days => 3);

...or Saturday...

    } elsif ($dt->day_of_week == 6) {
        $next->add(days => 2);

...the appropriate amount of days are added to make the next day Monday. For any other day of the week, one day is added.

    } else {
        $next->add(days => 1);
    }

Finally the time on the next day is set to 9AM. (We are never late for work!) and the new object is returned.

    return $next->set(hour => 9, minute => 0)
}

(Full code on Github.)

Here's the Raku version. It mostly works the same but Rakus' DateTime class does things a little differently.

Instead of custom output formatting being included in the class, you have to provide your own as a function which will be passed to the the DateTime objects .formatter() method.

sub format($self) {
    sprintf "%04d-%02d-%02d %02d:%02d", .year, .month, .day, .hour, .minute
        given $self;
}

sub nextBusinessDay(DateTime $dt) {
    my $next = DateTime.new(
        date => $dt.Date,
        hour => 9,
        minute => 0,
        formatter => &format
    );

Instead of .add() (and .subtract()), Raku uses .later() (and .earlier()) which makes more sense for time related objects in my opinion.

    if ($dt.day-of-week == 5) {
        $next = $next.later(days => 3);
    } elsif ($dt.day-of-week == 6) {
        $next = $next.later(days => 2);
    } else {
        $next = $next.later(days => 1);
    }

    return $next;
}

sub MAIN(
    Str $timestamp, #= a datetime string in the format YYYY-MM-DD HH:MM
    Real $duration #= a duration as a decimal number of hours
) {
    my ($year, $month, $day, $hour, $minute); 

    if $timestamp.match(/ ^ (\d ** 4) '-' (\d ** 2) '-' (\d ** 2) ' ' (\d ** 2) ':' (\d ** 2) $/) {
        ($year, $month, $day, $hour, $minute) = $/.List;
    } else {
        die "Bad timestamp format";
    }

    my $start = DateTime.new(
        year      => $year,
        month     => $month,
        day       => $day,
        hour      => $hour,
        minute    => $minute,
        formatter => &format
    );

    my $endOfDay = DateTime.new(date => $start.Date, hour => 18, minute => 0);

For some reason, I was unable to successfully change times in existing objects in any other units than seconds. I didn't stop to investigate why though.

    my $endOfDuration = $start.clone.later(seconds => 3_600 * $duration);
    if $endOfDuration <= $endOfDay {
        say $endOfDuration;
    } else {
        my $difference = $endOfDuration - $endOfDay;
        say nextBusinessDay($start).later(seconds => $difference);
    }
}

(Full code on Github.)