Added the twelve remaining base relations of Allen’s interval
algebra as infix operators - %p%/%pi%
(precedes/preceded by), %m%/%mi% (meets/met
by), %o%/%oi% (overlaps/overlapped by),
%s%/%si% (starts/started by),
%d%/%di% (during/contains), and
%f%/%fi% (finishes/finished by) - completing
the set alongside the existing ==.
Added chronon_glb() and chronon_lub(),
which find the greatest lower bound (the finest common chronon)
and the least upper bound (the coarsest chronon that every
input chronon evenly aggregates into) of a set of chronons.
time_shift() and seq() gain two
on_invalid strategies. "next" resolves an
invalid time point to the start of the following granule, so
date("1955-01-31") plus one month is
1955-03-01, and a local time skipped by daylight saving
moves to the end of the gap (02:30 becomes 03:00). "error"
throws an error instead of returning NA like
"reject".
The new mixtime.on_invalid option sets the default
on_invalid strategy for time_shift(),
seq(), and the + and - operators,
which otherwise clamp. seq() no longer warns about clamping
when the option is set.
chronon_common() is deprecated in favour of
chronon_glb(), which names the greatest lower
bound it finds explicitly and distinguishes it from its dual,
chronon_lub().
Converting a discrete time to a chronon it doesn’t nest within is
deprecated, since the result is temporally indeterminate. For example,
day_of_week(yearmonth(x)) (which day of the month?) and
yearmonth(yearweek(x)) (a week can straddle two months) now
warn, and still use the start of each time point. This will become an
error in the next release. Continuous time points are instants that are
always determinate and convert as before. The internal
chronon_convert() gains an allow_indeterminate
argument, which still allows it by default.
new_time(), deprecated in 0.3.0, is now defunct. Use
mt_linear(), mt_duration(), or
mt_cyclical() instead.
Bare numbers compared with (or cast to) a linear or cyclical time
are now read as mixtime() reads them, counting from the
chronon’s epoch, so year(x) == 2026L compares with the year
2026. Previously the number was a raw count of chronons since 1970 (2026
was the year 3996).
The on_invalid strategy "nearest" is
renamed to "previous", the backward counterpart of the new
"next". It always resolves to the end of the target
granule, rather than whichever valid time point is closest.
Shifting a discrete time point by a duration it doesn’t nest
within is now an error, since the result is temporally indeterminate,
matching the conversions refused by time constructors. For example,
yearweek(x) + years(1L) (a week can straddle two years)
previously shifted the start of the week, giving results such as
2020 W01 plus one year being 2020 W53.
Shifting by a whole number of chronons is still allowed (a week by 14
days, but not 11), as is shifting continuous times. This applies to
+, -, time_shift(), and
seq().
year(2015)) now
warns when reading the instant as a span would change the result:
== and != (never equal), and the orderings
that measure against the instant’s end
(e.g. span > instant and
span <= instant). The warning suggests code for the
intended span (e.g. year(2015L), 2026L, or
as.integer(myvar)).yearweek(), yearmonth(), and
yearquarter() to accept strings that are accepted by
tsibble equivalents for ease of migration (with the intentional
exception of dates and datetimes, which need to be parsed first with
date() or datetime()).Date and POSIXct objects is
now identified from the interval between observations (like tsibble’s
interval), rather than always being 1 day or 1 second. For example,
mixtime() on hourly datetimes now produces 1 hour chronons.
A multiple of a unit is used only when every observation is aligned to
it, so conversion never loses information.chronon_cardinality_fixed() now scales its result by
the granule sizes (n) of both arguments, like
chronon_cardinality() does (e.g. there are 4
minute(15L) in hour(1L), not 60, and 10
day(1L) in day(10L), not 1). Methods still
return the ratio between unit granules.duration(2L, hour(3L)) shows "6 hours" (not
"2 hours") and the chronon of a 3-hourly time vector reads
"3 hours" (e.g. in time_chronon() and
str()), not "1 hour".as.POSIXct(mixtime(x, month(tz = "Australia/Melbourne")))
returns 00:00 AEDT instead of 11:00. Previously a conversion to UTC read
the local label as a UTC one.floor() of a continuous time always gives the matching
discrete time.time_shift() and seq() resolve it with their
on_invalid argument: "previous" gives the end
of the time before the gap, "next" the start of the time
after it (03:00), "overflow" moves it forward by the gap
(03:30), and "reject" gives NA.seq()-ing times with a multi-unit chronon
(e.g. half-hourly times) by days, months or years no longer errors with
“Cannot determine whether 30 minutes nest within 1 year”.datetime("2025-10-05 02:30:00", tz = "Australia/Melbourne"))
now gives NA with a warning, like other invalid times such
as "2026-02-30".hours(24L) and days(1L) differ
on a 23 or 25 hour day.seq() of time-zone-aware times by days (or weeks) now
keeps the local clock time across daylight saving changes, so a daily
sequence at 10:00 stays at 10:00 (previously it moved to 09:00 when
daylight saving ended). Each element of seq() now matches
adding a duration to the start, so a time adjusted because daylight
saving skipped it returns to its clock time on the following day.tz_abbreviation() (and so format()) now
labels a time-zone-aware time by the zone in effect when it starts, so a
day on which daylight saving ends reads as daylight time
(e.g. "2015-04-05 AEDT" in Melbourne, previously
"AEST").format() of a continuous time no longer shows a time
just below its true value when floating-point error leaves it a fraction
of a second short (e.g. "03:29:59 100.0%" instead of
"03:30:00 0.0%").as.POSIXct() of a mixtime now keeps its time zone (or
uses UTC when it has several), so mixtime(as.POSIXct(x))
round trips. Previously the result had an empty time zone, which
mixtime() rejected. mixtime() now also reads a
POSIXct with an empty time zone as the local time zone,
like base R does.Added time_parse(), the inverse of
format(): parses text back into a time point using the same
{lin(...)}/{cyc(...)} template
format() uses. Default parse strings are specified with
chronon_parse_linear() and
chronon_parse_cyclical() (with the
parse_format() helper), mirroring how
chronon_format_*() methods specify their default
format.
Added time_components(), which decomposes a time
vector into its constituent parts using
dplyr::mutate()-like semantics: named lin()/
cyc() expressions (the same vocabulary used in
format() strings) are resolved together in a single
decomposition of x, returning a data frame of linear
(lin()) and cyclical (cyc()) component time
vectors (e.g.
time_components(yearmonth(x), yr = lin(year), mth = cyc(month, year))).
Added time_compose(), the inverse of
time_components(): builds a single mixtime
time point from a set of lin()/cyc()
components, supplied either as spec ~ value formulas (e.g.
time_compose(lin(year) ~ 1980, cyc(month, year) ~ 3 for
March 1980) or as already-tagged linear/cyclical time vectors.
Added time_is_determinate_at(), which tests whether
a time point resolves a given (typically finer) granule exactly: a
discrete time point never determines a finer granule (a year has no
determinate month), while a continuous time point tracks progress within
its chronon and so always does (0% through 2020 is 0% through
January).
Added time_is_complete_at(), which tests, for each
element of a time vector, whether the coarser granule it falls into is
fully observed by the vector as a whole - unlike
time_is_determinate_at(), completeness is a collective
property: an element is TRUE only when the other elements
needed to fill out its granule are also present elsewhere in the vector
(e.g. a year is only complete once all twelve of its months are
present).
Cyclical time vectors can now be compared with ==,
!=, <, <=,
> and >=, which previously errored. A
cyclical value means a position within its cycle rather than an instant,
so comparison is made on that position: two Wednesdays are equal
regardless of which week they fall in.
Added chronon_cardinality_fixed(), a variant of
chronon_cardinality() for time granule pairs whose
relationship is a constant, context-independent number (e.g., 60 seconds
in a minute) rather than dependent on an at time point
(e.g., 28-31 days in a month).
Added civil time microseconds and nanoseconds to
cal_time_civil (#92).
Added linear_labels() and
cyclical_labels(), an authoring interface for declaring a
calendar granule’s label scheme once and getting both text
formatting and its time_parse()-ready inverse,
text parsing
(linear_labels_parse()/cyclical_labels_parse()),
registered together so the two directions can’t drift apart. Covers
plain numeric labels, named labels from a lookup table
(vocab_table()), irregular cycles where the
raw-index-to-name mapping isn’t a constant shift
(transform, e.g. a leap month that splits one name into
two), and a full format/parse escape hatch for
labels that aren’t index-shaped at all (e.g. “2BC”). See
?cyclical_labels. A scheme is built with
label_scheme() and assigned directly as a
linear_labels()/cyclical_labels() method (e.g.
method(cyclical_labels, list(granule, cycle)) <- label_scheme(...)).
validate_label_scheme() checks a scheme’s format/parse
round-trip, most useful for a hand-written
transform.
new_time(), the low-level constructor for
mt_time vectors, is deprecated in favour of calling the
concrete time class constructors directly: mt_linear(),
mt_duration(), and mt_cyclical().mixtime() (and linear_time(),
cyclical_time(), and convenience functions like
yearmonth() and day_of_week()) now parse
character data with time_parse(). Parsing is
now tighter and chronon-specific: text must match the requested
chronon’s own format (e.g. yearmonth("2026 Feb") now works,
but yearmonth("2026-02-14"), now errors).
is_time_linear(), is_time_cyclical(),
and is_time_duration() have been renamed to
time_is_linear(), time_is_cyclical(), and
time_is_duration() for consistency with semantic property
functions.
tz_offset() now returns a mixtime
duration vector instead of a plain numeric vector, so offsets carry
their chronon (and can be combined with other durations) rather than
being a bare count of seconds/days/etc.
tz_transitions() now returns time as a
continuous mixtime linear time point (UTC seconds) and
offset_before/offset_after as
mixtime durations (UTC seconds), instead of plain numeric
columns.
Added at argument to cyclical_labels(),
which provides the linear position of the cycle granule to allow
appropriate labelling of irregular cycles, the same convention as
chronon_cardinality()’s at argument
(#100).
linear_labels() and cyclical_labels(),
the S7 generics that turn a granule’s position into label text, have
been renamed to linear_labels_format() and
cyclical_labels_format(). The bare names are now the
authoring interface described above; a hand-written
method(linear_labels_format, ...) <-/method(cyclical_labels_format, ...) <-
is still available for granules that only ever needs formatting (no
parsing).
cal_gregorian’s day<->month conversion
(chronon_cardinality() and chronon_divmod())
now supports a month chronon of any non-zero size (previously
capped at abs(n) < 12,
e.g. month(13L)/years(1L) + months(6L)-scale
steps errored with “Month chronons >= 12 are not yet supported”), and
fixes two correctness bugs uncovered while generalising it, both
specific to a multi-month (abs(n) > 1) chronon:
chronon_divmod(day, month)’s mod used to be
the offset into the calendar month containing the input day,
which silently dropped any whole months already elapsed earlier in a
wider window
(e.g. seq(date( "2020-02-15"), by = "2 months") produced
2020-01-15, 2020-03-15, ..., a full window early, instead
of 2020-02-15, 2020-04-15, ...); and
chronon_divmod(day, month)’s div used to be
fdiv(res, res_scale) unconditionally, which only agrees
with chronon_cardinality()’s window anchor
(at * n, always counting the window forward regardless of
sign) when res_scale > 0 - for a negative multi-month
to it instead picked the window running backwards from the
input day. Both the >= 12 cap and the old
circsum()-tabulated approach it came from are gone: day
counts for a window are now a difference of two calls to a new O(1)
month_start_days() helper (the same closed-form leap-year
arithmetic chronon_divmod(year, day) already used, reused
here anchored to a month instead of a year), so the cost no longer
scales with the chronon’s n.
chronon_nests_in_fixed() (and so
chronon_needs_clamping(), which it backs) previously
ignored @n entirely for a cross-class pair, treating
e.g. month(5L) and month(2L) identically as
long as month and year were connected at all - so it reported
month(5L) as nesting inside year(1L) even
though 12 months/year isn’t a whole multiple of 5. Fixed to check the
same divisibility the identical-class case already did, generalised
across a fixed-cardinality chain (multiplying
chronon_cardinality_fixed() along it, e.g. 3 months/quarter
* 4 quarters/year = 12 months/year).
chronon_nests_in() previously ignored
@n entirely for any cross-class pair, reporting e.g. both
month(5L) as nesting inside year(1L) (wrong -
12 months/year isn’t a whole multiple of 5, so a 5-month block never
lands on a year boundary) and day(2L) as nesting inside
month(1L) (also wrong in general - whether a 2-day block
straddles a month boundary depends on the parity of that month’s start
day-of-epoch). Fixed to match chronon_nests_in_fixed()’s
exact answer whenever a fixed (context- independent) path connects the
two classes (that path’s ratio is constant, so if it doesn’t divide
evenly, no other path could make it nest either). For a pair connected
only by an irregular (context-dependent) path, a single-unit
(n = 1) chronon still always nests (it’s never split across
a boundary), but any other n now errors instead of silently
assuming it nests - checking every at in the cycle isn’t
generally possible, since cycle length isn’t a declared, inspectable
property of chronon_cardinality() methods.
chronon_needs_clamping() (which uses both functions
together) is unaffected in practice - it now no longer fires for a
fixed-reachable, non-dividing pair like
month(5L)/year(1L), but no built-in calendar
arithmetic exercises that combination.
seq() on a linear_time with a
by whose magnitude doesn’t divide evenly into the coarser
calendar unit it’s ultimately anchored to (e.g.
by = months(5L), by = months(13L),
by = "18 months" - none of 5, 13, 18 divide 12) now errors
instead of silently producing calendar-drifted output.
seq()’s clamped path decomposes from once and
then advances by whole-number offsets in a single pass, which assumes
every by-sized block has the same shape - only true when
by’s magnitude divides evenly into that coarser unit
(checked with the fixed chronon_nests_in_fixed() fix
above). When it doesn’t, blocks land on a different month-of-year each
step and the output silently drifts
(e.g. seq(date("2020-01-31"), by = months(18L), length.out = 4)
used to give 2020-01-31, 2021-08-03, 2023-01-31, 2024-08-02
- only every second step agreeing with repeated
date + months(18L), which is unaffected by this and already
correct). Supporting this case properly (matching what repeated
+/- already do) is left for later;
by = months(1L)/(2L)/(3L)/(4L)/(6L)/(12L) and
by = years(n) are all unaffected, and
+/- were never affected (each call
re-decomposes the current instant rather than batching an offset from a
single decomposition).
linear_time + duration and
linear_time - duration across chronons of different
granularity (e.g. date() + months(),
date() + years()), and seq() on a
linear_time with a coarser by
(e.g. seq(date, by = "1 month")), now use calendar-field
arithmetic: the duration’s/by’s own chronon is advanced by
its magnitude, decomposing along the longest available chain of
calendar boundaries (e.g. day -> month -> year, not a shorter
direct day -> year edge) so that only the specific invalid component
(e.g. day-of-month) is clamped to the target period’s valid range, and
every other component - including time-of-day - is preserved exactly
(e.g. date("2020-01-31") + months(1) is now
2020-02-29, not 2020-03-02;
date("2024-02-29") + years(1) now agrees with
+ months(12), both giving 2025-02-28).
Previously the shift was approximated as
n * cardinality_at_start, which only happened to be exact
for n == 1 additions within same-length periods, and
compounded error for larger n
(date("2026-03-25") + months(6) was
2026-09-27, two days late; twelve months never returned to
the same day of the same month a year later). See
_dev/clamping.md for the design and remaining open
questions.
Fixed linear_time + duration/- duration
erroring with “Cannot convert from timezone-naive chronon to
timezone-aware chronon” whenever a timezone-aware time point
(e.g. datetime(tz = "Australia/Melbourne")) was shifted by
a duration with a regular (fixed-cardinality) relationship to the time’s
chronon, such as hours(), minutes(),
seconds(), or days()
(months()/years() were unaffected, as they use
the calendar-clamping path above). This was a regression from the change
above: the shift’s non-clamping path round-trips the time through the
duration’s chronon and back, and the return leg needs the duration’s
chronon to carry the time’s timezone first, the same way
seq() already does.
Fixed seq() on a linear_time with a
negative-magnitude by (e.g.
seq(date("2020-03-31"), by = "-1 month", length.out = 3),
by = months(-1L)) erroring with
'from' must be a finite number, and, once that was fixed,
clamping to the wrong end of the target period for descending sequences
(2020-03-31, 2020-03-02, 2020-01-31 instead of
2020-03-31, 2020-02-29, 2020-01-31). Also fixes
seq()/calendar-field arithmetic silently landing on the
wrong date for any multi-unit calendar by/duration whose
target chronon isn’t n = 1
(e.g. by = "2 months"). The negative-by error
was circsum() (the internal circular rolling-sum helper
backing days-in-a-month lookups) rejecting a negative
size/step outright; it now gives both a real
meaning instead - a negative step walks the same windows
backwards, and a negative size anchors a window at its end
instead of its start (a trailing rather than leading rolling sum) -
which a negative-n month chronon needs directly. The
multi-unit case was a stray reassignment in
R/cal_gregorian.R’s day<->month conversion left over
from an unrelated rebase, corrupting results for any target
month-chronon whose multiplier wasn’t exactly 1.
Combining two cyclical time vectors with different cycles is now an error rather than silently collapsing them. A cycle is a modulus rather than a unit of measure, so unlike a chronon it has no meaningful common value.
Converting to a finer chronon now takes a path that only ever steps finer, instead of the shortest path through the registered calendar arithmetic methods. A path that steps coarser first has to express the time point as a fraction of the coarser unit, and expanding that fraction again assumes the unit subdivides evenly: quarters were routed to days via years, making 2020 Q1 91.5 days long rather than the 91 days of January to March.
Fixed comparisons (==, <, etc.)
between a timezone-aware mt_linear/
mt_cyclical time point and a naive one, and
c()/vec_c() combining two such values, giving
numerically wrong results,
e.g. linear_time( "2015-02-01 10:00:00", hour(1L, tz = "Australia/Melbourne")) < datetime( "2015-02-01 00:00:00")
incorrectly returned TRUE, and combining a timezone-aware
“02:00” with a naive “02:00” produced two different instants under the
hood despite sharing a (naive) common chronon. Both operations convert
their operands into a shared common chronon that correctly comes out
naive when the operands disagree on timezone (see
chronon_common()), but converting an operand into
that common chronon silently re-inherited the operand’s own timezone
back (one conversion at a time), so a timezone-aware operand ended up at
its true (UTC) absolute instant while a naive operand was left as-is -
two different bases being compared/combined as if they were the same.
chronon_common()’s naive result is now hardened so it can
no longer be re-inherited into, and every operand converted into it
lands on bare wall-clock time instead, the same way
datetime(x, tz = NA) already strips a timezone for a single
value.
chronon_convert(),
chronon_divmod(), chronon_common() and
time_parts()) and tzdb names (used in operations with
timezones).round()/floor()/ceiling() methods
for rounding time by chronons. (note: use time_round(),
time_floor(), and time_ceiling() for rounding
to a specified time granule).tz = NA.time_unit_full() now uses cli-style pluralisation
templates (e.g., "year{?/s}",
"centur{?y/ies}"), enabling accurate plural forms beyond a
simple s suffix. A new time_unit_plural(x, n)
helper resolves the template for a given quantity.NaN, Inf, and
-Inf values in time types.str() output to be more compact for each time
type.time_chronon() causing
arithmetic to fail.c() allowing non-time vectors in mixtime
vectors.This is the initial CRAN release of the package with provides the core data types and temporal manipulation for temporal analysis with mixed granularity data.
mixtime() constructor for creating mixed
temporal vectors that combine time points of different granularities
(e.g. monthly and quarterly) in a single vector via
vecvec.linear_time() creates linear time vectors with a
user-specified chronon (smallest indivisible time granule), supporting
both discrete (integer) and continuous (fractional) time models.year(), yearquarter(),
yearmonth(), yearweek(), date(),
and datetime().cyclical_time() creates cyclical time vectors
representing positions within a repeating cycle (e.g. day-of-week,
month-of-year).month_of_year(), day_of_year(),
day_of_month(), day_of_week(),
week_of_year(), and time_of_day().cal_gregorian: standard Gregorian calendar with year,
quarter, month, day, hour, minute, second, and millisecond units.cal_isoweek: ISO 8601 week-date calendar with ISO year,
week, and day units. Weeks always start on Monday; the first week of the
year is the week containing the first Thursday.cal_sym454: Symmetry454 perennial solar calendar with a
symmetrical 4–5–4 week pattern per quarter and a leap-week rule.seq() methods for mixtime and
mt_time objects support integer, string
(e.g. "1 month"), and time unit by arguments,
as well as length.out and along.with. Overflow
behaviour when step granules differ from sequence granules can be
controlled with on_invalid = "nearest" (default) or
"overflow".round_time(), floor_time(), and
ceiling_time() round time objects to a specified time
granule, preserving the input class and timezone.mixtime objects. The format strings use glue-like
{} substitutions with the helper functions
lin(x) and cyc(x, y) to position linear and
cyclical time components in a string. x and y
are time granules from a calendar, which can be used to create general
purpose mixed-calendar time formats.tz_name() extracts the timezone from a time
object.tz_offset() returns the UTC offset for a datetime in
its timezone.tz_abbreviation() returns the timezone abbreviation
(e.g. "EST", "PDT").tz_transitions() returns a data frame of DST and other
timezone transitions between two time points.time_calendar() extracts the calendar system from a
time object.tsibble index validation are provided,
enabling mixtime vectors to be used as time indexes in
tsibble objects and in forecasting with
fable.new_calendar() defines a calendar as a named
collection of time unit classes, with optional inheritance from another
calendar.
mt_unit(), mt_tz_unit(), and
mt_loc_unit() primitives for creating new time
units.
new_linear_time_fn() and
new_cyclical_time_fn() are factories for building
convenient linear and cyclical time functions
(e.g. yearmonth()).
Calendar arithmetic with:
chronon_divmod() performs division-with-remainder
arithmetic between time granules (e.g. 1 day to 1 month), enabling
cross-granule calendrical arithmetic.chronon_cardinality() returns the number of smaller
granules contained in a coarser granule (e.g. days in 1 month), with
context-dependent results for variable-length granules.chronon_epoch() returns the epoch offset for a given
time unit.Displaying time with:
time_unit_abbr() and time_unit_full() for
time unit text.linear_labels() and cyclical_labels() for
time labels (e.g. Jan, Feb, … for months of year).chronon_format_linear() and
chronon_format_cyclical() for default formatting
strings.chronon_format_attr() for appending attribute
information (e.g. timezones)