Skip to content

Commit 0a079b7

Browse files
Merge pull request #37 from NewGraphEnvironment/32-dft-stac-cube-restore-aoi-polygon-clip-f
Restore dft_stac_cube() AOI-polygon clip via terra::mask (#32)
2 parents f0ee3e2 + 10f2165 commit 0a079b7

10 files changed

Lines changed: 287 additions & 25 deletions

File tree

‎DESCRIPTION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: drift
22
Title: Detecting Riparian and Inland Floodplain Transitions
3-
Version: 0.4.0
3+
Version: 0.5.0
44
Date: 2026-07-09
55
Authors@R: c(
66
person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"),

‎NEWS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# drift 0.5.0
2+
3+
- `dft_stac_cube()` gains `clip` (default `TRUE`), restoring AOI-polygon-tight output (#32). The assembled index stack is masked to the AOI polygon with `terra::mask()` — client-side, because `gdalcubes::filter_geom()` segfaults / returns an all-NA cube on the pinned build — so cells outside the polygon are `NA` on every layer. The reduced raster from `dft_rast_break()`/`dft_rast_trend()` is now polygon-tight with no caller-side mask, and those reducers skip out-of-AOI pixels via their valid-observation gate. `clip = FALSE` keeps the full bounding box. This is an output change for callers that relied on the bounding-box extent, and the clip is folded into the cube cache key, so existing cached cubes rebuild once. Note the clip affects the *output* only — the full bbox of COGs is still streamed either way (the AOI cannot be pushed into the read on the pinned gdalcubes build).
4+
15
# drift 0.4.0
26

37
- Categorical land-cover change detection no longer exhausts memory on large-floodplain AOIs (#34, #28). `dft_rast_transition()` was rewritten to stream entirely through `terra` — transitions are encoded and filtered with raster arithmetic, `terra::subst()`, `patches()`, and a single `terra::freq()`, with no `terra::values()` pull and no full-grid R vectors — so peak memory scales with the number of distinct transitions and patches, not the grid size (producer-only peak at 16M cells dropped from 2.66 GB to 1.63 GB). Output is byte-identical to the previous version, verified by a golden snapshot across the full parameter matrix.

‎R/dft_stac_cube.R‎

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
#' @param aggregation Character. Temporal aggregation for multiple scenes in one
3838
#' `dt` window (default `"median"`).
3939
#' @param resampling Character. Spatial resampling (default `"bilinear"`).
40+
#' @param clip Logical. When `TRUE` (default), clip the returned stack to the AOI
41+
#' polygon with `terra::mask()` (cells outside → `NA` on every layer), so
42+
#' [dft_rast_break()] / [dft_rast_trend()] reduce only in-polygon pixels. Set
43+
#' `FALSE` to keep the full bounding box (e.g. for surrounding context, or to
44+
#' mask later with a different polygon). Note this clips the *output* only — the
45+
#' full bbox of COGs is still streamed either way (the AOI cannot be pushed into
46+
#' the read on the pinned gdalcubes build; see `inst/notes/gdalcubes-pc-gotchas.md`).
4047
#' @param cloud_cover_max Numeric. Scene-level `eo:cloud_cover` maximum percent
4148
#' for the STAC pre-filter (default 60).
4249
#' @param months Integer vector of calendar months (1-12) to keep, or `NULL`
@@ -58,12 +65,13 @@
5865
#' [rstac::sign_planetary_computer()].
5966
#'
6067
#' @return A [terra::SpatRaster] index stack — one layer per time step, with a
61-
#' time value per layer — cached as a GeoTIFF. The stack spans the AOI
62-
#' **bounding box** (cloud-masked but not clipped to the AOI polygon); clip the
63-
#' reduced raster from [dft_rast_break()] with `terra::mask()` if a tight AOI is
64-
#' needed. For sources with a reflectance-offset baseline boundary (Sentinel-2),
65-
#' items are split at the boundary and offset-corrected per side, so a series
66-
#' crossing it carries no artificial index step.
68+
#' time value per layer — cached as a GeoTIFF. By default (`clip = TRUE`) the
69+
#' stack is clipped to the AOI polygon (cloud-masked, cells outside the polygon
70+
#' `NA`), so the reduced raster from [dft_rast_break()] is already polygon-tight;
71+
#' pass `clip = FALSE` for the full AOI **bounding box**. For sources with a
72+
#' reflectance-offset baseline boundary (Sentinel-2), items are split at the
73+
#' boundary and offset-corrected per side, so a series crossing it carries no
74+
#' artificial index step.
6775
#'
6876
#' @seealso [dft_rast_break()] (the reducer that consumes this cube),
6977
#' [dft_index_expr()] (the index applied), [dft_stac_fetch()] (categorical
@@ -93,6 +101,7 @@ dft_stac_cube <- function(aoi,
93101
dt = "P1M",
94102
aggregation = "median",
95103
resampling = "bilinear",
104+
clip = TRUE,
96105
cloud_cover_max = 60,
97106
months = NULL,
98107
mask_values = NULL,
@@ -137,6 +146,10 @@ dft_stac_cube <- function(aoi,
137146
# each side, so a series crossing it has no artificial index step.
138147
offset_boundary <- cfg$offset_boundary
139148
offset_before <- cfg$offset_before %||% 0
149+
# normalize clip to a single scalar so the mask gate and the cache key agree: a
150+
# truthy-but-non-TRUE clip (e.g. 1 or "TRUE") must not skip the mask yet key as
151+
# TRUE, which would let a later clip=TRUE read the unclipped cube (#32).
152+
clip <- isTRUE(as.logical(clip))
140153

141154
# Ensure aoi is sf
142155
if (inherits(aoi, "SpatVector")) aoi <- sf::st_as_sf(aoi)
@@ -168,7 +181,7 @@ dft_stac_cube <- function(aoi,
168181
cache_key <- stac_cube_cache_key(
169182
aoi_target, res, target_crs, dt, aggregation, resampling,
170183
cfg$stac_url, cfg$collection, band_assets, datetime, index,
171-
cloud_cover_max, mask_values, scale, offset, months, offset_before
184+
cloud_cover_max, mask_values, scale, offset, months, offset_before, clip
172185
)
173186
cache_file <- file.path(cache_source_dir, paste0("cube_", cache_key, ".tif"))
174187

@@ -190,8 +203,8 @@ dft_stac_cube <- function(aoi,
190203
rstac::stac_search(
191204
collections = cfg$collection,
192205
# union so a multi-feature AOI queries its whole footprint, matching the
193-
# cube extent and filter_geom clip (a single first-feature geometry would
194-
# leave silent NoData holes over the other features)
206+
# cube extent and the terra::mask() clip (a single first-feature geometry
207+
# would leave silent NoData holes over the other features)
195208
intersects = sf::st_geometry(sf::st_union(aoi_wgs84))[[1]],
196209
datetime = datetime,
197210
limit = 500
@@ -229,8 +242,9 @@ dft_stac_cube <- function(aoi,
229242
# Build the index cube for one item subset with one offset, materialize it, and
230243
# read it back as a terra stack. The cube spans the AOI bounding box:
231244
# gdalcubes::filter_geom() to clip to the polygon yields an all-NA cube (and can
232-
# crash the compute worker) on the pinned build, so we mask clouds here and
233-
# leave polygon clipping to the caller, as the sibling dft_stac_fetch() does.
245+
# crash the compute worker) on the pinned build, so we mask clouds here and clip
246+
# the assembled stack to the AOI polygon afterward with terra::mask() (see
247+
# stac_cube_clip, #32), as the sibling dft_stac_fetch() does — never filter_geom().
234248
build_index_stack <- function(features, offset_use) {
235249
img_col <- gdalcubes::stac_image_collection(
236250
features, asset_names = c(band_assets, mask_asset)
@@ -268,34 +282,58 @@ dft_stac_cube <- function(aoi,
268282
stk <- build_index_stack(items$features, if (all(is_pre)) offset_before else offset)
269283
}
270284

285+
# Restore the AOI-polygon clip removed in #30: mask the assembled stack (never
286+
# gdalcubes::filter_geom, which segfaults on the pinned build). Out-of-polygon
287+
# cells become NA on every layer, so dft_rast_break()/dft_rast_trend() skip them
288+
# via their `rowSums(!is.na) >= min_obs` gate. `mask` preserves nlyr and time is
289+
# set below, so the cached tif — and the cache-read path — need no other change.
290+
if (isTRUE(clip)) stk <- stac_cube_clip(stk, aoi_target)
291+
271292
terra::time(stk) <- month_times(terra::nlyr(stk))
272293
names(stk) <- rep(index, terra::nlyr(stk))
273294
terra::writeRaster(stk, cache_file, overwrite = TRUE)
274295
stk
275296
}
276297

277298

299+
#' Clip an index stack to the AOI polygon (client-side terra mask)
300+
#'
301+
#' Restores AOI-polygon-tight output without `gdalcubes::filter_geom()`, which
302+
#' segfaults / returns an all-NA cube on the pinned build (see
303+
#' `inst/notes/gdalcubes-pc-gotchas.md`, drift#32). Cells whose centre falls
304+
#' outside the polygon become `NA` on every layer, so [dft_rast_break()] /
305+
#' [dft_rast_trend()] skip them via their `rowSums(!is.na) >= min_obs` gate. A
306+
#' multi-feature `aoi` masks to the union. Mirrors the post-read mask in
307+
#' [dft_stac_fetch()]; `aoi` is already in the stack's CRS.
308+
#' @noRd
309+
stac_cube_clip <- function(stk, aoi) {
310+
terra::mask(stk, terra::vect(aoi))
311+
}
312+
313+
278314
#' Cache key for one STAC index-cube parameter set
279315
#'
280316
#' Cube-mode analogue of `stac_cache_key()` (kept separate so the fetch key
281317
#' stays byte-for-byte stable). Hashes the AOI geometry as WKB plus every
282318
#' parameter that changes the written index cube. `res` is coerced to double so
283319
#' `10L` and `10` key alike; `mask_values` is sorted so order does not matter.
284-
#' `scale`/`offset` are included because they change pixel values.
320+
#' `scale`/`offset` are included because they change pixel values. `clip` is
321+
#' included because it changes the written extent (polygon vs bbox), so a
322+
#' `clip = FALSE` request must not read a clipped cube (or vice versa).
285323
#' @noRd
286324
stac_cube_cache_key <- function(aoi_target, res, target_crs, dt, aggregation,
287325
resampling, stac_url, collection, band_assets,
288326
datetime, index, cloud_cover_max, mask_values,
289327
scale, offset, months = NULL,
290-
offset_before = 0) {
328+
offset_before = 0, clip = TRUE) {
291329
geom_wkb <- sf::st_as_binary(sf::st_geometry(aoi_target), endian = "little")
292330
substr(
293331
rlang::hash(list(
294332
geom_wkb, as.numeric(res), target_crs, dt, aggregation, resampling,
295333
stac_url, collection, band_assets, datetime, index,
296334
as.numeric(cloud_cover_max), sort(as.numeric(mask_values)),
297335
as.numeric(scale), as.numeric(offset), sort(as.numeric(months)),
298-
as.numeric(offset_before)
336+
as.numeric(offset_before), as.logical(clip)
299337
)),
300338
1, 12
301339
)

‎inst/notes/gdalcubes-pc-gotchas.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ bfast 1.7.2.
88
- **`gdalcubes::filter_geom()` segfaults / returns an all-NA cube** on this build
99
(crashes `gc_exec_worker`, `address 0x120`). Do NOT clip to an AOI polygon
1010
inside the cube pipeline. Use the AOI bbox in `cube_view(extent=)` and
11-
`terra::mask()` the reduced raster afterward (as `dft_stac_fetch()` does).
12-
Tracked as drift#32.
11+
`terra::mask()` afterward, as `dft_stac_fetch()` does. **Resolved (#32):**
12+
`dft_stac_cube(clip = TRUE)` (the default) masks the assembled terra stack to
13+
the AOI polygon client-side (helper `stac_cube_clip()` = `terra::mask(stk,
14+
terra::vect(aoi))`), so the cube is polygon-tight and
15+
`dft_rast_break()`/`dft_rast_trend()` skip out-of-AOI pixels via their
16+
`rowSums(!is.na) >= min_obs` gate. **Residual:** this clips the *output* only —
17+
`cube_view(extent = bbox)` still streams the full bbox of COGs, so fetch time is
18+
unchanged; pushing the AOI into the read would need a working `filter_geom` or
19+
server-side windowing. `clip = FALSE` keeps the full bbox.
1320
- **`reduce_time()` R-callback runs in spawned worker processes at EVERY parallel
1421
setting** (incl. `parallel = 1`). A closure over enclosing locals fails there
1522
(`object 'band' not found`). Options: build a self-contained callback (inline

‎man/dft_stac_cube.Rd‎

Lines changed: 16 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Outcome
2+
3+
Restored `dft_stac_cube()`'s AOI-polygon clip (#32), removed in #30 when
4+
`gdalcubes::filter_geom()` proved to segfault / return an all-NA cube on the
5+
pinned gdalcubes 0.7.3 build. The clip is now done client-side: a new
6+
`clip = TRUE` default masks the assembled terra index stack to the AOI polygon
7+
with `terra::mask()` (helper `stac_cube_clip()`, mirroring `dft_stac_fetch()`).
8+
Out-of-polygon cells become `NA` on every layer, so `dft_rast_break()` /
9+
`dft_rast_trend()` skip them via their existing `rowSums(!is.na) >= min_obs` gate,
10+
and the reduced raster is polygon-tight with no caller-side mask. `clip = FALSE`
11+
keeps the full bbox.
12+
13+
Key learnings: the headline cost — streaming the full bbox of COGs via
14+
`cube_view(extent = bbox)` — is **unchanged** (a post-hoc mask can't push the AOI
15+
into the read), so the genuine wins are polygon-tight output + a modest reducer
16+
speedup, not a fetch-time saving; that residual is documented in the gotchas note.
17+
The load-bearing correctness detail was threading `clip` into **both** the mask
18+
gate and the cache key, and normalizing `clip <- isTRUE(as.logical(clip))` once so
19+
a truthy-but-non-`TRUE` input (e.g. `1`, `"TRUE"`) can't skip the mask yet key as
20+
`TRUE` — which a `/code-check` fresh-eyes pass caught.
21+
22+
Released as **v0.5.0**. `devtools::check()` clean (0E/0W/0N); full suite 319 pass;
23+
offline `stac_cube_clip()` masking test + `cube_key(clip=FALSE) != base` cover the
24+
contract network-free.
25+
26+
Closed by: PR (Fixes #32) on branch `32-dft-stac-cube-restore-aoi-polygon-clip-f`.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Findings — dft_stac_cube AOI-polygon clip (#32)
2+
3+
## Issue context
4+
5+
Restore AOI-polygon clipping in `dft_stac_cube()`. It was intended to clip the cube
6+
to the AOI polygon with `gdalcubes::filter_geom()`; on gdalcubes 0.7.3 that yields an
7+
entirely-NA cube and can segfault the compute worker, so #30 removed it. The cube now
8+
spans the AOI **bounding box** and callers clip the reduced raster with
9+
`terra::mask()`. Cost: `dft_rast_break()` reduces over the whole bbox rather than just
10+
the floodplain polygon (a few× more pixels for a thin reach). #32 scoped to the AOI
11+
clip only (the Sentinel-2 baseline-offset half shipped in #30).
12+
13+
## Exploration (2026-07-09)
14+
15+
- **Downstream consumers already skip NA pixels.** `dft_rast_break.R:101-102` and
16+
`dft_rast_trend.R:58-59` both do `vals <- terra::values(cube); usable <-
17+
which(rowSums(!is.na(vals)) >= min_obs)` and run the expensive per-pixel reducer
18+
only on `usable` rows. So masking the cube to the AOI (out-of-polygon → NA on every
19+
layer) makes those pixels drop out of the reducer for free — no change to break/trend.
20+
21+
- **Proven in-package clip pattern:** `dft_stac_fetch.R:150` —
22+
`terra::mask(r, terra::vect(aoi_target))` — applied client-side after reading. Mirror
23+
it at the cube-stack level in `dft_stac_cube()`.
24+
25+
- **`filter_geom` gotcha documented:** `inst/notes/gdalcubes-pc-gotchas.md:8-12`
26+
records the segfault (`gc_exec_worker`, `address 0x120`) and tags the fix as #32,
27+
recommending bbox `cube_view` + `terra::mask()`.
28+
29+
- **Example AOI is non-rectangular:** single MULTIPOLYGON, 2049 pts, area/bbox ≈ 0.105
30+
→ a clipped cube has all-NA bbox corners (valid opt-in network assertion).
31+
32+
## Plan-agent review (key corrections adopted)
33+
34+
1. **Compute win is largely illusory — reframe.** `cube_view(extent = bbox_target)`
35+
(`dft_stac_cube.R:218-227`) still streams the full bbox of COGs (the ~10-30 min
36+
cost per `gotchas:40-41`); `terra::values()` still loads the full bbox matrix (peak
37+
memory unchanged). Only the seconds-scale per-pixel reducer loop shrinks. Lead the
38+
rationale with **polygon-tight output + no caller-side mask needed**; describe the
39+
reducer speedup as a modest secondary effect. Fetch-time pixel savings that
40+
`filter_geom` would have given are **not** recoverable — documented residual.
41+
42+
2. **Blocker — thread `clip` into BOTH the mask step and the cache key.** The key fn
43+
has a default, so forgetting `clip` at the call site (`:168-172`) makes `clip=TRUE`
44+
and `clip=FALSE` hash identically → silent wrong-extent cache hit (no error). Add
45+
`expect_false(cube_key(clip = FALSE) == cube_key(clip = TRUE))`.
46+
47+
3. **Simplify the helper** to bare `terra::vect(aoi_target)`, matching fetch — drop the
48+
`sf::st_as_sf()` coercion unless fetch's path proves `sfc` needs it. `terra::mask`
49+
with a multi-feature SpatVector masks to the union (matches the STAC `st_union`
50+
query at `:195`).
51+
52+
4. **Ordering is safe:** mask the `terra::cover(...)` result (offset-split branch) or
53+
the single-build result before `time`/`names`/`writeRaster`. `mask` preserves
54+
`nlyr`; `time` is re-derived on every read via `month_times(terra::nlyr(r))`
55+
(`:183`), so the cache-read path needs no change.
56+
57+
5. **Behavior change + cache churn:** direct cube users now get NA outside the AOI by
58+
default; hashing `clip` invalidates every existing 0.4.0 bbox cache (one-time
59+
rebuild). Both flagged in NEWS. Considered a `_clip.tif` filename suffix to spare
60+
`clip=FALSE` users the re-fetch — rejected as needless branching for a pre-1.0
61+
package with ~one cube-cache user.
62+
63+
6. **Vignette shielded:** `vignettes/trajectory-break-detection.Rmd` loads a committed
64+
`.rds` (pipeline chunk `eval = FALSE`), so the default change doesn't alter the
65+
rendered vignette. `data-raw/vignette_data_break.R`'s `terra::mask` becomes an
66+
idempotent no-op under `clip = TRUE` — leave it (defensive).

0 commit comments

Comments
 (0)