Introduction to phinterval

library(phinterval)
library(lubridate, warn.conflicts = FALSE)
library(dplyr, warn.conflicts = FALSE)
library(tidyr, warn.conflicts = FALSE)

Introduction

The phinterval package extends {lubridate} to support disjoint (“holey”) and empty time spans. It implements the <phinterval> vector class, a generalization of the standard contiguous <Interval>, which can represent:

This package is designed to easily integrate into existing lubridate workflows. Any <Interval> vector can be converted to an equivalent <phinterval> vector using as_phinterval(), and all phinterval functions accept either <Interval> or <phinterval> inputs.

When Time Isn’t Continuous

Certain set operations on time spans naturally produce empty or disjoint results, which are difficult to represent using a standard interval. This section illustrates several such edge cases using the months of January and November 2025, along with the full calendar year.

jan <- interval(ymd("2025-01-01"), ymd("2025-02-01"))
nov <- interval(ymd("2025-11-01"), ymd("2025-12-01"))
full_2025 <- interval(ymd("2025-01-01"), ymd("2026-01-01"))

Empty Intersections

Because January and November do not overlap, their intersection should contain no time.

lubridate::intersect(jan, nov)
#> [1] NA--NA

phint_intersect(jan, nov)
#> <phinterval<UTC>[1]>
#> [1] <hole>

In lubridate this is resolved by coercing the intersection to NA, while phinterval returns a <hole>, which explicitly represents an empty span of time.

This distinction matters when performing downstream calculations. For example, counting the number of days contained in both January and November:

lubridate::intersect(jan, nov) / duration(days = 1)
#> [1] NA

phint_intersect(jan, nov) / duration(days = 1)
#> [1] 0

Punching Holes in Intervals

Next, consider subtracting the month of November from the full year of 2025.

try(lubridate::setdiff(full_2025, nov))
#> Error in setdiff.Interval(full_2025, nov) : 
#>   Cases 1 result in discontinuous intervals.

phint_setdiff(full_2025, nov)
#> <phinterval<UTC>[1]>
#> [1] {2025-01-01--2025-11-01, 2025-12-01--2026-01-01}

The result is two disjoint spans, January through October and December, which can’t be represented by a single interval. As a result, lubridate raises an error. In phinterval, the disjoint span is represented as a single object with an explicit gap.

Unions of Non-Overlapping Spans

Similarly, the union of January and November contains a gap from February to October.

lubridate::union(jan, nov)
#> [1] 2025-01-01 UTC--2025-12-01 UTC

phint_union(jan, nov)
#> <phinterval<UTC>[1]>
#> [1] {2025-01-01--2025-02-01, 2025-11-01--2025-12-01}

In this case lubridate returns the span from the beginning of January to the end of November, implicitly filling in the gap. The two disjoint months are represented explicitly using phinterval.

Subtracting an Interval from Itself

Finally, consider subtracting an interval from itself. Intuitively, this should result in an empty time span.

lubridate::setdiff(jan, jan)
#> [1] 2025-01-01 UTC--2025-02-01 UTC

phint_setdiff(jan, jan)
#> <phinterval<UTC>[1]>
#> [1] <hole>

In this case, lubridate returns the original interval, while phinterval returns a <hole>.

Case Study: Employment History

The phinterval package is most useful when working with tabular data and vectorized workflows. To illustrate this, we’ll consider an abridged employment history for several characters from the television show Succession.

jobs <- tribble(
  ~name,   ~job_title,             ~start,        ~end,
  "Greg",  "Mascot",               "2018-01-01",  "2018-06-03",
  "Greg",  "Executive Assistant",  "2018-06-10",  "2020-04-01",
  "Greg",  "Chief of Staff",       "2020-03-01",  "2020-11-28",
  "Tom",   "Chairman",             "2019-05-01",  "2020-11-10",
  "Tom",   "CEO",                  "2020-11-10",  "2020-12-31",
  "Shiv",  "Political Consultant", "2017-01-01",  "2019-04-01"
)

