Skip to content

Dated reference imagery: dft_stac_composite, RGB map layers, NDWI/MNDWI (#79) - #86

Merged
NewGraphEnvironment merged 29 commits into
mainfrom
79-dated-reference-imagery-true-colour-comp
Sep 29, 2026
Merged

NewGraphEnvironment merged 29 commits into
mainfrom
79-dated-reference-imagery-true-colour-comp

Conversation

@NewGraphEnvironment

@NewGraphEnvironment NewGraphEnvironment commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Fixes #79

Stacked on #84 (#80, gdalcubes from GitHub). Merge #84 first; its four commits disappear from this diff once it lands.

Why

floodplains#93 needs dated reference imagery to run a stratified accuracy assessment of IO LULC at sample points across whole floodplains. The Esri and Google basemaps have no capture date. drift could fetch Sentinel-2, but only as a single-index monthly cube.

What

  • dft_stac_composite(), new. For each year's run of calendar months it returns a cloud-masked median of any band roles, as surface reflectance. It shares the read path with dft_stac_cube(), which was refactored into shared helpers; a live untiled cube from main and from the refactor match cell for cell (max diff 0). The cache is written as a COG.
  • Per-point chips. To review sample points, call it once per buffered point, so each chip is small and cached on its own. The planned alternative, all the points in one AOI with tiling, turned out in plan review to give a floodplain-sized raster under a single cache key.
  • dft_map_interactive(rgb =): composites as rasters (leafem) or COG URLs (titiler), drawn beneath the land cover with one per-band stretch shared across years. x may be NULL.
  • NDWI and MNDWI in dft_index_table(), with the green and blue roles.
  • HLS is split to HLS (Harmonized Landsat Sentinel-2) as a cube source: Earthdata auth, CMR-STAC query, bit-mask Fmask #82. A live spike found:
    • CMR-STAC returns 500 on intersects and ignores the CQL2 cloud filter;
    • reading needs a real Earthdata Login, because earthdatalogin's default credentials now return 401;
    • S30 and L30 name their bands differently;
    • Fmask is a bit mask.

Defects found and fixed here

found by defect fix
live e2e terra reads gdalcubes' multi-variable NetCDF alphabetically (blue, green, red); renaming by position would silently swap red and blue select layers by name
live e2e first call returned the double stack while the cache holds Float32, so it differed from later cache hits return the re-read cache
probe a date-only STAC end bound drops that day's scenes (22 of 23) explicit times in the composite; the cube's copy is #83
plan review classified layers went to leaflet in 4326 with project = FALSE, so they were misregistered against anything beneath them project to 3857
code-check R1+R2 a time on the stack made the COG writer emit a .aux.json that cache_write_atomic() stranded. The R1 fix was incomplete: the time comes from the NetCDF reader, and the fixture had none strip time before the write; the atomic publisher moves .aux.json too
code-check R1 transition legend dropped with x = NULL split the legend gate
BULK scale test Planetary Computer returns HTTP 413 past about 1 MiB of POST body (accepted at 860 KB, rejected at 1.07 MB). BULK's floodplain is 104,584 vertices (4.2 MB), so neither the cube nor the composite could query it above 20,000 vertices, query by convex hull; queries under that are unchanged
BULK scale test Signed URLs expire in about 45 min. Features were signed once, and the 54-min tiled read lost 15 of 30 tiles. gdalcubes reports failed chunks only on stderr, so the partial result would have been cached as complete re-sign features before each read extent (live: corrupted tokens read 102,364 cells after re-signing, 0 before). Detection is #87: stderr capture was tried and caught the report in 1 of 4 configurations, so it was not shipped

Also fixed: five opt-in network assertions left on the pre-#48 cache path (they are on #84 too).

Verification

  • Offline devtools::test(): FAIL 0, SKIP 15, PASS 1113. lintr: 0 lints on changed lines. pkgdown::check_pkgdown(): clean.
  • DRIFT_TEST_NETWORK=true, cube (incl. tiled vs untiled): PASS 110.
  • DRIFT_TEST_NETWORK=true, composite: PASS 64. It checks 3 bands in order, reflectance values, LAYOUT=COG, a declared NoData, identical values on a cache hit, and that false-colour NIR is more than 2× red.
  • Mutation checks each turn a test red: band order, 4326 projection, key call site, offset-straddle refusal, per-image stretch, empty-year skip, time strip, .aux.json move, layer reorder call, transition legend, transition centring.
  • Live map: 2017 Jun–Jul and 2023 Aug–Sep on the packaged AOI in Chrome. Both layers register against the Esri basemap, and 2023 reads darker and hazier under the shared stretch.
  • /code-check: three rounds, and the loop was ended by an enumeration.
    • R1: 2 findings.
    • R2: 1 finding, a defect inside the R1 fix.
    • R3: enumerated every raster write in R/ (none emits a sidecar on real output) and every test stub against its real upstream. It found 2 test gaps, now fixed.

BULK scale test (data-raw/benchmark_composite_bulk.R, RSS sampled every 2 s)

stage result
chips: 100 buffered points (300 m), 2023 Jul–Aug 84.1 min; per chip median 40.2 s, mean 50.5 s, max 140.9 s; 3,660 cells/chip; cache 3.8 MB; peak RSS 0.48 GiB
floodplain-wide, tile_size = 20000 run 1: HTTP 413 at the query. Run 2: 72 items, 30 tiles, 54 min, peak RSS 5.09 GiB; 15 tiles lost to expired tokens, then the final COG write failed (a synthetic floodplain-size COG writes fine: 82 s, 459 MB). Run 3, with re-signing: killed at a 2-h cap after 16 tiles (5.4–11.9 min each), no chunk failures. Run 4: all 30 tiles in 3 h 16 min, 0 chunk failures, peak RSS 3.90 GiB, then terra::mask could not read the merged mosaic's temp file. Open as #88: not reproduced offline (a synthetic 30-tile merge and mask with the real polygon passes in 0.7 min), and the benchmark now inventories temp files on failure. Run 2's COG-write failure was probably the same thing

Chip cost is COG reads, not the query (1.4–2.5 s of about 50), so concurrency is #85.

Follow-ups filed

Known limitation at merge: a floodplain-wide composite over an AOI the size of BULK does not yet complete (#88). Per-point chips, the path floodplains#93 needs first, and reach-scale composites do.

The last three commits are NEWS, the PWF archive (planning/archive/2026-09-issue-79-dated-reference-imagery/, whose README has the measurement record) and Release v0.18.0.

Relates to NewGraphEnvironment/sred-2025-2026#16

🤖 Generated with Claude Code

https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN

NewGraphEnvironment and others added 29 commits September 28, 2026 07:19
gdalcubes was archived on CRAN on 2026-09-16, which broke the pkgdown
workflow's dependency resolve and user installs of the STAC functions.
Upstream master (0.7.5) carries the filter_geom fix from our fork, so
Remotes points at appelmar/gdalcubes with no suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Since #48 the cache lives under <cache>/v2/<source>, but five opt-in
network assertions still listed <cache>/<source> and so failed on every
run. Found verifying gdalcubes 0.7.5; every data assertion passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
rlang::check_installed() offered a bare-name install, which now means
CRAN and fails. The three gdalcubes guards share one helper whose error
gives pak::pak("appelmar/gdalcubes"). Found by code-check round 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Adds the green and blue Sentinel-2 roles (B03, B02) that the water
indices and true-colour composites need. Tests evaluate the resolved
expressions over known reflectance and over post-2022 DN through the
offset, so they check the value and not only the string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Session setup, the cache read gate, the STAC item query with its
offset split, and the tiled or untiled assembly move into internal
helpers. The assembly takes a pixel function, so the composite coming
next can share every line of the read path. Behaviour is unchanged:
offline cube tests pass and a live untiled cube is byte-identical to
main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Each year's window (a run of calendar months) is reduced to a
cloud-masked median of the requested band roles, as surface reflectance,
through the read path shared with dft_stac_cube. Cached as a COG so it
can be served by titiler unchanged.

From the plan review:
- chips around sample points are one call per point, not one tiled call
  over all points, which would span the floodplain under one key;
- a window straddling the 2022-01-25 offset change is refused rather
  than returned as a pre-side cover;
- an empty year is dropped with a warning;
- the STAC query uses explicit times, since a date-only end bound drops
  the last day's scenes;
- the network e2e cache paths now follow the #48 scheme dir;
- a test pins how dft_stac_cube calls its cache key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
rgb takes composites as rasters (leafem) or COG URLs (titiler), drawn
beneath the classified layers with one stretch shared across years, so
a difference between years is in the data. x may be NULL for an
imagery-only map. Classified and transition layers now reach leaflet in
EPSG:3857: with project = FALSE leaflet assumes Mercator pixels, so the
4326 grid was misregistered against anything drawn beneath it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
terra reads gdalcubes' multi-variable NetCDF with its variables in
alphabetical order, so a true-colour request came back blue, green,
red. The live e2e's layer-order guard caught it; renaming by position
would have swapped red and blue silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
The first call returned the in-memory double stack while the cache holds
Float32, so a cache hit differed in the eighth significant figure. The
live e2e's same-values-on-second-call assertion caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
From code-check round 1:
- Stamping time before the COG write made terra emit a .aux.json
  sidecar that cache_write_atomic() does not move, so every composite
  build left an orphan in the cache. Time is now stamped only on read.
  A new offline test drives the whole build and write path with the
  STAC query and gdalcubes assembly stubbed.
- With x = NULL the transition legend sat inside the land-cover
  legend's gate and was dropped, and a map with no AOI and COG imagery
  was not centred. Both now follow the transition raster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Code-check round 2: the round-1 sidecar fix only stopped the code
stamping a time, but the stack already carries one read from the
gdalcubes NetCDF, so the orphan persisted on the real path. The test
fixture had no time and could not reach it.

- The composite strips time before writing and re-stamps on read.
- cache_write_atomic() now moves a terra .aux.json the same way it
  moves a GDAL .aux.xml. A COG gets one for time, units, varnames,
  longnames, metags or scoff, so a later metadata addition cannot
  strand one again.
- Both fixtures now carry a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Code-check round 3 enumerated every raster write in R/ and every test
stub against the object it stands in for. There was no production
defect, but two tests could not reach what they claimed to cover:
- the offline build stub gave every band the same value, so dropping
  composite_layers_order() stayed green (verified with that mutant);
- the network test listed only *.tif, so a sidecar beside the COG was
  invisible to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
The BULK scale test failed in 9 s: Planetary Computer returns HTTP 413
when the POST search body passes about 1 MiB (measured: accepted at
21,348 vertices / 860 KB, rejected at 26,508 / 1.07 MB). BULK's
floodplain is 104,584 vertices, so neither the cube nor the composite
could query it at all. Above 20,000 vertices the intersects geometry
is now the convex hull, a superset that changes no pixel over the AOI.
Below that the query is exactly the union, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
100 chips over the BULK floodplain, 2023 Jul-Aug: 84.1 min, median 40.2 s
per chip, peak RSS 0.48 GiB. The cost is COG reads, not the STAC query
(#85). The floodplain-wide stage follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Planetary Computer SAS tokens last about 45 minutes and features were
signed once, at query time. The BULK floodplain composite read for 54
minutes over 30 tiles, and 15 tiles came back with every chunk failed.
rstac's signer refreshes an expired token, so the features are now
re-signed per extent. Verified live: corrupted tokens read 102,364
cells after re-signing, 0 without. Detecting failed chunk reads is #87;
gdalcubes reports them only on worker stderr, which R cannot reliably
capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
…re (#79, #88)

Run 4 read all 30 tiles in 3 h 16 min with no chunk failures (the
per-extent re-signing holds at scale), peak RSS 3.90 GiB, then failed:
terra::mask could not read the merged mosaic's temp GeoTIFF. It was not
reproduced offline, and the evidence went with the session tempdir, so
the stage now inventories temp files and gdalinfo -checksum on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRhUJsuKABLfpBGktPoiBN
@NewGraphEnvironment
NewGraphEnvironment merged commit 923c7f2 into main Sep 29, 2026
1 check passed
@NewGraphEnvironment
NewGraphEnvironment deleted the 79-dated-reference-imagery-true-colour-comp branch September 29, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dated reference imagery: true-colour composites, NDWI/MNDWI, and RGB layers in dft_map_interactive (+ HLS source)

1 participant