Skip to content

Commit f126422

Browse files
Merge pull request #33 from NewGraphEnvironment/30-continuous-index-trajectory-change-detec
Continuous index-trajectory change detection (Sentinel-2 + BFAST) (#30)
2 parents f8112ba + ac3ada0 commit f126422

36 files changed

Lines changed: 2281 additions & 28 deletions

.Rbuildignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@
1111
^doc$
1212
^Meta$
1313
^CITATION\.cff$
14+
^planning$
15+
^\.claude$

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ docs
77
/doc/
88
/Meta/
99
vignettes/*.html
10+
11+
# local STAC cube cache (generated by data-raw/vignette_data_break.R)
12+
data-raw/.break_cache/

DESCRIPTION

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: drift
22
Title: Detecting Riparian and Inland Floodplain Transitions
3-
Version: 0.2.4
4-
Date: 2026-07-07
3+
Version: 0.3.0
4+
Date: 2026-07-08
55
Authors@R: c(
66
person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"),
77
comment = c(ORCID = "0000-0002-3495-2128")),
@@ -31,6 +31,7 @@ Imports:
3131
terra (>= 1.8-10),
3232
tibble
3333
Suggests:
34+
bfast,
3435
bookdown,
3536
DT,
3637
flooded,

NAMESPACE

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ export(dft_cache_clear)
44
export(dft_cache_info)
55
export(dft_cache_path)
66
export(dft_class_table)
7+
export(dft_index_expr)
8+
export(dft_index_table)
79
export(dft_map_interactive)
10+
export(dft_rast_break)
811
export(dft_rast_classify)
912
export(dft_rast_consensus)
1013
export(dft_rast_summarize)
1114
export(dft_rast_transition)
15+
export(dft_rast_trend)
1216
export(dft_stac_classes)
1317
export(dft_stac_config)
18+
export(dft_stac_cube)
1419
export(dft_stac_fetch)
1520
export(dft_transition_vectors)

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
# drift 0.3.0
2+
3+
- Continuous index-trajectory change detection for floodplain reaches (#30). A new fetch-and-reduce pipeline complements the categorical `dft_stac_fetch()` path. `dft_stac_cube()` builds a cloud-masked monthly spectral-index stack from Sentinel-2 (via a new `"sentinel-2-l2a"` source); `dft_rast_break()` reduces it per pixel with `bfast::bfastmonitor()` into a two-band raster of *abrupt* break date and magnitude; and `dft_rast_trend()` reduces it to a per-pixel *gradual* trend — a robust Theil-Sen slope (index change per year) with Mann-Kendall significance — for degradation/recovery monitoring the annual labels cannot show. Together they let a continuous trajectory validate categorical land-cover transitions (confirming which mapped losses carry a real spectral decline) and detect gradual change. See the "Trajectories as a Check on Land-Cover Change" vignette.
4+
- `dft_index_expr()` and `dft_index_table()` add a table-driven spectral-index registry (NDVI, kNDVI, NDMI) whose formulas are written over band *roles*, so one index resolves against any reflectance source; the reflectance scale/offset is folded into each expression.
5+
- Sentinel-2 handling is correctness-focused: `dft_stac_cube()` masks cloud/shadow/cirrus/snow, restricts to caller-chosen calendar `months` (e.g. the growing season) to sharpen the signal and cut scenes streamed, and — because the +1000 DN reflectance offset only applies from processing baseline 04.00 (2022-01-25) — splits items at that boundary and corrects each side, so a multi-year series carries no artificial index step at 2022.
6+
- `dft_stac_config()` gains a role-based schema for reflectance cube sources (band roles, mask classes, scale/offset, offset boundary), leaving the categorical `io-lulc`/`esa-worldcover` sources unchanged. `bfast` added to Suggests.
7+
- Known limitation tracked as a follow-up: the cube spans the AOI bounding box rather than the polygon (a gdalcubes `filter_geom` limitation, #32); labelling breaks with from/to land-cover classes is #31.
8+
19
# drift 0.2.4
210

311
- `dft_transition_vectors()` no longer exhausts memory on large-extent rasters (#27). The per-class loop allocated full-grid vectors per class and per patch — ncell × n_patches churn that OOM-killed a 102.6M-cell, 56-class floodplain. Replaced by a single `terra::patches(values = TRUE)` pass plus a sparse patch-to-label map. Output is identical (verified patch-by-patch against the old implementation); only `patch_id` numbering / row order changes, to raster scan order. Benchmark at 24M cells: 1.9 s for a 4,799-patch raster; the old code took 122 s on a milder 1,232-patch raster of the same size.

R/dft_index_expr.R

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#' Apply a spectral index to a data cube
2+
#'
3+
#' Resolve a named spectral index (e.g. `"kndvi"`) into a per-pixel arithmetic
4+
#' expression over a source's band roles and apply it to a `gdalcubes` data
5+
#' cube, returning a single-band cube named after the index.
6+
#'
7+
#' Index formulas are stored in a shipped registry ([dft_index_table()]) written
8+
#' over band **roles** (`red`, `nir`, `swir16`), not literal asset names. The
9+
#' roles are resolved to per-source asset names via [dft_stac_config()], so the
10+
#' same `"kndvi"` works against Sentinel-2 (`B04`/`B08`) or any future
11+
#' reflectance source without changing the formula.
12+
#'
13+
#' Reflectance `scale`/`offset` are folded **into** the expression as a per-band
14+
#' affine transform `(asset * scale + offset)`. This matters for ratio indices:
15+
#' a non-zero offset does not cancel in `(nir - red)/(nir + red)`, so computing
16+
#' NDVI on raw digital numbers is wrong for sources with an offset (Landsat C2
17+
#' L2, or Sentinel-2 processing baseline 04.00).
18+
#'
19+
#' @param cube A `gdalcubes` data cube (e.g. the lazy cube built inside
20+
#' [dft_stac_cube()]) whose bands are the source's assets.
21+
#' @param index Character. An index name present in [dft_index_table()]
22+
#' (default `"kndvi"`).
23+
#' @param source Character. Source name passed to [dft_stac_config()] to resolve
24+
#' the role→asset map and reflectance scale/offset (default
25+
#' `"sentinel-2-l2a"`).
26+
#' @param roles Named list mapping roles to asset names. When `NULL`, taken from
27+
#' `dft_stac_config(source)$roles`.
28+
#' @param scale,offset Numeric reflectance affine transform. When `NULL`, taken
29+
#' from the source config (falling back to `1` / `0`).
30+
#'
31+
#' @return A single-band `gdalcubes` cube with the band named `index`.
32+
#'
33+
#' @seealso [dft_index_table()] for the registry, [dft_stac_cube()] for the
34+
#' caller that builds the input cube.
35+
#'
36+
#' @examples
37+
#' # The registry the resolver reads:
38+
#' dft_index_table()
39+
#'
40+
#' \dontrun{
41+
#' # Applied to a lazy Sentinel-2 cube (requires network + gdalcubes):
42+
#' aoi <- sf::st_read(system.file("extdata", "example_aoi.gpkg", package = "drift"))
43+
#' cube <- dft_stac_cube(aoi, index = "kndvi") # dft_stac_cube calls this internally
44+
#' }
45+
#'
46+
#' @export
47+
dft_index_expr <- function(cube,
48+
index = "kndvi",
49+
source = "sentinel-2-l2a",
50+
roles = NULL,
51+
scale = NULL,
52+
offset = NULL) {
53+
rlang::check_installed("gdalcubes", reason = "to apply an index to a cube")
54+
cfg <- dft_stac_config(source)
55+
roles <- roles %||% cfg$roles
56+
scale <- scale %||% cfg$scale %||% 1
57+
offset <- offset %||% cfg$offset %||% 0
58+
expr <- index_resolve_expr(index, roles, scale, offset)
59+
gdalcubes::apply_pixel(cube, expr, names = index)
60+
}
61+
62+
#' Load the shipped spectral-index registry
63+
#'
64+
#' Reads the CSV index registry bundled with the package. Each row defines one
65+
#' index as a `gdalcubes`/tinyexpr formula written over band roles.
66+
#'
67+
#' @return A tibble with columns `index`, `formula`, `roles` (comma-separated
68+
#' role names), and `description`.
69+
#'
70+
#' @examples
71+
#' dft_index_table()
72+
#'
73+
#' @export
74+
dft_index_table <- function() {
75+
path <- system.file("indices", "indices.csv", package = "drift", mustWork = TRUE)
76+
tibble::as_tibble(utils::read.csv(path, stringsAsFactors = FALSE))
77+
}
78+
79+
#' Look up one index registry row, erroring on unknown index
80+
#' @noRd
81+
index_row <- function(index) {
82+
tbl <- dft_index_table()
83+
row <- tbl[tbl$index == index, ]
84+
if (nrow(row) == 0) {
85+
cli::cli_abort(c(
86+
"Unknown index {.val {index}}.",
87+
"i" = "Available indices: {.val {tbl$index}}."
88+
))
89+
}
90+
row
91+
}
92+
93+
#' Band roles required by an index (internal)
94+
#'
95+
#' Used by [dft_stac_cube()] to decide which assets to pull.
96+
#' @noRd
97+
index_roles <- function(index) {
98+
trimws(strsplit(index_row(index)$roles[[1]], ",")[[1]])
99+
}
100+
101+
#' Build a per-band affine reflectance token for an expression (internal)
102+
#'
103+
#' Returns the bare asset name when the transform is identity (`scale == 1`,
104+
#' `offset == 0`); otherwise `(asset * scale +/- |offset|)`. Numbers are
105+
#' formatted without scientific notation so the tinyexpr C parser accepts them.
106+
#' @noRd
107+
scale_token <- function(asset, scale, offset) {
108+
if (scale == 1 && offset == 0) return(asset)
109+
core <- if (scale == 1) {
110+
asset
111+
} else {
112+
sprintf("%s * %s", asset, format(scale, scientific = FALSE, trim = TRUE))
113+
}
114+
if (offset == 0) return(sprintf("(%s)", core))
115+
sign <- if (offset < 0) "-" else "+"
116+
sprintf("(%s %s %s)", core, sign,
117+
format(abs(offset), scientific = FALSE, trim = TRUE))
118+
}
119+
120+
#' Resolve an index name to a per-pixel expression string (internal)
121+
#'
122+
#' Substitutes each role token in the registry formula with its scaled asset
123+
#' token. Roles are substituted longest-name-first with word boundaries so a
124+
#' shorter role name cannot clobber part of a longer one.
125+
#' @noRd
126+
index_resolve_expr <- function(index, roles, scale = 1, offset = 0) {
127+
row <- index_row(index)
128+
formula <- row$formula[[1]]
129+
needed <- trimws(strsplit(row$roles[[1]], ",")[[1]])
130+
needed <- needed[order(nchar(needed), decreasing = TRUE)]
131+
for (role in needed) {
132+
asset <- roles[[role]]
133+
if (is.null(asset)) {
134+
cli::cli_abort(c(
135+
"Index {.val {index}} needs role {.val {role}}, absent from the role map.",
136+
"i" = "Available roles: {.val {names(roles)}}."
137+
))
138+
}
139+
formula <- gsub(paste0("\\b", role, "\\b"),
140+
scale_token(asset, scale, offset), formula)
141+
}
142+
formula
143+
}

R/dft_rast_break.R

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#' Detect per-pixel index-trajectory breakpoints
2+
#'
3+
#' Reduce a monthly index stack (from [dft_stac_cube()]) over time with
4+
#' [bfast::bfastmonitor()], returning a two-band `SpatRaster` of the break date
5+
#' and magnitude for every pixel. Where categorical differencing compares two
6+
#' land-cover labels, this asks a stronger question of a continuous index
7+
#' trajectory: *when* did the pixel's spectral history break, and by how much?
8+
#'
9+
#' `bfastmonitor` fits a season-trend model to a stable *history* period, then
10+
#' watches the *monitoring* period (from `start` onward) for a structural break.
11+
#' The returned `break_mag` is the median monitoring-period residual: **negative
12+
#' means the index dropped** (e.g. vegetation loss / channel scour), positive
13+
#' means it rose (establishment). `break_date` is a decimal year (e.g.
14+
#' `2022.42`) or `NA` where no break was detected.
15+
#'
16+
#' Pixels are reduced in parallel with `parallel::mclapply()` (forked workers, so
17+
#' the per-pixel logic and its parameters are inherited directly). Pixels with
18+
#' fewer than `min_obs` valid observations short-circuit to `NA`.
19+
#'
20+
#' @param cube A monthly index `SpatRaster` (the return value of
21+
#' [dft_stac_cube()]): one layer per time step, with a time value per layer.
22+
#' @param history Character. `bfastmonitor` history-selection method: `"all"`
23+
#' (default), `"ROC"`, or `"BP"`.
24+
#' @param start Numeric `c(year, period)`. Start of the monitoring period, in the
25+
#' stack's temporal frequency (e.g. `c(2022, 1)` = Jan 2022 for a monthly
26+
#' stack). Everything before it is the stable history.
27+
#' @param frequency Numeric or `NULL`. Seasonal frequency of the time series
28+
#' (12 for monthly, 1 for annual). When `NULL`, derived from the layer time
29+
#' spacing; when supplied, it must agree with that spacing or the call errors.
30+
#' @param order Integer. Harmonic order of the season-trend model passed to
31+
#' [bfast::bfastmonitor()] (default 3). Lower it (1-2) when the series samples
32+
#' only part of the year (e.g. a growing-season-only cube from
33+
#' [dft_stac_cube()] `months`), where a high order overfits sparse seasonal
34+
#' coverage.
35+
#' @param level Numeric. Significance level passed to [bfast::bfastmonitor()]
36+
#' (default 0.01).
37+
#' @param min_obs Integer. Minimum non-`NA` observations required to attempt a
38+
#' fit; pixels with fewer return `NA` (default 6).
39+
#' @param cores Integer or `NULL`. Forked workers for the per-pixel reduction.
40+
#' When `NULL`, uses one fewer than the detected cores.
41+
#'
42+
#' @return A two-band [terra::SpatRaster] with layers `break_date` (decimal year
43+
#' or `NA`) and `break_mag` (signed index change; negative = index drop).
44+
#'
45+
#' @seealso [dft_stac_cube()] (builds the input stack), [dft_index_expr()].
46+
#'
47+
#' @examples
48+
#' \dontrun{
49+
#' # Requires network + gdalcubes + bfast
50+
#' aoi <- sf::st_read(system.file("extdata", "example_aoi.gpkg", package = "drift"))
51+
#' cube <- dft_stac_cube(aoi, index = "kndvi", datetime = "2019-01-01/2023-12-31")
52+
#' breaks <- dft_rast_break(cube, start = c(2022, 1))
53+
#' terra::plot(breaks[["break_mag"]]) # negative (blue) = scour / veg loss
54+
#' }
55+
#'
56+
#' @export
57+
dft_rast_break <- function(cube,
58+
history = "all",
59+
start = c(2022, 1),
60+
frequency = NULL,
61+
order = 3,
62+
level = 0.01,
63+
min_obs = 6,
64+
cores = NULL) {
65+
rlang::check_installed("bfast", reason = "for trajectory breakpoint detection")
66+
if (!inherits(cube, "SpatRaster")) {
67+
cli::cli_abort("`cube` must be a SpatRaster time stack from {.fn dft_stac_cube}.")
68+
}
69+
tm <- terra::time(cube)
70+
if (length(tm) != terra::nlyr(cube) || anyNA(tm)) {
71+
cli::cli_abort(c(
72+
"`cube` must carry a time value for every layer.",
73+
"i" = "Pass the stack returned by {.fn dft_stac_cube}."
74+
))
75+
}
76+
77+
# derive the ts() start and seasonal frequency from the layer times
78+
cadence_freq <- cadence_frequency(tm)
79+
if (is.na(cadence_freq)) {
80+
cli::cli_abort("Unsupported layer cadence; use a monthly or annual stack.")
81+
}
82+
if (is.null(frequency)) {
83+
frequency <- cadence_freq
84+
} else if (!isTRUE(all.equal(as.numeric(frequency), as.numeric(cadence_freq)))) {
85+
cli::cli_abort(c(
86+
"`frequency` ({frequency}) disagrees with the layer cadence (= {cadence_freq}).",
87+
"i" = "Leave `frequency = NULL` to derive it from the stack."
88+
))
89+
}
90+
t0 <- as.Date(tm[1])
91+
yr <- as.integer(format(t0, "%Y"))
92+
mo <- as.integer(format(t0, "%m"))
93+
ts_start <- c(yr, floor((mo - 1) / (12 / frequency)) + 1)
94+
95+
if (is.null(cores)) {
96+
dc <- parallel::detectCores()
97+
cores <- if (is.na(dc)) 2L else max(1L, dc - 1L)
98+
}
99+
100+
# reduce only pixels with a usable series; the rest stay NA
101+
vals <- terra::values(cube)
102+
usable <- which(rowSums(!is.na(vals)) >= min_obs)
103+
res <- matrix(NA_real_, nrow(vals), 2)
104+
if (length(usable)) {
105+
chunks <- split(usable, (seq_along(usable) - 1) %% cores)
106+
parts <- parallel::mclapply(chunks, function(ii) {
107+
t(vapply(ii, function(i) {
108+
.dft_break_pixel(vals[i, ], ts_start, frequency, start, history, order,
109+
level, min_obs)
110+
}, numeric(2)))
111+
}, mc.cores = cores)
112+
for (k in seq_along(chunks)) res[chunks[[k]], ] <- parts[[k]]
113+
}
114+
115+
out <- cube[[1:2]]
116+
terra::values(out) <- res
117+
names(out) <- c("break_date", "break_mag")
118+
out
119+
}
120+
121+
122+
#' Per-pixel breakpoint reducer logic (internal, unit-testable)
123+
#'
124+
#' The degenerate branches (all-`NA` or fewer than `min_obs` observations) return
125+
#' `c(NA, NA)` before any `bfast` symbol is touched, so they are testable without
126+
#' bfast installed.
127+
#' @noRd
128+
.dft_break_pixel <- function(v, ts_start, frequency, start, history, order,
129+
level, min_obs) {
130+
if (all(is.na(v)) || sum(!is.na(v)) < min_obs) return(c(NA_real_, NA_real_))
131+
ts_v <- stats::ts(v, start = ts_start, frequency = frequency)
132+
tryCatch({
133+
m <- bfast::bfastmonitor(ts_v, start = start, history = history,
134+
order = order, level = level)
135+
c(m$breakpoint, m$magnitude)
136+
}, error = function(e) c(NA_real_, NA_real_))
137+
}
138+
139+
140+
#' Seasonal frequency implied by a stack's layer times (internal)
141+
#'
142+
#' Monthly spacing -> 12, quarterly -> 4, annual -> 1. Returns `NA` for
143+
#' unsupported cadences.
144+
#' @noRd
145+
cadence_frequency <- function(tm) {
146+
if (length(tm) < 2) return(NA_real_)
147+
d <- stats::median(as.numeric(diff(as.Date(tm))))
148+
if (is.na(d)) return(NA_real_)
149+
if (d >= 26 && d <= 32) return(12)
150+
if (d >= 85 && d <= 95) return(4)
151+
if (d >= 360 && d <= 370) return(1)
152+
NA_real_
153+
}

0 commit comments

Comments
 (0)