Suppose we know that Greg, Tom, and Shiv went on a Christmas vacation in December 2017.

vacation <- interval(ymd("2017-12-23"), ymd("2017-12-29"))

If we want to analyze only the time spent working, and exclude time on vacation, we might try to subtract the vacation interval from each span in jobs. However, this approach breaks down when the vacation falls strictly within a job interval, as it does for Shiv’s Political Consultant role.

try(
  jobs |>
    mutate(
      span = interval(start, end),
      span = setdiff(span, vacation)
    ) |>
    select(name, job_title, span)
)
#> Error in mutate(jobs, span = interval(start, end), span = setdiff(span,  : 
#>   ℹ In argument: `span = setdiff(span, vacation)`.
#> Caused by error in `setdiff.Interval()`:
#> ! Cases 6 result in discontinuous intervals.

Handling this correctly is surprisingly involved. One option is to split Shiv’s job into two rows (one pre-vacation and one post-vacation), breaking the one-row-per-job structure of the data. Another is to represent each job as a list of intervals, complicating downstream analysis.

The main purpose of phinterval is to avoid these workarounds, by providing drop-in replacements for lubridate interval functions. Because phinterval functions accept either <Interval> or <phinterval> inputs, existing code can typically be adapted by simply replacing a lubridate function with its phinterval counterpart.

jobs |>
  mutate(
    span = interval(start, end),
    span = phint_setdiff(span, vacation)
  ) |>
  select(name, job_title, span)
#> # A tibble: 6 × 3
#>   name  job_title            span                       
#>   <chr> <chr>                <phint<UTC>>               
#> 1 Greg  Mascot               {2018-01-01--2018-06-03}   
#> 2 Greg  Executive Assistant  {2018-06-10--2020-04-01}   
#> 3 Greg  Chief of Staff       {2020-03-01--2020-11-28}   
#> 4 Tom   Chairman             {2019-05-01--2020-11-10}   
#> 5 Tom   CEO                  {2020-11-10--2020-12-31}   
#> 6 Shiv  Political Consultant {2017-01-01-[2]-2019-04-01}

Merging Intervals

Suppose we want to analyze only the total time each character spent employed, without distinguishing between individual jobs. This can be done using phint_squash(), which aggregates a vector of intervals into a minimal set of non-overlapping spans within a scalar <phinterval>.

employment <- jobs |>
  mutate(span = interval(start, end)) |>
  group_by(name) |>
  summarize(employed = phint_squash(span))

employment
#> # A tibble: 3 × 2
#>   name  employed                                        
#>   <chr> <phint<UTC>>                                    
#> 1 Greg  {2018-01-01--2018-06-03, 2018-06-10--2020-11-28}
#> 2 Shiv  {2017-01-01--2019-04-01}                        
#> 3 Tom   {2019-05-01--2020-12-31}

Notice that:

The by argument of phint_squash() and datetime_squash() (which takes start and end times directly) can be used in place of dplyr::group_by(). The example below is equivalent to the previous code but is usually several times faster.

datetime_squash(
  start = ymd(jobs$start),
  end = ymd(jobs$end),
  by = jobs$name,
  keep_by = TRUE,
  order_by = TRUE
)
#> # A tibble: 3 × 2
#>   by    phint                                           
#>   <chr> <phint<UTC>>                                    
#> 1 Greg  {2018-01-01--2018-06-03, 2018-06-10--2020-11-28}
#> 2 Shiv  {2017-01-01--2019-04-01}                        
#> 3 Tom   {2019-05-01--2020-12-31}

As in dplyr::summarize(), the by argument can be a vector or data frame to support multiple grouping columns.

To return the dataset to a one-row-per-span format, use phint_unnest(), which converts each <phinterval> element into separate rows:

employment |>
  reframe(phint_unnest(employed, key = name))
