---
title: "Getting started with SporeLag"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Getting started with SporeLag}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

```{r setup}
library(SporeLag)
```

SporeLag turns a daily exposure series (pollen or spore counts, ozone,
particulate matter) into lagged and moving-average exposure variables for
epidemiological models. This vignette walks through the full pipeline on the
bundled `pollen_demo` data and, along the way, shows the ways the pipeline
can go wrong and how SporeLag stops them from going wrong silently.

## The data: gaps are not missing values

`pollen_demo` is a **synthetic** dataset of daily pollen counts at two
monitoring sites over one spring season. It is not real surveillance data.

```{r}
str(pollen_demo)
```

The season runs from 2024-02-15 to 2024-06-30, which is 137 days. Neither site
has 137 rows:

```{r}
table(pollen_demo$site)
```

The data contain two different defects, and it is worth being precise about
the difference:

- a **missing value** is a row whose `count` is `NA`, for example a sample
  that failed on a day the station was running;
- a **gap** is a day with **no row at all**, for example a week when the
  sampler was offline.

`is.na()` sees the first kind and is blind to the second:

```{r}
tapply(is.na(pollen_demo$count), pollen_demo$site, sum)
```

Here is the North site around its gap. The rows jump from 17 March straight
to 23 March:

```{r}
north <- pollen_demo[pollen_demo$site == "North", ]
north[north$date >= as.Date("2024-03-15") & north$date <= as.Date("2024-03-25"), ]
```

## Lags on a gapped series fail loudly

A lag computed by row position on this series would be wrong. "Yesterday"
for 23 March would be 17 March, six days earlier. The resulting column would
look plausible, run cleanly through a regression, and give a misaligned
lag-response estimate that nothing downstream would flag.

For that reason the two temporal functions, `apply_lag()` and
`build_moving_average()`, check that each group is on a complete daily grid
and refuse to run if it is not:

```{r, error = TRUE}
apply_lag(pollen_demo, value = "count", lags = 1, date = "date", by = "site")
```

The error has class `sporelag_error_gaps`, so code that wraps SporeLag can
catch it specifically rather than matching on the message text:

```{r}
tryCatch(
  apply_lag(pollen_demo, value = "count", lags = 1, date = "date", by = "site"),
  sporelag_error_gaps = function(e) "gapped grid detected"
)
```

There is deliberately no `fill_gaps = TRUE` argument. Closing gaps is a
separate, visible step.

## Step 1: complete the daily grid

`complete_daily_grid()` inserts a row for every absent calendar day within
each group. Inserted rows carry `NA` in every column other than the date and
the grouping columns; nothing is guessed.

```{r}
grid <- complete_daily_grid(pollen_demo, date = "date", by = "site")
table(grid$site)
sum(is.na(grid$count))
```

Each site now has all 137 days. The gaps have become ordinary missing values,
which is a problem we can reason about explicitly.

## Step 2: calendar features

### ISO weeks

`assign_iso_week()` appends the ISO 8601 week **and** the ISO year. Both are
needed, because the ISO year of a date near New Year is often not its calendar
year:

```{r}
new_year <- data.frame(date = as.Date(c("2024-12-29", "2024-12-30", "2025-01-01")))
assign_iso_week(new_year, date = "date")
```

30 December 2024 belongs to week 1 of ISO year 2025. Grouping a multi-year
series by `iso_week` alone would pool unrelated days from different years, so
always group by both columns. The weeks are computed in base R, giving
identical results on every operating system.

### Seasons

`assign_season()` appends a season label as an ordered factor. The default is
the meteorological calendar:

```{r}
grid <- grid |>
  assign_iso_week(date = "date") |>
  assign_season(date = "date")

table(grid$season, grid$site)
```

A calendar season is not a pollen season. Pollen seasons are taxon- and
region-specific, so for exposure work you will usually want to supply your own
season start dates with `definition = "custom"`. The dates below are
illustrative, not a recommendation:

```{r}
custom <- assign_season(
  grid[, c("site", "date")],
  date = "date",
  definition = "custom",
  breaks = c(Dormant = "11-01", Tree = "02-15", Grass = "05-01", Weed = "08-01")
)
table(custom$season, custom$site)
```

## Step 3: impute missing values, and keep track of what you imputed

`impute_weekly_mean()` fills each missing day with the mean of the observed
days in the same ISO week, within site. It never overwrites the input column.
Instead, it appends the completed series and a logical flag:

