almanac 0.1.0

Author

Davis Vaughan

Published

May 27, 2020

I’m very excited to announce that the first release of almanac has made its way to CRAN! almanac is a package for working with recurring events. These typically include dates that occur on some kind of recurring basis, like weekends or holidays. As you’ll soon see, one of the most powerful features of almanac is the ability to build up a set of these recurring events, like a company’s holiday calendar, so that you can then shift a vector of dates by, say, 5 business days, skipping over any weekends or holidays that might be specific to your company.

library(almanac)

Recurrence Rules

To start working with recurring events, we’ll need a way to define when those events happen. This is known as a recurrence rule, and is most easily built up using a chain of pipes like this:

on_thursday <- weekly() %>%
  recur_on_wday("Thursday")

This rule defines Thursdays as “events”. A Thursday comes around on a weekly() basis, but you can also create rules for daily(), monthly(), and yearly(). The call to recur_on_wday() further isolates exactly when the event occurs in the week. I call these recurrence conditions, and almanac comes with a whole family of them. All recurrence condition functions start with recur_*().

You can generate the events that fall between two dates with alma_search(). For example, here are all of the Thursdays in January 2019.

alma_search("2019-01-01", "2019-01-31", on_thursday)
#> [1] "2019-01-03" "2019-01-10" "2019-01-17" "2019-01-24" "2019-01-31"

A more common use case is to create a rule that defines when a particular holiday happens. For example, Thanksgiving happens on the 4th Thursday in November. Here’s a recurrence rule for Thanksgiving:

on_thanksgiving <- yearly() %>%
  recur_on_wday("Thursday", nth = 4) %>%
  recur_on_ymonth("November")

on_thanksgiving
#> <rrule[yearly / 1900-01-01 / 2100-01-01]>
#> - ymonth: Nov
#> - wday: Thu[4]

Search for all Thanksgivings between 2010-2015:

alma_search("2010-01-01", "2015-12-31", on_thanksgiving)
#> [1] "2010-11-25" "2011-11-24" "2012-11-22" "2013-11-28" "2014-11-27"
#> [6] "2015-11-26"

You can also check if a particular date is contained in a rule’s event set (the set of dates that that rule considers events) using alma_in().

x <- as.Date(c("2015-11-26", "2015-11-27"))

alma_in(x, on_thanksgiving)
#> [1]  TRUE FALSE

Recurrence Bundles

Recurrence rules just scratch the surface of what almanac can do. They are powerful on their own, but they can’t answer every question. An easy way to see their limitations is to try and construct a rule that defines Christmas or Thanksgiving as events. You can easily create rules for the individual holidays, but it would be impossible to create 1 rule that captures both. This is where recurrence bundles come in. They allow you to combine the event sets of multiple rules together in a variety of ways using set-based logic. Here’s a recurrence bundle for Christmas or Thanksgiving:

on_christmas <- yearly() %>%
  recur_on_mday(25) %>%
  recur_on_ymonth("December")

hldy_bundle <- runion() %>%
  add_rschedule(on_christmas) %>%
  add_rschedule(on_thanksgiving)

runion() initializes a new recurrence bundle that takes the union of the event sets of each recurrence rule you pass it. almanac also comes with rintersect() and rsetdiff() bundle types. Below, we’ll use alma_next() to generate the next events after these particular dates.

dates <- as.Date(c("2019-11-01", "2019-12-01"))

# The first event after 2019-11-01 is Thanksgiving
# The first event after 2019-12-01 is Christmas
alma_next(dates, hldy_bundle)
#> [1] "2019-11-28" "2019-12-25"

You can even add recurrence bundles to other recurrence bundles to make infinitely complex rules. For example, maybe we want all Thanksgiving and Christmas holidays, except for Thanksgiving dates that occur on the 26th of the month and Christmas dates that occur on a Wednesday.

First we can create a bundle for the dates we want to exclude:

on_26th <- monthly() %>%
  recur_on_mday(26)

on_wednesday <- weekly() %>%
  recur_on_wday("Wed")

exclusion_bundle <- runion() %>%
  add_rschedule(on_26th) %>%
  add_rschedule(on_wednesday)

Then we can create a setdiff bundle to remove them from the holiday event set:

hldy_bundle_with_exclusions <- rsetdiff() %>%
  add_rschedule(hldy_bundle) %>%
  add_rschedule(exclusion_bundle)

The order matters with this rsetdiff() bundle creation. If the order was flipped, it would be all Wednesdays and 26ths of the month except for those on Christmas and Thanksgiving.

To validate that, let’s generate some events before and after applying the exclusion criteria and check the results:

from <- as.Date("2010-01-01")
to <- as.Date("2015-12-31")

hldys_2010_2015 <- alma_search(from, to, hldy_bundle)
hldys_exclude_2010_2015 <- alma_search(from, to, hldy_bundle_with_exclusions)

# Find holidays that don't exist in the exclusion bundle
exists <- hldys_2010_2015 %in% hldys_exclude_2010_2015
not_exists <- !exists

# 2013-12-25 - A Wednesday
# 2015-11-26 - On the 26th
hldys_2010_2015[not_exists]
#> [1] "2013-12-25" "2015-11-26"

Adjusters

There are other things that you can do with these recurrence rules and bundles beyond just generating dates in their event sets. One powerful idea is to take an existing vector of dates and adjust it in the places where it lands on an event defined by a recurrence bundle.

almanac comes with a number of adjusters that specify what kind of adjustment to make when this happens. For example, adj_following() will adjust to the next non-event date, and adj_preceding() will adjust to the preceding one.