#> # A tibble: 4 × 3
#>   key   start               end                
#>   <chr> <dttm>              <dttm>             
#> 1 Greg  2018-01-01 00:00:00 2018-06-03 00:00:00
#> 2 Greg  2018-06-10 00:00:00 2020-11-28 00:00:00
#> 3 Shiv  2017-01-01 00:00:00 2019-04-01 00:00:00
#> 4 Tom   2019-05-01 00:00:00 2020-12-31 00:00:00

Finding Gaps

To analyze periods of unemployment, we need to identify the gaps between employment intervals. The phint_invert() function returns the gaps between spans in a <phinterval>.

unemployment <- employment |>
  mutate(
    # Find the gaps between jobs
    unemployed = phint_invert(employed),
    
    # Calculate duration of unemployment
    days_unemployed = unemployed / ddays(1)
  ) |>
  select(name, unemployed, days_unemployed)

unemployment
#> # A tibble: 3 × 3
#>   name  unemployed               days_unemployed
#>   <chr> <phint<UTC>>                       <dbl>
#> 1 Greg  {2018-06-03--2018-06-10}               7
#> 2 Shiv  <hole>                                 0
#> 3 Tom   <hole>                                 0

Greg was unemployed for 7 days between his time as a Mascot and his role as Executive Assistant. Tom and Shiv have no gaps within their respective employment timelines, represented by a <hole>.

Edge Cases and Gotchas

Abutting Intervals and Intersection

Manipulating abutting intervals (intervals that share an endpoint) can produce sometimes unexpected results. To demonstrate, consider the time within a Monday and Tuesday in November 2025.

monday <- interval(ymd("2025-11-10"), ymd("2025-11-11"))
tuesday <- interval(ymd("2025-11-11"), ymd("2025-11-12"))

By default, intervals in <phinterval> and <Interval> vectors have inclusive endpoints, meaning that midnight on Monday, November 11th, 2025 falls within both monday and tuesday:

midnight_monday <- ymd_hms("2025-11-11 00:00:00")
phint_within(midnight_monday, monday)
#> [1] TRUE
phint_within(midnight_monday, tuesday)
#> [1] TRUE

As a result, the intersection of monday and tuesday is an instantaneous interval at midnight_monday.

phint_intersect(monday, tuesday) == as_phinterval(midnight_monday)
#> [1] TRUE

Perhaps surprisingly, this also means that the intersection of monday and its complement is not empty, but consists of the two endpoints of monday.

not_monday <- phint_complement(monday)
not_monday
#> <phinterval<UTC>[1]>
#> [1] {-Inf--2025-11-10, 2025-11-11--Inf}

phint_intersect(monday, not_monday)
#> <phinterval<UTC>[1]>
#> [1] {2025-11-10--2025-11-10, 2025-11-11--2025-11-11}

The bounds argument in phint_overlaps(), phint_within(), and phint_intersect() controls this behavior. When bounds = "()", endpoints are treated as exclusive:

phint_overlaps(monday, tuesday, bounds = "()")
#> [1] FALSE
phint_intersect(monday, tuesday, bounds = "()")
#> <phinterval<UTC>[1]>
#> [1] <hole>

With exclusive endpoints, monday and tuesday no longer overlap, and their intersection is empty.

An instantaneous interval (point, point) with open bounds is mathematically undefined, but for convenience we allow these points to exist. With bounds = "()", instants on the endpoint of an interval are outside of the interval, while instants in the middle of an interval are considered to be within it:

monday_at_9AM <- as_phinterval(ymd_hms("2025-11-10 00:09:00"))
phint_within(monday_at_9AM, monday, bounds = "()")
#> [1] TRUE
phint_within(midnight_monday, monday, bounds = "()")
#> [1] FALSE

To consider instantaneous intervals as empty, use phint_sift() to remove all instants from an interval vector:

phint <- phint_squash(c(monday_at_9AM, tuesday))
phint
#> <phinterval<UTC>[1]>
#> [1] {2025-11-10 00:09:00--2025-11-10 00:09:00, 2025-11-11 00:00:00--2025-11-12 00:00:00}

phint_sift(phint)
#> <phinterval<UTC>[1]>
#> [1] {2025-11-11--2025-11-12}