```{r}
grid <- impute_weekly_mean(grid, value = "count", by = "site")

grid[grid$site == "North" &
       grid$date >= as.Date("2024-03-16") & grid$date <= as.Date("2024-03-25"),
     c("date", "iso_week", "count", "count_imputed", "count_imputed_flag")]
```

This output exposes a real weakness of the default. North's offline period
fills almost all of ISO week 12, so six days have been filled from **a single
observation** (24 March). The `min_obs` argument sets how many observed days a
week needs before its mean is used:

```{r}
strict <- impute_weekly_mean(
  grid[, c("site", "date", "count", "iso_week", "iso_year")],
  value = "count", by = "site", min_obs = 4
)
sum(strict$count_imputed_flag)
sum(is.na(strict$count_imputed))
```

With `min_obs = 4`, weeks with fewer than four observed days are left `NA`
rather than extrapolated.

Whatever threshold you choose, imputation replaces day-to-day variation with a
constant. That shrinks the variance of the exposure and tends to bias effect
estimates toward the null. Use the flag column to report the proportion
imputed and to run a complete-case sensitivity analysis:

```{r}
tapply(grid$count_imputed_flag, grid$site, mean)
```

## Step 4: moving averages

`build_moving_average()` appends one column per window. By default the window
is **trailing and includes the current day**: `window = 3` averages today and
the two previous days.

```{r}
grid <- build_moving_average(
  grid, value = "count_imputed", window = c(3, 7), date = "date", by = "site"
)
```

Three defaults are worth knowing, because each changes what the variable
means:

- **Edge windows are `NA`.** The first `window - 1` days of each site have no
  complete window, so a 7-day mean is `NA` for the first six days rather than
  being computed from fewer days.
- **`min_obs = window`.** Any `NA` inside a window makes the mean `NA`. You
  can lower it to tolerate missing days, but that has the same
  variance-shrinking effect as imputation.
- **`align = "center"` uses future exposure.** It is useful for smoothing a
  plot, but it should not be used to build an exposure for an outcome measured
  on the current day.

To see what `min_obs` does, apply a 7-day window to the raw (non-imputed)
counts:

```{r}
raw_ma <- build_moving_average(grid[, c("site", "date", "count")],
                               value = "count", window = 7,
                               date = "date", by = "site")
sum(is.na(raw_ma$count_ma7))

raw_ma5 <- build_moving_average(grid[, c("site", "date", "count")],
                                value = "count", window = 7, min_obs = 5,
                                date = "date", by = "site")
sum(is.na(raw_ma5$count_ma7))
```

## Step 5: lags

`apply_lag()` appends one column per lag. Lag `n` places the value from `n`
days earlier alongside the current day. `lags = 0` gives a copy of the
same-day value, so you can build a uniform `lag0, lag1, ...` model matrix.
Negative lags are an error, since a negative lag would pair an outcome with
exposure measured after it.

```{r}
grid <- apply_lag(
  grid, value = "count_imputed", lags = 0:3, date = "date", by = "site"
)
```

Lags are computed strictly within each site. The first rows of the South
series are `NA`, not the last values of the North series:

```{r}
south <- grid[grid$site == "South", ]
head(south[, c("site", "date", "count_imputed",
               "count_imputed_lag1", "count_imputed_lag3")], 4)
```

## The whole pipeline

Each function takes a data frame and returns the same data frame with new
columns appended, so the steps chain naturally:

```{r}
model_ready <- pollen_demo |>
  complete_daily_grid(date = "date", by = "site") |>
  assign_iso_week(date = "date") |>
  assign_season(date = "date") |>
  impute_weekly_mean(value = "count", by = "site") |>
  build_moving_average(value = "count_imputed", window = c(3, 7),
                       date = "date", by = "site") |>
  apply_lag(value = "count_imputed", lags = 0:3,
            date = "date", by = "site")

dim(model_ready)
names(model_ready)
```

The input columns `site`, `date`, and `count` are unchanged; everything else
was appended. If you leave out `complete_daily_grid()`, the last two steps
raise an error instead of returning misaligned lags.

## What SporeLag does not do

SporeLag builds exposure variables. It does not merge them with health
outcomes, fit models, or decide for you what counts as a pollen season,
whether imputation is acceptable, or how much missingness a window may
tolerate. Those are analytic decisions. The package makes them explicit
arguments so that they are visible in your code and can be reported.
