| Title: | Lagged and Moving-Average Exposure Features for Aeroallergen Epidemiology |
|---|---|
| Description: | Deterministic, group-safe utilities that transform daily environmental exposure series (pollen and spore counts, with support for other time-varying exposures such as ozone and particulate matter) into analysis-ready lagged and moving-average exposure features. Functions validate temporal regularity, assign ISO 8601 weeks and configurable seasons, impute missing daily values transparently, and construct lagged and windowed exposures suitable for environmental epidemiology and public health analyses. |
| Authors: | Félix E. Rivera-Mariani [aut, cre] (ORCID: <https://orcid.org/0000-0002-6671-0174>) |
| Maintainer: | Félix E. Rivera-Mariani <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.1.2 |
| Built: | 2026-10-01 20:43:42 UTC |
| Source: | https://github.com/friveramariani/sporelag |
apply_lag() appends one column per requested lag, shifting the exposure
series backwards in time within each group. A lag of n places the value
observed n days earlier alongside the current day.
apply_lag(data, value, lags, date, by = character())apply_lag(data, value, lags, date, by = character())
data |
A |
value |
A single string naming a numeric exposure column. |
lags |
A vector of non-negative whole numbers, e.g. |
date |
A single string naming a |
by |
A character vector of grouping column names. Default
|
This function indexes by position, and therefore requires a complete daily
grid. On a gapped series, "lag 1" would mean "the previous row", which
may be one day earlier or thirty. The resulting exposure column looks
entirely plausible, runs cleanly through a regression, and yields a
silently misaligned lag–response estimate. apply_lag() therefore refuses
to operate on a gapped grid and raises sporelag_error_gaps. Call
complete_daily_grid() first. This is a deliberate design decision, not an
oversight; see vignette("getting-started").
Documented defaults:
Lags are computed strictly within each group defined by by. Values
never carry across a group boundary — the first n days of each site's
series are NA, not the tail of the previous site's series.
lags = 0 is permitted and yields a copy of the same-day value. This
exists so a uniform model matrix (lag0, lag1, lag2, ...) can be built
without special-casing the contemporaneous term.
Negative values are an error. A negative lag is a lead: it aligns an exposure with an outcome that preceded it. That is almost never intended, and when it is (e.g. a negative-control exposure), it should be written explicitly rather than arrived at by a sign slip.
Row order is preserved. Columns are appended.
data with one column appended per lag, named {value}_lag{n}.
Row order and all input columns are unchanged.
sporelag_error_gapsThe daily grid is incomplete within at least
one group. Call complete_daily_grid() first.
sporelag_error_duplicate_datesA date appears more than once within a group.
sporelag_error_bad_inputBad types; missing columns; value is
not numeric; lags contains negative or non-whole numbers; an output
column name already exists.
complete_daily_grid(), build_moving_average()
df <- data.frame( site = rep(c("A", "B"), each = 4), date = rep(seq(as.Date("2024-05-01"), by = "day", length.out = 4), 2), count = c(10, 20, 30, 40, 100, 200, 300, 400) ) apply_lag(df, value = "count", lags = 0:2, date = "date", by = "site")df <- data.frame( site = rep(c("A", "B"), each = 4), date = rep(seq(as.Date("2024-05-01"), by = "day", length.out = 4), 2), count = c(10, 20, 30, 40, 100, 200, 300, 400) ) apply_lag(df, value = "count", lags = 0:2, date = "date", by = "site")
assign_iso_week() appends the ISO 8601 week number and ISO year for each
date. It is a row-wise mapping: no grouping, no temporal indexing, no
requirement that the grid be complete.
assign_iso_week(data, date, week_col = "iso_week", year_col = "iso_year")assign_iso_week(data, date, week_col = "iso_week", year_col = "iso_year")
data |
A |
date |
A single string giving the name of a |
week_col |
Name of the ISO week column to append. Default |
year_col |
Name of the ISO year column to append. Default |
The ISO year is not the calendar year, and this matters. Late-December and early-January dates routinely belong to an ISO week owned by the adjacent year:
| Date | Weekday | ISO |
| 2019-12-30 | Monday | 2020-W01 |
| 2020-12-31 | Thursday | 2020-W53 |
| 2021-01-01 | Friday | 2020-W53 |
| 2023-01-01 | Sunday | 2022-W52 |
Consequently, week number alone is not a valid grouping key. Grouping a
multi-year series by iso_week pools week 1 of every year together, and
pools the days either side of a New Year boundary into different weeks than
the calendar suggests. Always group by c(iso_year, iso_week). Downstream
aggregation functions in SporeLag require both columns for this reason.
ISO weeks are computed in base R via the nearest-Thursday rule rather than
strftime()'s %V/%G format codes, whose behaviour has historically
varied by platform. Output is therefore identical on every operating system.
data with two integer columns appended: week_col (1–53) and
year_col. Row order and all input columns are unchanged.
sporelag_error_bad_inputdata, date, week_col, or
year_col do not match the documented types; date is not a Date
column; or an output column name already exists in data.
sporelag_error_missing_datedate contains NA.
df <- data.frame( date = as.Date(c("2019-12-30", "2020-12-31", "2021-01-01")), count = c(1, 2, 3) ) assign_iso_week(df, date = "date")df <- data.frame( date = as.Date(c("2019-12-30", "2020-12-31", "2021-01-01")), count = c(1, 2, 3) ) assign_iso_week(df, date = "date")
assign_season() appends a season label to each date. The definition is an
explicit argument with no silent default behaviour, because "season" means
materially different things in different literatures.
assign_season( data, date, definition = c("meteorological", "astronomical", "custom"), hemisphere = c("northern", "southern"), breaks = NULL, season_col = "season" )assign_season( data, date, definition = c("meteorological", "astronomical", "custom"), hemisphere = c("northern", "southern"), breaks = NULL, season_col = "season" )
data |
A |
date |
A single string giving the name of a |
definition |
One of |
hemisphere |
One of |
breaks |
Required when |
season_col |
Name of the column to append. Default |
definition = "meteorological" (the default) uses whole calendar months
— Northern Hemisphere: Winter = Dec–Feb, Spring = Mar–May, Summer = Jun–Aug,
Autumn = Sep–Nov. Boundaries are exact and leap-year stable.
definition = "astronomical" uses fixed conventional dates (Mar 20,
Jun 21, Sep 22, Dec 21). True equinoxes and solstices drift by up to a day
or two across years and depend on time zone; computing them exactly requires
an ephemeris, which is out of scope for a zero-dependency package. If your
analysis is sensitive to a one-day boundary shift, do not use this option —
supply exact dates via definition = "custom".
definition = "custom" takes breaks: a named character vector of
"MM-DD" start dates, where the names are the season labels. Each season
runs from its start date until the day before the next, wrapping the year
boundary. This is the option to use for pollen seasons, which are
taxon- and region-specific and bear no necessary relation to the calendar.
Epidemiologic caution. In aeroallergen research, "season" usually means pollen season — a taxon-specific exposure window, often defined by a cumulative-count threshold, that varies by region and by year. It is not the astronomical or meteorological calendar. Using a calendar season as a proxy for a pollen season will misclassify exposure windows. The default here is a calendar convenience, not an exposure definition; state which you used in any analysis.
The returned column is an ordered factor whose levels follow the
chronological cycle, so its behaviour in a model matrix is predictable. The
reference level is the first level; set it explicitly with stats::relevel()
if your model requires a different baseline.
data with an ordered factor column appended. Row order and all
input columns are unchanged.
sporelag_error_bad_inputBad types; date is not a Date
column; season_col already exists; breaks missing, unnamed,
duplicated, or not in "MM-DD" form.
sporelag_error_missing_datedate contains NA.
df <- data.frame(date = as.Date(c("2024-01-15", "2024-04-15", "2024-07-15"))) assign_season(df, date = "date") assign_season(df, date = "date", hemisphere = "southern") # A taxon-specific pollen season assign_season( df, date = "date", definition = "custom", breaks = c(Dormant = "11-01", Tree = "02-15", Grass = "05-01", Ragweed = "08-15") )df <- data.frame(date = as.Date(c("2024-01-15", "2024-04-15", "2024-07-15"))) assign_season(df, date = "date") assign_season(df, date = "date", hemisphere = "southern") # A taxon-specific pollen season assign_season( df, date = "date", definition = "custom", breaks = c(Dormant = "11-01", Tree = "02-15", Grass = "05-01", Ragweed = "08-15") )
build_moving_average() appends one column per requested window, containing
the mean exposure over that window, computed within each group.
build_moving_average( data, value, window, date, by = character(), align = c("right", "center"), min_obs = window )build_moving_average( data, value, window, date, by = character(), align = c("right", "center"), min_obs = window )
data |
A |
value |
A single string naming a numeric exposure column. |
window |
A vector of positive whole numbers, e.g. |
date |
A single string naming a |
by |
A character vector of grouping column names. Default |
align |
|
min_obs |
Minimum non-missing observations required within a window.
Defaults to |
Like apply_lag(), this function indexes by position and therefore
requires a complete daily grid; it raises sporelag_error_gaps
otherwise. Call complete_daily_grid() first.
Documented defaults — each of these changes what the variable means:
align = "right" (default)The window is trailing and
inclusive of the current day: window = 3 averages today and the
two preceding days. This is the alignment appropriate for
exposure→outcome inference.
align = "center"The window is symmetric around the current day
and therefore incorporates future exposure. If the outcome is
measured on the current day, a centred window conditions on information
that did not exist at the time — it can induce apparent associations
with no causal interpretation. It is provided for smoothing and
descriptive display, not for exposure construction. Requires an odd
window.
NA
A window that extends past the start (or,
when centred, either end) of a group's series is incomplete by
construction and returns NA. The first window - 1 days of each
trailing series are NA. They are not back-filled with a shorter
average, because a "7-day mean" computed from 2 days is not a 7-day
mean.
min_obs = window (default)The minimum number of non-missing
observations required within an otherwise complete window. At the
default, any NA in the window yields NA. Lowering it (e.g.
min_obs = 5 with window = 7) tolerates missing days and averages
over those present — which shrinks variance and can bias a
lag–response estimate toward the null. Lower it deliberately, and
report that you did.
data with one double column appended per window, named
{value}_ma{k}. Row order and all input columns are unchanged.
sporelag_error_gapsThe daily grid is incomplete within at least one group.
sporelag_error_bad_inputBad types; value is not numeric;
window is not a positive whole number; min_obs exceeds window;
align = "center" with an even window; an output column already
exists.
complete_daily_grid(), apply_lag()
df <- data.frame( site = rep(c("A", "B"), each = 5), date = rep(seq(as.Date("2024-05-01"), by = "day", length.out = 5), 2), count = c(10, 20, 30, 40, 50, 100, 200, 300, 400, 500) ) build_moving_average(df, value = "count", window = 3, date = "date", by = "site")df <- data.frame( site = rep(c("A", "B"), each = 5), date = rep(seq(as.Date("2024-05-01"), by = "day", length.out = 5), 2), count = c(10, 20, 30, 40, 50, 100, 200, 300, 400, 500) ) build_moving_average(df, value = "count", window = 3, date = "date", by = "site")
complete_daily_grid() fills gaps in a daily time series so that every
calendar day between the minimum and maximum date (within each group) is
represented by exactly one row. Newly inserted rows carry NA for every
column except date and the columns named in by.
complete_daily_grid(data, date, by = character())complete_daily_grid(data, date, by = character())
data |
A |
date |
A single string giving the name of a |
by |
A character vector of column names in |
Functions in SporeLag that index by time (lags, moving averages) assume the day-to-day step is exactly one day with no repeats and no gaps within a group. Rather than silently shifting values by row position when that assumption fails, those functions raise a classed error. This function is the documented mechanism for producing that guarantee before calling them.
complete_daily_grid() never guesses at missing values: inserted rows
get NA, and it is the caller's responsibility to decide whether and how
to impute them.
A data.frame with the same columns as data, in the same
order, with one row per calendar day between min(date) and
max(date) within each group. Rows are sorted by by, then date.
No columns are added or removed; this function only inserts rows.
sporelag_error_bad_inputdata, date, or by do not match
the documented types, or name columns not present in data.
sporelag_error_duplicate_datesThe same date appears more than once within a single group. A grid cannot be built unambiguously, so the function errors instead of silently choosing a row.
df <- data.frame( site = c("A", "A", "A", "B", "B"), date = as.Date(c( "2024-01-01", "2024-01-02", "2024-01-04", "2024-01-01", "2024-01-03" )), count = c(1, 2, 4, 10, 30) ) complete_daily_grid(df, date = "date", by = "site")df <- data.frame( site = c("A", "A", "A", "B", "B"), date = as.Date(c( "2024-01-01", "2024-01-02", "2024-01-04", "2024-01-01", "2024-01-03" )), count = c(1, 2, 4, 10, 30) ) complete_daily_grid(df, date = "date", by = "site")
impute_weekly_mean() fills missing daily exposure values with the mean of
the observed values in the same ISO week, within group. It appends the
completed series and a flag marking which values were imputed. The input
column is never modified.
impute_weekly_mean( data, value, by = character(), week_col = "iso_week", year_col = "iso_year", min_obs = 1L )impute_weekly_mean( data, value, by = character(), week_col = "iso_week", year_col = "iso_year", min_obs = 1L )
data |
A |
value |
A single string naming a numeric exposure column. |
by |
A character vector of grouping column names. Default |
week_col |
Name of the ISO week column. Default |
year_col |
Name of the ISO year column. Default |
min_obs |
Minimum observed days required in a week for its mean to be
used. Default |
Requires the ISO week and year columns produced by assign_iso_week().
Typically it also follows complete_daily_grid(), since a day that is
absent from the data entirely cannot be imputed — it must first exist as a
row with an NA value.
The canonical pipeline is:
data |> complete_daily_grid(date = "date", by = "site") |> assign_iso_week(date = "date") |> impute_weekly_mean(value = "count", by = "site")
Grouping is by c(by, year_col, week_col) — never by week alone. ISO
week 1 recurs every year, and the ISO year of a late-December date is often
the following calendar year. Grouping on week number alone would pool
observations from different years into a single mean. See assign_iso_week().
Documented defaults:
min_obs = 1The minimum number of observed days required in a
week before its mean is used. At the default, a single observed day is
enough to fill the other six. Raise it (e.g. min_obs = 4) if you are
unwilling to extrapolate a week from one observation; weeks below the
threshold are left NA and flagged FALSE.
NA
Nothing is carried across a week boundary. There is no interpolation between weeks, no last-observation-carried-forward, and no global mean fallback.
double
Even when value is an integer count. A mean is
not a count, and rounding an imputed 12.5 to 12 or 13 would fabricate
precision. If your model needs integers, round explicitly and say so.
Epidemiologic caution. Weekly-mean imputation replaces day-to-day
variation with a constant, which shrinks the variance of the exposure.
In a lag–response model, an exposure with attenuated variance typically
produces effect estimates biased toward the null, and standard errors
that do not reflect the true uncertainty (the imputed values are treated as
though observed). The {value}_imputed_flag column exists so that you can
quantify this: report the proportion imputed, and re-run the analysis on
complete cases as a sensitivity check. This function makes imputation
convenient; it does not make it innocuous.
data with two columns appended:
{value}_imputeddouble. The completed series.
{value}_imputed_flaglogical. TRUE where a value was filled.
Row order and all input columns, including value, are unchanged.
sporelag_error_missing_colweek_col or year_col is absent.
Call assign_iso_week() first.
sporelag_error_bad_inputBad types; value is not numeric;
min_obs is not a positive whole number; an output column already
exists.
assign_iso_week(), complete_daily_grid()
df <- data.frame( site = rep("A", 7), date = seq(as.Date("2024-05-06"), by = "day", length.out = 7), # Mon-Sun count = c(10, NA, 30, NA, 50, 60, NA) ) df |> assign_iso_week(date = "date") |> impute_weekly_mean(value = "count", by = "site")df <- data.frame( site = rep("A", 7), date = seq(as.Date("2024-05-06"), by = "day", length.out = 7), # Mon-Sun count = c(10, NA, 30, NA, 50, 60, NA) ) df |> assign_iso_week(date = "date") |> impute_weekly_mean(value = "count", by = "site")
A synthetic dataset of daily airborne pollen concentrations for a single
spring tree-pollen season at two hypothetical monitoring stations. It exists
to demonstrate and test SporeLag and is used throughout
vignette("getting-started").
pollen_demopollen_demo
A data frame with three columns:
character. Monitoring station: "North" or "South".
Date. Calendar day, spanning 2024-02-15 to 2024-06-30.
integer. Pollen concentration in grains per cubic metre.
NA where sampling failed on a day that was otherwise monitored.
The data deliberately contain both of the defects SporeLag is designed to handle, because they are different problems and are frequently confused:
Calendar days with no row at all, representing periods
when the sampler was offline: 2024-03-18 to 2024-03-22 at North, and
2024-04-01 to 2024-04-03 at South. A gap is invisible to is.na() — the
row simply is not there. Gaps must be closed with
complete_daily_grid() before any lag or moving average is computed, or
the shift will be by row position rather than by time.
Days that are present but whose count is NA,
representing failed samples on otherwise-monitored days (roughly 6% of
days at each site). These are candidates for impute_weekly_mean().
The South gap falls at that site's seasonal peak — the point at which losing data does the most damage to an exposure estimate, and where a naive positional lag would silently produce the largest misalignment.
Counts were drawn from a negative binomial distribution around a Gaussian
seasonal curve, so they are overdispersed in the way real aeroallergen counts
are. The generating script is data-raw/make_pollen_demo.R and is fully
deterministic.
Simulated. These are not real surveillance data and must not be
used for any substantive inference about pollen exposure. See
data-raw/make_pollen_demo.R for the generating code.
complete_daily_grid(), impute_weekly_mean()
str(pollen_demo) # Gaps are absent rows, not NA values -- is.na() cannot see them: table(pollen_demo$site) # neither site has a full 137 days sum(is.na(pollen_demo$count)) # the NAs are a separate problem # The canonical pipeline: pollen_demo |> complete_daily_grid(date = "date", by = "site") |> assign_iso_week(date = "date") |> impute_weekly_mean(value = "count", by = "site") |> apply_lag(value = "count_imputed", lags = 0:2, date = "date", by = "site") |> head()str(pollen_demo) # Gaps are absent rows, not NA values -- is.na() cannot see them: table(pollen_demo$site) # neither site has a full 137 days sum(is.na(pollen_demo$count)) # the NAs are a separate problem # The canonical pipeline: pollen_demo |> complete_daily_grid(date = "date", by = "site") |> assign_iso_week(date = "date") |> impute_weekly_mean(value = "count", by = "site") |> apply_lag(value = "count_imputed", lags = 0:2, date = "date", by = "site") |> head()