Instantaneous Intervals and Set Difference

Because phinterval elements are composed of non-overlapping, non-adjacent spans, “punching” an instantaneous hole into an interval using phint_setdiff() has no effect on the interval. While removing a single point from an interval [start, end] would theoretically split it into [start, point) and (point, end], in practice these adjacent pieces are immediately merged back together:

monday_noon <- as_phinterval(ymd_hms("2025-11-10 12:00:00"))
monday_lunch_break <- interval(
  ymd_hms("2025-11-10 12:00:00"), 
  ymd_hms("2025-11-10 13:00:00")
)

phint_setdiff(monday, monday_lunch_break) # Removes a non-zero interval
#> <phinterval<UTC>[1]>
#> [1] {2025-11-10 00:00:00--2025-11-10 12:00:00, 2025-11-10 13:00:00--2025-11-11 00:00:00}
phint_setdiff(monday, monday_noon)        # Instantaneous - no effect
#> <phinterval<UTC>[1]>
#> [1] {2025-11-10--2025-11-11}

To create gaps, you must remove an interval with non-zero duration.

Time Zones

To ensure that any <Interval> vector can be represented as an equivalent <phinterval> vector, the phinterval() constructor accepts any time zone permitted by interval(), including unrecognized zones.

intvl <- interval(ymd("2020-01-01"), ymd("2020-01-02"), tzone = "nozone")
phint <- phinterval(ymd("2020-01-01"), ymd("2020-01-02"), tzone = "nozone")
intvl == phint
#> [1] TRUE

When a <phinterval> with an unrecognized time zone is formatted, its time points are displayed using the UTC time zone:

print(phint)
#> <phinterval<nozone>[1]>
#> [1] {2020-01-01--2020-01-02}

The is_recognized_tzone() function can be used to check whether a time zone is recognized:

is_recognized_tzone("America/New_York")
#> [1] TRUE
is_recognized_tzone("nozone")
#> [1] FALSE
is_recognized_tzone(NA_character_)
#> [1] FALSE

Some datetime vectors, such as <POSIXct>, are allowed to have an NA time zone. When converted to a <phinterval>, the missing time zone is silently replaced with UTC:

na_zoned <- as.POSIXct("2021-01-01", tz = NA_character_)
as_phinterval(na_zoned)
#> <phinterval<UTC>[1]>
#> [1] {2021-01-01--2021-01-01}

Operations that combine two or more interval vectors, such as phint_union(), use the time zone of the first argument. If the first argument’s time zone is "" (the user’s local time zone), the second argument’s time zone is used instead.

int_est <- interval(ymd("2020-01-01"), ymd("2020-01-02"), tzone = "EST")
int_utc <- interval(ymd("2020-01-01"), ymd("2020-01-02"), tzone = "UTC")
int_lcl <- interval(ymd("2020-01-01"), ymd("2020-01-02"), tzone = "")

phint_union(int_est, int_utc)
#> <phinterval<EST>[1]>
#> [1] {2019-12-31 19:00:00--2020-01-01 19:00:00}
phint_union(int_utc, int_est)
#> <phinterval<UTC>[1]>
#> [1] {2020-01-01--2020-01-02}
phint_union(int_lcl, int_est)
#> <phinterval<EST>[1]>
#> [1] {2019-12-31 19:00:00--2020-01-01 19:00:00}

Comparison with Datetime Vectors

Comparison operators (<=, <, >, >=, ==) work in unexpected ways when comparing datetime vectors (<Date>, <POSIXct>, <POSIXlt>) to <phinterval> or <Interval> vectors. For example:

span <- phinterval(ymd("2000-08-05"), ymd("2000-11-29"))
date <- ymd("2021-01-01")

span == date
#>   size starts   ends 
#>  FALSE     NA     NA

For the intended behavior, use as_phinterval() to convert datetime vectors into an equivalent <phinterval> first.

span == as_phinterval(date)
#> [1] FALSE

mirror server hosted at Truenetwork, Russian Federation.