Skip to content

Commit f9509a7

Browse files
Merge pull request #26 from NewGraphEnvironment/25-dft-stac-fetch-cache-key-omits-aoi-secon
Key STAC cache by AOI and fetch parameters (fixes silent cross-AOI collision)
2 parents 793f3f7 + b9e8d80 commit f9509a7

9 files changed

Lines changed: 293 additions & 6 deletions

File tree

DESCRIPTION

Lines changed: 2 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.2
4-
Date: 2026-04-14
3+
Version: 0.2.3
4+
Date: 2026-07-06
55
Authors@R: c(
66
person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"),
77
comment = c(ORCID = "0000-0002-3495-2128")),

NEWS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# drift 0.2.3
2+
3+
- Fix silent cross-AOI cache collision in `dft_stac_fetch()` (#25). Cache files were keyed by source + year only, so fetching a second AOI with the same source/year silently returned the first AOI's raster masked to the second AOI's extent. Cache filenames now include a hash of the AOI geometry and all fetch-affecting parameters (`res`, `crs`, `dt`, `aggregation`, `resampling`, `stac_url`, `collection`, `asset`). Existing caches re-fetch on first use after upgrading; `dft_cache_clear()` reclaims the orphaned old-format files.
4+
- `force = TRUE` now overwrites the cached file instead of erroring with "File already exists" (#25).
5+
16
# drift 0.2.2
27

38
- Startup quote pool expanded to 113. Adds 52 domain-expert quotes from 11 voices across floodplain/river process (David Montgomery, Ellen Wohl), Indigenous stewardship (Robin Wall Kimmerer, Kyle Whyte, Nancy Turner, Jeannette Armstrong), ecosystem valuation (Kai Chan), Canadian public voices (David Suzuki, Wade Davis), and legacy conservation (Aldo Leopold, Wendell Berry).

R/dft_stac_fetch.R

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
#' collection hosting single-band classified rasters (IO LULC, ESA WorldCover,
66
#' custom COGs).
77
#'
8+
#' Fetched rasters are cached under [dft_cache_path()] as
9+
#' `<source>/<year>_<key>.nc`, where `key` is a hash of the AOI geometry and
10+
#' every fetch parameter that affects the output (`res`, `crs`, `dt`,
11+
#' `aggregation`, `resampling`, `stac_url`, `collection`, `asset`). Repeat
12+
#' calls with the same AOI and parameters reuse the cache; changing any of
13+
#' them re-fetches.
14+
#'
815
#' @param aoi An `sf` polygon defining the area of interest.
916
#' @param source Character. A known source name passed to [dft_stac_config()].
1017
#' Ignored when `stac_url`, `collection`, and `asset` are all provided.
@@ -24,7 +31,10 @@
2431
#' for categorical data).
2532
#' @param cache_dir Character. Cache directory path. When `NULL`, uses
2633
#' [dft_cache_path()].
27-
#' @param force Logical. Re-fetch even if cached (default `FALSE`).
34+
#' @param force Logical. Re-fetch even if cached, overwriting the cached file
35+
#' (default `FALSE`). A raster returned by an earlier call with the same
36+
#' parameters is backed by that file and may silently pick up the rewritten
37+
#' contents.
2838
#' @param sign_fn A signing function for STAC assets. Default is
2939
#' [rstac::sign_planetary_computer()].
3040
#'
@@ -97,10 +107,14 @@ dft_stac_fetch <- function(aoi,
97107
source_label <- if (!is.null(source)) source else "custom"
98108
cache_source_dir <- file.path(cache_base, source_label)
99109
dir.create(cache_source_dir, recursive = TRUE, showWarnings = FALSE)
110+
cache_key <- stac_cache_key(
111+
aoi_target, res, target_crs, dt, aggregation, resampling,
112+
stac_url, collection, asset
113+
)
100114

101115
# Fetch per year
102116
result <- lapply(years, function(yr) {
103-
cache_file <- file.path(cache_source_dir, paste0(yr, ".nc"))
117+
cache_file <- file.path(cache_source_dir, paste0(yr, "_", cache_key, ".nc"))
104118

105119
if (!force && file.exists(cache_file)) {
106120
message(" ", yr, ": cached")
@@ -123,7 +137,7 @@ dft_stac_fetch <- function(aoi,
123137
resampling = resampling
124138
)
125139
cube <- gdalcubes::raster_cube(col, v)
126-
gdalcubes::write_ncdf(cube, cache_file)
140+
gdalcubes::write_ncdf(cube, cache_file, overwrite = TRUE)
127141
r <- terra::rast(cache_file)
128142
}
129143

@@ -136,6 +150,29 @@ dft_stac_fetch <- function(aoi,
136150
}
137151

138152

153+
#' Cache key for one STAC fetch parameter set
154+
#'
155+
#' Hashes everything that changes the written raster except year, which stays
156+
#' as the readable filename prefix (all years of one call share a key). The
157+
#' geometry is hashed as WKB so sf attribute columns and PROJ-version CRS
158+
#' representation differences can't change the key; the CRS enters separately
159+
#' as `target_crs`. `res` is coerced to double so `10L` and `10` key alike.
160+
#' Callers must pass post-resolution `stac_url`/`collection`/`asset`, never
161+
#' the raw possibly-NULL arguments.
162+
#' @noRd
163+
stac_cache_key <- function(aoi_target, res, target_crs, dt, aggregation,
164+
resampling, stac_url, collection, asset) {
165+
geom_wkb <- sf::st_as_binary(sf::st_geometry(aoi_target), endian = "little")
166+
substr(
167+
rlang::hash(list(
168+
geom_wkb, as.numeric(res), target_crs, dt, aggregation,
169+
resampling, stac_url, collection, asset
170+
)),
171+
1, 12
172+
)
173+
}
174+
175+
139176
#' Auto-detect UTM EPSG code from sf geometry
140177
#' @noRd
141178
auto_utm_epsg <- function(x) {

man/dft_stac_fetch.Rd

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Issue #25 — dft_stac_fetch cache key omits AOI
2+
3+
## Outcome
4+
5+
Fixed a silent wrong-data bug: `dft_stac_fetch()` cached NetCDFs as `<source>/<year>.nc`, so a
6+
second AOI with the same source/year silently received the first AOI's raster masked to its own
7+
extent (real occurrence: MORR got Neexdzii's rasters). Added internal `stac_cache_key()`
8+
`rlang::hash()` over the AOI geometry as WKB plus every fetch-affecting parameter (`res` coerced
9+
to double, target CRS, `dt`, `aggregation`, `resampling`, post-resolution
10+
`stac_url`/`collection`/`asset`) — giving filenames `<year>_<key>.nc`; also made `force = TRUE`
11+
overwrite via `write_ncdf(..., overwrite = TRUE)` instead of erroring. Key learnings: hash sf
12+
geometry as WKB, not the sfc object (PROJ-version CRS WKT drift causes spurious misses, and sf
13+
attribute columns would leak into the key); coerce numerics before hashing (`10L` vs `10` hash
14+
differently under `rlang::hash()`); hash post-default-resolution values, never possibly-NULL
15+
args; and skip extent-containment checks on cache hits — gdalcubes only ever enlarges extents,
16+
so the check validates nothing. Verified end-to-end against live Planetary Computer STAC (two
17+
AOIs → two distinct cache files, correct extents, cache hit, force overwrite). Released as
18+
v0.2.3.
19+
20+
Closed by: commits 9e2816a / 352aec9 / fc7c4e2 / b09c0fb, PR pending (branch
21+
`25-dft-stac-fetch-cache-key-omits-aoi-secon`)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Findings — dft_stac_fetch cache key omits AOI (#25)
2+
3+
## Issue context
4+
5+
**Repo:** NewGraphEnvironment/drift · **Severity:** high (silent wrong data, no error) · **Version seen:** 0.2.2
6+
7+
### Summary
8+
9+
`dft_stac_fetch()` caches fetched rasters at `file.path(cache_source_dir, paste0(yr, ".nc"))`
10+
(`R/dft_stac_fetch.R:103`) — keyed only by **source** and **year**, with **no AOI component**. Any
11+
two calls with the same `source`/`year` but different `aoi` collide: the second call finds the
12+
first call's NetCDF, skips the fetch (when `force = FALSE`, the default), and returns the **first
13+
AOI's raster masked to the second AOI**. No warning, no error — just wrong data.
14+
15+
### Evidence (real occurrence)
16+
17+
Running two BC watershed areas through a floodplain/LULC pipeline that calls
18+
`dft_stac_fetch(source = "io-lulc", years = c(2017, 2020, 2023))`:
19+
20+
1. Area A (Neexdzii, a reach of the Bulkley) ran first → populated
21+
`~/Library/Caches/drift/io-lulc/{2017,2020,2023}.nc` with Neexdzii's extent. Correct output.
22+
2. Area B (MORR / Morice, ~80 km west, larger) ran second → `dft_stac_fetch` found the cache files
23+
and returned **Neexdzii's** rasters, masked to the MORR floodplain.
24+
25+
Cache extent vs. AOIs (EPSG:32609, metres):
26+
27+
| | E min–max | N min–max |
28+
|---|---|---|
29+
| cache `io-lulc/*.nc` | 645443–696463 | 6000758–6056578 |
30+
| **Neexdzii** fp bbox | 645444–696461 | 6000762–6056573 | ← cache == Area A |
31+
| **MORR** fp bbox | 566715–651331 | 5948369–6035818 | ← what Area B should have gotten |
32+
33+
Result: MORR's land cover was classified over only the ~3% where the Neexdzii cached extent
34+
overlaps the MORR floodplain (near the shared Bulkley/Morice confluence); "tree loss" came out
35+
22 ha of Bulkley-valley agricultural transitions instead of the true MORR figure.
36+
37+
### Secondary bug: `force = TRUE` cannot overwrite
38+
39+
`force = TRUE` routes to the fetch branch and calls `gdalcubes::write_ncdf(cube, cache_file)`
40+
without removing the existing file first. When the cache file exists, `write_ncdf` errors:
41+
42+
```
43+
Error: File already exists, please change the output filename or set overwrite = TRUE
44+
```
45+
46+
So `force = TRUE` cannot be used to bypass a stale/colliding cache — the user must manually delete
47+
the file (or call `dft_cache_clear()`).
48+
49+
### Fix (from issue)
50+
51+
1. **Put the AOI in the cache key.** Hash the AOI (bbox + geometry) into the filename. Preserves
52+
caching for repeat runs of the *same* AOI while eliminating cross-AOI collisions. (Also fold
53+
`res`, `crs`, `aggregation` into the key, since they change the output too.)
54+
2. **Fix `force = TRUE`** to overwrite instead of erroring.
55+
3. **Defensive check (optional):** on a cache hit, verify the cached raster's extent covers the
56+
requested AOI bbox; if not, re-fetch.
57+
58+
### Minimal repro
59+
60+
```r
61+
library(drift)
62+
a <- sf::st_as_sf(sf::st_sfc(sf::st_buffer(sf::st_point(c(-126.75, 54.41)), 0.1), crs = 4326))
63+
b <- sf::st_as_sf(sf::st_sfc(sf::st_buffer(sf::st_point(c(-127.75, 54.05)), 0.1), crs = 4326)) # ~65 km west
64+
ra <- dft_stac_fetch(a, source = "io-lulc", years = 2020) # fetches
65+
rb <- dft_stac_fetch(b, source = "io-lulc", years = 2020) # returns a's cached raster, masked to b -> mostly NA
66+
# terra::ext(rb[["2020"]]) matches a, not b
67+
```
68+
69+
## Plan-mode exploration (2026-07-06)
70+
71+
### Code facts
72+
73+
- Only place the `<year>.nc` filename is constructed: `R/dft_stac_fetch.R:103`. Written at :126,
74+
read at :107/:127. No other code, test, vignette, or data-raw script assumes the pattern.
75+
- `dft_cache_clear()` / `dft_cache_info()` (`R/dft_cache.R`) are filename-agnostic
76+
(`list.files(recursive = TRUE)` / `unlink(recursive = TRUE)`) — unaffected by a filename change.
77+
`dft_cache_clear(source=)` assumes only the per-source subdirectory, which is kept.
78+
- Fetch-affecting params NOT in the current key: `aoi`, `res`, `crs`, `dt`, `aggregation`,
79+
`resampling`, `stac_url`, `collection`, `asset`. All must enter the hash. `sign_fn` doesn't
80+
affect pixels; `source` remains the directory.
81+
- `rlang` and `sf` are already in Imports; `digest` is not a dependency and isn't needed —
82+
`rlang::hash()` (XXH128) works. No hashing exists anywhere in the package yet.
83+
- Existing tests never exercise the network fetch path (only `auto_utm_epsg` and the
84+
missing-gdalcubes error). Cache-key helper is unit-testable fully offline via `drift:::`.
85+
86+
### Design decisions (validated against installed sf 1.1.0 / rlang 1.2.0 / gdalcubes 0.7.3)
87+
88+
- **Hash WKB (`sf::st_as_binary(sf::st_geometry(x), endian = "little")`), not the sfc object.**
89+
sfc carries a PROJ-generated CRS WKT that drifts across PROJ versions → spurious cache misses.
90+
WKB is coordinates + geometry type only; CRS enters the key separately as `target_crs`.
91+
Also immune to sf attribute columns (verified: sf-with-attributes and bare sfc hash identically
92+
via WKB).
93+
- **`as.numeric(res)`**`10L` vs `10` serialize differently under `rlang::hash()`; identical
94+
fetches would get different keys without coercion.
95+
- **Hash post-resolution `stac_url`/`collection`/`asset`** (after the `%||%` config resolution),
96+
never the raw possibly-NULL args — otherwise `dft_stac_fetch(aoi)` and an explicit-but-identical
97+
call hash differently. Bonus: also fixes a latent collision where a custom collection with
98+
default `source = "io-lulc"` landed in the io-lulc dir keyed only by year.
99+
- **Year stays out of the hash** — key computed once before the per-year `lapply`; filename
100+
`<year>_<key>.nc` groups all years of one call under a shared readable suffix.
101+
- **`write_ncdf(..., overwrite = TRUE)` over bare `unlink()`** — gdalcubes 0.7.3 signature is
102+
`write_ncdf(x, fname, overwrite = FALSE, ...)`. Bare `unlink()` fails *silently* on Windows when
103+
a prior SpatRaster holds a GDAL handle on the file, reproducing the original confusing error.
104+
- **Extent check (issue's optional item 3) skipped** — user confirmed 2026-07-06. gdalcubes'
105+
`cube_view` only ever *enlarges* extents to fit the pixel grid, so a containment check with
106+
one-pixel tolerance would validate nothing; post-hash-fix, legacy `<year>.nc` files can never
107+
match the new pattern anyway.
108+
- **Old-format cache files become dead weight** — correct behavior; do NOT auto-delete (can't
109+
attribute them to an AOI). NEWS notes existing caches refetch and `dft_cache_clear()` reclaims
110+
space.
111+
- **POSIX silent-swap caveat** — with `force = TRUE`, a SpatRaster returned by an earlier call and
112+
backed by the same cache file may lazily reopen and see the new content. Newly reachable (the
113+
old behavior errored first), but benign under the hash key: the overwritten file corresponds to
114+
the identical parameter set. Documented in `@param force` rather than engineered around.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Progress — dft_stac_fetch cache key omits AOI (#25)
2+
3+
## Session 2026-07-06
4+
5+
- Plan-mode exploration — phases approved by user (extent check explicitly skipped)
6+
- Created branch `25-dft-stac-fetch-cache-key-omits-aoi-secon` off main
7+
- Scaffolded PWF baseline from issue #25 with approved phases
8+
- Phase 1 complete: `stac_cache_key()` helper + `<year>_<key>.nc` filenames + 5 local key tests
9+
(suite: 192 pass, 0 fail; lint clean)
10+
- Phase 2 complete: `write_ncdf(..., overwrite = TRUE)` + `@param force` doc caveat
11+
- Phase 3 complete: cache-keying note in roxygen, NEWS 0.2.3, version bump
12+
- E2E verified against live Planetary Computer STAC: two AOIs ~64 km apart produced two
13+
distinct cache files (`2020_622235623d95.nc`, `2020_d631fe72d838.nc`), each raster matched
14+
its own AOI extent, re-run hit the cache, `force = TRUE` overwrote without error
15+
- Next: /planning-archive + /gh-pr-push
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Task: dft_stac_fetch cache key omits AOI -> second area silently gets first area's raster (#25)
2+
3+
`dft_stac_fetch()` caches fetched rasters at `file.path(cache_source_dir, paste0(yr, ".nc"))`
4+
(`R/dft_stac_fetch.R:103`) — keyed only by **source** and **year**, with **no AOI component**. Any
5+
two calls with the same `source`/`year` but different `aoi` collide: the second call finds the
6+
first call's NetCDF, skips the fetch (when `force = FALSE`, the default), and returns the **first
7+
AOI's raster masked to the second AOI**. No warning, no error — just wrong data.
8+
9+
## Phase 1: Cache key includes AOI + fetch parameters
10+
11+
- [x] Add internal `stac_cache_key()` helper in `R/dft_stac_fetch.R` — WKB geometry + `as.numeric(res)` + `target_crs` + `dt` + `aggregation` + `resampling` + post-resolution `stac_url`/`collection`/`asset`, `rlang::hash()`, 12-char prefix
12+
- [x] Compute key once after AOI/CRS resolution; cache filename becomes `<year>_<key>.nc` (`R/dft_stac_fetch.R:103`)
13+
- [x] Unit tests in `tests/testthat/test-dft_stac_fetch.R` (all local, no network): determinism; shifted geometry → different key; different `res`/`crs`/`collection`/`asset`/`stac_url` → different keys; `res = 10` vs `10L` → same key; sf-with-attributes vs bare sfc → same key; key matches `^[0-9a-f]{12}$`
14+
15+
## Phase 2: force = TRUE overwrites cleanly
16+
17+
- [x] `gdalcubes::write_ncdf(cube, cache_file, overwrite = TRUE)` (`R/dft_stac_fetch.R:126`)
18+
- [x] Update `@param force` roxygen — overwrites the cached file; note that a SpatRaster returned earlier and backed by the same file may silently see new content on POSIX
19+
20+
## Phase 3: Docs + release
21+
22+
- [x] Roxygen note in `dft_stac_fetch` docs: cache entries keyed by AOI geometry + fetch parameters; `devtools::document()`
23+
- [x] NEWS.md entry for 0.2.3: bug + fix, existing caches will refetch, `dft_cache_clear()` reclaims space
24+
- [x] `lintr::lint_package()` clean + full `devtools::test()` pass (one pre-existing vignette lint, untouched by this branch)
25+
- [x] Version bump to 0.2.3 in DESCRIPTION as final commit
26+
27+
## Validation
28+
29+
- [x] Tests pass
30+
- [x] `/code-check` clean on each commit
31+
- [x] PWF checkboxes match landed work
32+
- [x] `/planning-archive` on completion

tests/testthat/test-dft_stac_fetch.R

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,55 @@ test_that("dft_stac_fetch requires gdalcubes", {
2828
)
2929
expect_error(dft_stac_fetch(aoi), "gdalcubes")
3030
})
31+
32+
# helpers for stac_cache_key tests: a unit-square polygon (optionally shifted)
33+
# and a key call with fixed defaults so each test varies one input at a time
34+
square_aoi <- function(dx = 0) {
35+
sf::st_sfc(
36+
sf::st_polygon(list(rbind(
37+
c(0 + dx, 0), c(1 + dx, 0), c(1 + dx, 1), c(0 + dx, 1), c(0 + dx, 0)
38+
))),
39+
crs = 32609
40+
)
41+
}
42+
43+
cache_key <- function(aoi = square_aoi(), res = 10, target_crs = "EPSG:32609",
44+
dt = "P1Y", aggregation = "first", resampling = "near",
45+
stac_url = "https://example.com/stac",
46+
collection = "test-collection", asset = "data") {
47+
drift:::stac_cache_key(aoi, res, target_crs, dt, aggregation, resampling,
48+
stac_url, collection, asset)
49+
}
50+
51+
test_that("stac_cache_key is deterministic and 12-char hex", {
52+
k1 <- cache_key(square_aoi())
53+
k2 <- cache_key(square_aoi())
54+
expect_equal(k1, k2)
55+
expect_match(k1, "^[0-9a-f]{12}$")
56+
})
57+
58+
test_that("stac_cache_key changes when the AOI geometry changes", {
59+
expect_false(cache_key(square_aoi()) == cache_key(square_aoi(dx = 0.5)))
60+
})
61+
62+
test_that("stac_cache_key changes with each fetch-affecting parameter", {
63+
base <- cache_key()
64+
expect_false(cache_key(res = 20) == base)
65+
expect_false(cache_key(target_crs = "EPSG:32610") == base)
66+
expect_false(cache_key(dt = "P2Y") == base)
67+
expect_false(cache_key(aggregation = "median") == base)
68+
expect_false(cache_key(resampling = "bilinear") == base)
69+
expect_false(cache_key(stac_url = "https://other.com/stac") == base)
70+
expect_false(cache_key(collection = "other-collection") == base)
71+
expect_false(cache_key(asset = "other-asset") == base)
72+
})
73+
74+
test_that("stac_cache_key treats integer and double res alike", {
75+
expect_equal(cache_key(res = 10L), cache_key(res = 10))
76+
})
77+
78+
test_that("stac_cache_key ignores sf attribute columns", {
79+
bare <- square_aoi()
80+
with_attrs <- sf::st_sf(name = "a", area = 1.5, geometry = bare)
81+
expect_equal(cache_key(with_attrs), cache_key(bare))
82+
})

0 commit comments

Comments
 (0)