christmas <- "2019-12-25"

adj_following(christmas, on_christmas)
#> [1] "2019-12-26"

adj_preceding(christmas, on_christmas)
#> [1] "2019-12-24"

Adjusted rules

These adjusters are critical low-level components that power more interesting aspects of almanac. One of those is an adjusted rule.

To motivate it, imagine your company deems Christmas to be a holiday. Whenever Christmas rolls around on the 25th of December, you get that day off. But what happens when Christmas falls on a Saturday? What about Sunday? Most corporations will observe a holiday that falls on the weekend on the nearest working day instead of on the weekend date that it actually occurred on.

In almanac, it seems like this would pose a problem. You can create rules for Christmas and for weekends, but a recurrence bundle like runion, rintersect, or rsetdiff can only perform some kind of set operation on those individual rules. What you really need is a way to say: recur on the dates defined by this rule, unless it intersects with this second rule. In those cases, apply an adjustment to the intersected dates to create valid dates. This is the job of the adjusted rule.

# A rule for weekends
on_weekends <- weekly() %>%
  recur_on_weekends()

# Create an adjusted rule that normally occurs on Christmas,
# unless Christmas is on a weekend, in which case it rolls to 
# the nearest non-event date (so this rolls Saturday Christmas
# dates to Friday, and Sunday dates to Monday).
on_adjusted_christmas <- radjusted(
  rschedule = on_christmas,
  adjust_on = on_weekends,
  adjustment = adj_nearest
)

on_adjusted_christmas
#> <radjusted>
#> 
#> Adjust:
#> <rrule[yearly / 1900-01-01 / 2100-01-01]>
#> - ymonth: Dec
#> - mday: 25
#> 
#> Adjust on:
#> <rrule[weekly / 1900-01-01 / 2100-01-01]>
#> - wday: Sat, Sun

This is just another type of recurrence object, so it can be used with all of the other alma_*() functions we have seen so far. For example, we can confirm that Christmas dates that fall on the weekend are adjusted appropriately by searching for a few of them.

# Note 2004-12-24, which was rolled back from 2004-12-25, a Saturday.
# Note 2005-12-26, which was rolled forward from 2005-12-25, a Sunday.
alma_search("2002-01-01", "2006-01-01", on_adjusted_christmas)
#> [1] "2002-12-25" "2003-12-25" "2004-12-24" "2005-12-26"

Stepping

library(lubridate, warn.conflicts = FALSE)

alma_step() allows you to take an existing vector of dates and shift it by a number of days, “stepping over” any events in the event set defined by a recurrence object. This is generally useful for shifting by “N business days”, where the logic for a business day is encapsulated in the rule.

You can think of alma_step() as a way to replace lubridate’s x + days(5) with x + business_days(5) where business_days() is specific to your company’s holiday calendar.

In the following example, we shift a Thursday and Friday by 2 working days. Notice that Thursday is shifted to Monday and Friday is shifted forward to Tuesday.

# A Thursday / Friday pair
x <- as.Date(c("2019-12-19", "2019-12-20"))

# Shift by 2 working days, stepping over weekends
step <- alma_step(x, n = 2, rschedule = on_weekends)

data.frame(
  x = x,
  x_wday = wday(x, label = TRUE),
  step = step,
  step_wday = wday(step, label = TRUE)
)
#>            x x_wday       step step_wday
#> 1 2019-12-19    Thu 2019-12-23       Mon
#> 2 2019-12-20    Fri 2019-12-24       Tue

Internally, n is applied 1 day at a time. adj_following() is called after each 1 day shift if n is positive, otherwise adj_preceding() is called.

To break this down, we’ll analyze that Friday.

  • Start on 2019-12-20, a Friday.
  • Step forward 1 day, to 2019-12-21, a Saturday.
  • Apply adj_following(), landing us on Monday, 2019-12-23.
  • Step forward 1 day, to 2019-12-24, a Tuesday.
  • Apply adj_following(), but nothing needs to be done.

Steppers

alma_step() is nice, but it would be really nice to have something like lubridate’s x + days(5) syntax, but relative to a recurrence rule. Due to some issues with how R’s S3 dispatch system works with +, this isn’t exactly replicable with almanac, but you can get close.

lubridate uses R’s S4 object oriented system to get it to work, but I don’t want to go there

First off, you need an object the holds information about how to shift relative to a recurrence rule. You can create one of these with stepper(). The only thing you give stepper() is the rule to step relative to. It returns a function of 1 argument, n, which you’ll call with the desired number of days to shift. The resulting object can be added to or subtracted from your vector of dates. It sounds a little complicated, but hopefully things will clear up with an example. Let’s reproduce the last example from the previous section:

working_days <- stepper(on_weekends)

x %s+% working_days(2)
#> [1] "2019-12-23" "2019-12-24"

Notice the usage of %s+%. This replaces +, and allows you to step forward. There is also a %s-% for stepping backwards.

The nice thing about working_days() is that you can continue to use it on other date vectors.

# A Wednesday
wednesday <- as.Date("2019-12-18")

# Returns Thursday, Friday, Monday
wednesday %s+% working_days(1:3)
#> [1] "2019-12-19" "2019-12-20" "2019-12-23"

Vacation

I don’t really expect you to build all of your holidays and calendars from scratch. almanac holds the building blocks so that this is all possible, but an add-on package, vacation, will eventually hold pre-generated holidays and calendars (like the US Federal calendar) with more bells and whistles.

Learning More

To learn more about almanac, visit the pkgdown site. In particular, head over to the Introduction to almanac vignette.