library(dplyr)
library(ggplot2)
library(knitr)
library(DT)
source("R/utils.R")
source("R/trend_model.R")
source("R/mann_kendall.R")
source("R/plot_trend.R")
Environmental monitoring programs accumulate years of water quality data across multiple sampling areas. Assessing whether a given analyte is trending up, down, or holding steady over that record — and whether the trend differs by area — informs everything from compliance reporting to the design of the next monitoring plan.
The same underlying question — is this analyte trending, and does the trend hold across every area — comes up repeatedly across different projects and consulting engagements, usually re-implemented from scratch each time. This vignette is also an example of streamlining that common logic into one reusable pipeline rather than a new script per analyte or project. That streamlining isn’t a substitute for looking closely at each analyte, though: outlier removal, assumption checks, and the interaction test below are still run and inspected individually for TSS, Copper, Ammonia, and Iron, not applied blindly. The benefit is in not re-deriving the same statistical logic every time, while still giving each analyte its own answer.
This vignette walks through a reproducible year-over-year trend assessment applied to four case-study analytes, each chosen to exercise a different part of the pipeline:
For each analyte the pipeline:
result ~ year * area) and
iteratively removes studentized-residual outliers (|residual| >
3).emmeans post-hoc pairwise comparisons
between areas. If not, the model is reduced to the additive
year + area form.A significant year effect only says the slope is
distinguishable from zero — it doesn’t say how big the trend is. Because
year is fit as a numeric predictor rather than a factor,
the model already contains that slope; the pipeline reads it back out
and reports it as a %/year rate of change alongside every significance
test, and the Mann-Kendall step reports its own nonparametric equivalent
(Sen’s slope) alongside tau, for the same reason.
The data below are synthetic; program names and site identifiers have been genericized to three monitoring areas (Near Field, Mid Field, Effluent) so the vignette runs standalone.
This report was developed with the assistance of Claude, refactoring and streamlining analysis code and scripts originally written by hand for individual projects into the shared pipeline described below.
The four analyte sections below aren’t four separate scripts. Each
one is the same handful of function calls, defined once in
R/ and reused for every analyte:
fit_trend_model()
(R/trend_model.R) does the statistical work: removes
outliers, fits the two-way ANOVA, checks the model assumptions, tests
the area × year interaction, and — depending on that test — either fits
the additive model or keeps the interaction model and runs the
emmeans post-hoc comparison. It returns a single object
holding everything the report needs downstream: the cleaned data, the
fitted model, the interaction test, the assumption checks, and the
year-effect slope.mann_kendall_summary()
(R/mann_kendall.R) runs the nonparametric Mann-Kendall test
and Sen’s slope per area.plot_trend(), plot_posthoc(), and
plot_overview() (R/plot_trend.R),
sharing one theme and color palette from R/utils.R, turn a
fit into the figures below.summarize_trend() collapses a fit to
one row for the cross-analyte summary table in the final section.Each section’s code is just
fit_trend_model(temporal, "<analyte>") followed by
calls to these helpers. The branching — additive vs. interaction,
post-hoc or not, how many outliers to drop — is a property of the data,
decided once inside fit_trend_model(), not something
written per analyte. Of the four analytes below, only Ammonia’s data
triggers the interaction branch; Copper, TSS, and Iron all reduce to the
additive model. That asymmetry is exactly why the pipeline is
centralized rather than copy-pasted: a fixed per-analyte script has to
either special-case the interaction/post-hoc logic every time — most of
which then goes unused — or risk silently skipping it on the one analyte
where a real interaction shows up. One function tests for it on every
analyte and only pays for the post-hoc step when the data actually calls
for it.
Additive model — year + area. Every
area gets its own line, but all lines share the same slope — they’re
parallel, just shifted up or down by a constant, area-specific offset
that’s the same in every year:
\[\log(\text{result}) = \beta_0 + \beta_1 \cdot \text{year} + \beta_2 \cdot \text{area}\]
Interaction model — year * area. Adds a
per-area adjustment to the slope itself, so the lines no longer have to
be parallel — one area’s trend can rise while another stays flat:
\[\log(\text{result}) = \beta_0 + \beta_1 \cdot \text{year} + \beta_2 \cdot \text{area} + \beta_3 \cdot (\text{year} \times \text{area})\]
Both trend tests in this pipeline assume the relationship between the analyte and year is monotonic (consistently up, down, or flat). The ANOVA slope goes further and assumes that relationship is linear, once the data are log-transformed. The Mann-Kendall test relaxes the linearity assumption but still requires monotonicity, so it won’t flag a trend that rises and then falls, or reverses direction partway through the record; a pattern like that can register as “no trend” in both tests even though something real is happening in the data.
That’s a deliberate scope, not an oversight. Most compliance and
monitoring questions are “is this analyte getting worse or better, and
by roughly how much per year”, which is what a linear slope and a
monotonic rank test are built to answer. If a plot suggests curvature,
such as a level shift after a known event or a rise followed by a
plateau, a nonlinear method like a spline or generalized additive model
(GAM) fits the data better than either test here. That’s a different
model, not a variant of this one: it replaces the linear
year term with a smooth function of year, and the slope and
%/year reporting used throughout this vignette wouldn’t carry over
directly.
| Term | What it does | Plain-language meaning |
|---|---|---|
| Breusch-Pagan test | Tests whether residual variance is constant across fitted values (homoscedasticity). A low p-value flags heteroscedasticity. | “Is the model’s scatter roughly the same size everywhere, or does it fan out?” |
| Shapiro-Wilk test | Tests whether model residuals are normally distributed. A low p-value flags non-normal residuals. | “Do the leftover errors look bell-shaped, like they should?” |
| emmeans | Computes model-based estimated marginal means and pairwise contrasts between groups (here, areas at the most recent year), with multiple-comparison-adjusted p-values. | “After accounting for year and area, which areas actually differ from each other, and by how much?” |
| Mann-Kendall test | A nonparametric test for a monotonic trend over time, run per area. Doesn’t assume normality or a linear relationship — a lightweight cross-check on the ANOVA slope. Paired with Sen’s slope, its nonparametric estimate of trend magnitude. | “Ignoring the model entirely, is this area’s data going up, down, or flat over time, and by roughly how much per year?” |
temporal <- read.csv("data/synthetic/temporal_wq.csv")
Three of the four analytes look broadly similar at a glance — scattered points across ten years, colored by area. The statistical tests below are what actually separate “a real trend” from “no trend” from “a trend that only shows up in one area.”
TSS rises at a similar rate in every monitoring area, so the area × year interaction should come back non-significant and the pipeline should fall through to the additive model.
tss_fit <- fit_trend_model(temporal, "TSS")
| Analyte | Outliers removed | Model | Interaction p-value | Breusch-Pagan p-value | Shapiro-Wilk p-value |
|---|---|---|---|---|---|
| TSS | 1 | additive | 0.174 | 0.0613 | 0.998 |
| Area | tau | p-value | Sen’s slope (%/year) | 95% CI (%/year) | n |
|---|---|---|---|---|---|
| Effluent | 0.297 | 7.12e-03 | 2.99 | 0.997 to 5.23 | 40 |
| Near Field | 0.146 | 1.88e-01 | 1.75 | -0.824 to 4.28 | 40 |
| Mid Field | 0.501 | 7.60e-06 | 4.79 | 3.08 to 6.54 | 39 |
The interaction term is not significant (p = 0.174), confirming a
shared upward trend rather than area-specific behavior. That test only
says the shared slope is distinguishable from zero, though — the size of
it comes from the year coefficient in the additive model,
back-transformed to a %/year rate:
| Area | Slope (log/year) | Slope (%/year) | 95% CI (%/year) | p-value |
|---|---|---|---|---|
| All areas | 0.0325 | 3.31 | 2.13 to 4.5 | 1e-07 |
TSS is rising at roughly 3.31% per year. The Mann-Kendall tau values are positive in every area, consistent with the ANOVA result, and its Sen’s slope estimates (also above, in the per-area Mann-Kendall table) land in the same range without assuming a linear model or normal residuals — a nonparametric cross-check on both the direction and the magnitude of the trend.
Copper concentrations are flat over the record in every area. This is the contrast case: no significant year effect, no interaction, nothing to report.
copper_fit <- fit_trend_model(temporal, "Total Copper")
| Analyte | Outliers removed | Model | Interaction p-value | Breusch-Pagan p-value | Shapiro-Wilk p-value |
|---|---|---|---|---|---|
| Total Copper | 0 | additive | 0.752 | 0.907 | 0.147 |
| Area | tau | p-value | Sen’s slope (%/year) | 95% CI (%/year) | n |
|---|---|---|---|---|---|
| Effluent | -0.0205 | 0.861 | -0.298 | -2.2 to 1.49 | 40 |
| Near Field | 0.0179 | 0.880 | 0.112 | -1.46 to 1.96 | 40 |
| Mid Field | 0.1150 | 0.300 | 0.861 | -1.1 to 2.45 | 40 |
| Area | Slope (log/year) | Slope (%/year) | 95% CI (%/year) | p-value |
|---|---|---|---|---|
| All areas | 0.00277 | 0.277 | -0.732 to 1.3 | 0.589 |
Mann-Kendall p-values are all well above 0.05 in every area — no
evidence of a monotonic trend, consistent with the flat ANOVA fit. The
year coefficient’s confidence interval also spans zero, so
there’s no slope to report here beyond noise — the non-significant
p-value and the near-zero estimate agree.
Ammonia is where the pipeline’s interaction branch actually matters: Effluent concentrations rise steadily while Near Field and Mid Field stay flat. A model that only looked at the overall year effect would miss this; the area × year interaction term is what catches it.
ammonia_fit <- fit_trend_model(temporal, "Total Ammonia")
| Analyte | Outliers removed | Model | Interaction p-value | Breusch-Pagan p-value | Shapiro-Wilk p-value |
|---|---|---|---|---|---|
| Total Ammonia | 0 | interaction | 0 | 0.439 | 0.985 |
Before deciding which model to report, the pipeline tests the null
hypothesis that the area × year interaction term is zero — i.e., that
all three areas share the same trend — against the alternative that at
least one area’s trend differs, using a Type III F-test
(car::Anova) at α = 0.05. If the interaction p-value is at
or above 0.05, that term is dropped and the simpler additive model
(year + area) is reported instead, since there’s no
statistical evidence the trends differ; because the additive model’s
area offset is constant across years, a single ANOVA main-effect test
for area already tells you whether areas differ overall, so
there’s no need for a post-hoc, year-specific comparison. If it’s below
0.05, as is the case here, the full interaction model is kept and
emmeans post-hoc pairwise comparisons are run between areas
at the most recent year, to identify specifically which areas differ
once the shared trend is allowed to vary by area.
The Type III ANOVA table below has one row per term: the
year and area main effects, and the
year:area interaction. When the interaction is significant,
it’s the row that matters most — a significant year:area
means the year and area main-effect rows are
no longer straightforwardly interpretable on their own (the “effect of
year” isn’t a single number once it’s allowed to differ by area), so the
interaction result is what actually drives what gets reported below:
| Sum Sq | Df | F value | Pr(>F) | |
|---|---|---|---|---|
| (Intercept) | 2.1272 | 1 | 71.5583 | 0 |
| year | 2.2171 | 1 | 74.5823 | 0 |
| area | 1.4922 | 2 | 25.0985 | 0 |
| year:area | 1.5106 | 2 | 25.4089 | 0 |
| Residuals | 3.3888 | 114 | NA | NA |
The interaction is significant (p = 7.49^{-10}), so the pipeline
keeps the full interaction model and runs emmeans post-hoc
comparisons between areas at the most recent year, rather than reducing
to the additive model:
| contrast | estimate | SE | df | t.ratio | p.value |
|---|---|---|---|---|---|
| Effluent - Mid Field | 1.6163 | 0.0717 | 114 | 22.5561 | 0 |
| Effluent - Near Field | 1.2886 | 0.0717 | 114 | 17.9827 | 0 |
| Mid Field - Near Field | -0.3277 | 0.0717 | 114 | -4.5735 | 0 |
Because the interaction is significant, there’s no single shared
slope to report — each area gets its own, estimated from the fitted
model via emmeans::emtrends():
| Area | Slope (log/year) | Slope (%/year) | 95% CI (%/year) | p-value |
|---|---|---|---|---|
| Effluent | 0.08200 | 8.540 | 6.52 to 10.6 | 0.000 |
| Near Field | -0.00676 | -0.673 | -2.52 to 1.21 | 0.478 |
| Mid Field | 0.00658 | 0.660 | -1.21 to 2.57 | 0.490 |
| Area | tau | p-value | Sen’s slope (%/year) | 95% CI (%/year) | n |
|---|---|---|---|---|---|
| Effluent | 0.6380 | 0.000 | 8.440 | 6.52 to 10.4 | 40 |
| Near Field | -0.0513 | 0.650 | -0.565 | -2.89 to 1.48 | 40 |
| Mid Field | 0.0667 | 0.552 | 0.703 | -1.17 to 2.68 | 40 |
The Mann-Kendall results confirm the same asymmetry at the individual-area level: a strong, significant upward trend in Effluent, and no trend in Near Field or Mid Field. Its Sen’s slope for Effluent lands close to the ANOVA estimate above, giving the same magnitude by an assumption-light route.
Iron is flat like copper, but the record includes a handful of
erroneously high results — the kind of data-entry or instrument spike
that shows up in any long-running monitoring dataset.
fit_trend_model() flags these via studentized residuals and
refits until none remain.
iron_fit <- fit_trend_model(temporal, "Total Iron")
| Analyte | Outliers removed | Model | Interaction p-value | Breusch-Pagan p-value | Shapiro-Wilk p-value |
|---|---|---|---|---|---|
| Total Iron | 5 | additive | 0.64 | 0.608 | 0.226 |
5 observation(s) were removed as outliers before the reported model was fit.
| Area | tau | p-value | Sen’s slope (%/year) | 95% CI (%/year) | n |
|---|---|---|---|---|---|
| Effluent | -0.0901 | 0.440 | -0.961 | -2.59 to 1.14 | 37 |
| Near Field | 0.0850 | 0.453 | 0.703 | -1.29 to 2.7 | 39 |
| Mid Field | 0.0472 | 0.681 | 0.439 | -1.93 to 2.87 | 39 |
| Area | Slope (log/year) | Slope (%/year) | 95% CI (%/year) | p-value |
|---|---|---|---|---|
| All areas | -0.00156 | -0.155 | -1.27 to 0.969 | 0.784 |
As with Copper, the slope estimate is small and its confidence interval spans zero — flat, once the outliers are out of the way.
| Analyte | Outliers removed | Model | Interaction p-value | Breusch-Pagan p-value | Shapiro-Wilk p-value |
|---|---|---|---|---|---|
| TSS | 1 | additive | 0.174 | 0.0613 | 0.998 |
| Total Copper | 0 | additive | 0.752 | 0.9070 | 0.147 |
| Total Ammonia | 0 | interaction | 0.000 | 0.4390 | 0.985 |
| Total Iron | 5 | additive | 0.640 | 0.6080 | 0.226 |
TSS shows a real, shared upward trend; Copper shows none; Ammonia’s trend is confined to Effluent; Iron is flat once its outliers are removed. Four different outcomes, produced by running the same pipeline described at the outset against each analyte in turn — not four different analyses.