Measure what a partially DEM-covered footprint costs, and report dem_elev_sd (#58) - #64
Merged
NewGraphEnvironment merged 9 commits intoSep 21, 2026
Conversation
Measurement issue: what a partially DEM-covered footprint costs. Plan-mode exploration established four facts that shape the design, and a Plan-agent review contributed 16 findings — both recorded in findings.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
…hetic one (#58) `data-raw/dem_calibrate-coverage_error.R`, stages 1 and 2. The issue cites "18 of 416 DEM-corrected frames fell under 95% dem_coverage" from the fly#50 run; no artifact holds it, so it is re-derived rather than quoted. Against MRDEM-30 — the DEM `fly_footprint()` documents as its default — partial coverage is essentially absent in BC. Candidates are found by distance to the nearest nodata cell on a 1833 m overview, read through `gdal_translate -outsize` so it resolves against the COG's own overviews: cropping the full raster and aggregating does not finish in two minutes and is the wrong instrument. A first attempt used `terra::distance(x, target = NA)` directly, which measures FROM the NA cells outward and returned 0 for every data cell, marking 99.999% of the catalogue as a candidate — implausible enough to be the instrument rather than the world. Film: 113 of 1,437,147 frames could reach nodata; 87 are DEM-sized and 66 sit under 0.95, one as low as 0.0064. Digital: 173 of 223,667 are candidates, but 167 of those take the GSD route, and every DEM-sized digital frame measured — 6 candidates and 130 controls — comes back at coverage 1.0000. The random draw is the control on the finder, which reads a coarse overview and so cannot see a nodata hole a few cells across: 0 of 2,975 randomly drawn frames are short of full coverage. Frames are drawn as contiguous runs from whole rolls, because `fly_bearing()` refuses a non-adjacent neighbour (fly#26) and a scatter of isolated frames would be measured as axis-aligned squares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
Against MRDEM-30 partial coverage is a 0.005% event in the film catalogue and a zero-frequency one in the digital catalogue, so the issue's own figure does not reproduce and the decision has to rest on what partial coverage costs rather than on how often it happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
) Stages 3-6 of the calibration script, plus five defects found running them. The sweep is 9,600 footprint calls over 120 cached DEM windows, and the machine killed it four times for memory. Each cause was different and only the last one was what the earlier two fixes had claimed: - Workers referenced `WINDOWS`, `window_radius` and `window_path` that never reached them. A PSOCK worker binds a deserialised function to its OWN global environment, so every helper has to be installed there explicitly; collected into `worker_objs()` rather than listed at each of the three cluster call sites, because a helper added later would otherwise be forgotten in one. - The resume filter tested `done$run_id` against output that carried no `run_id` column. `%in% NULL` is FALSE for everything, so nothing was ever skipped and every attempt redid the same twenty targets — while the row count grew, which is exactly what made it read as progress. Now keyed on the target frame, with a `stopifnot` so a cache missing the key fails loudly. The two independent runs it produced turned out byte-identical across every column, which is a determinism check the sweep would otherwise have needed separately. - `terra::gdalCache()` defaults to 3,276 MB PER PROCESS and `memfrac` to 0.5, so a master and two workers may reserve about 10 GB before reading a cell. Capped at 128 MB and 0.05 in every process. - The master held the 1.67M-row catalogue for the whole run because Stage 6 called `nrow(film)` at the end. Freeing it took the R heap to 0.11 GB and left resident memory at 3.30 GB, because R does not return memory to the OS and it is the resident figure the kernel reads. Stage 1 now caches its selection, so a resume never rebuilds the catalogue: 0.105 GB and seconds instead of 3.3 GB and a minute. Largest R process went 3.30 GB to 1.04 GB. - A PSOCK worker is started detached, so every kill left two workers alive holding 1.3 GB each, and those orphans caused the next kill. Three kills were blamed on other sessions before `ps -o ppid` showed the 2.6 GB was this script's own corpses. Two GDAL driver-guess failures along the way: the atomic-write idiom appends `.part`, and both `gdal_utils("translate")` and `terra::writeRaster()` guess the driver from the extension. Pass `-of GTiff`, or name the temp file `.part.tif`. The truncation treatment is the share of the NOMINAL footprint removed, computed from geometry before any DEM is read. Achieved `dem_coverage` is recorded as an outcome: truncation biases the first pass, which changes the height above ground, which resizes the rectangle, which moves the coverage again, so putting it on the x-axis would fit a feedback loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
…elev_sd (#58) 11,520 truncations of 120 real frames, cut from eight directions at eight depths by two mechanisms and compared against each frame's own full-coverage answer. The sweep and its population ship under inst/extdata so the suite recomputes every published figure instead of trusting it; a planted 50% area error in the shipped table reddens three assertions. Four results, and three of them are negative. The error is exactly the elevation bias over the height above ground — realised width error equals it to 1.6e-13 over 10,248 runs — so the pipeline adds nothing of its own and the finding transfers past this package. 0.95 stays, with a measured reason rather than the one it was chosen for. The worst linear error at or above 0.95 coverage is 0.794%, still 0.794% at 0.94, and first passes 1% at 0.92; one percent of width is about two percent of area, which is the per-corner ray-casting this model defers. It is not moved down despite the median staying under 0.05% to 0.8, because the warning's own remedy is correct at every coverage. No fallback floor, by the rule fixed before the numbers existed: none was added because no band exists where the DEM route is worse than nominal scale in the median. Below 20% coverage it is still better by nearly four times. `dem_elev_sd` ships because coverage cannot separate the frames inside a band — the error spans 13 to 52 times at a fixed coverage, and frames above their band's median spread sit 2.3 to 4.1 times further out. It is taken from values `fly_dem_sample()` already held and read off the same pass as `dem_coverage`. Two things the measurement design got wrong first and the artifacts now record. Pooled, the terrain statistics barely beat coverage (rho 0.80 against 0.72) and on that alone no column would have shipped; pooling is the wrong comparison, because coverage dominates it and the question is what remains once coverage is known. And a calibrated bound is deliberately not shipped: it covers 95.3% of held-out runs but is a median 6.1 times the actual error and holds on only 89.4% below half coverage, which is where a caller would want it. The issue's own figure does not reproduce. It reports 18 of 416 frames under 95% from the fly#50 run; against MRDEM-30 nothing in the catalogue's digital population is partially covered at all, and 66 of 1,437,147 film frames are. The likely explanation is a DEM cropped to that run's area of interest, which is inference rather than measurement and is recorded as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
Four review rounds, and rounds 2, 3 and 4 each found a defect inside the previous round's fix. The mechanism they converged on: the binder set was built by walking the TEST and not the PROSE, so the note's claims were a strict superset of the assertions and nothing enumerated the difference. What that let through, in order of how much it mattered: A figure from the two-frame feasibility probe reached the note as a measurement. "A 0.30 geometric target comes back as 0.215 achieved" was the probe's parameterisation — a fraction of the bounding box — quoted as this sweep's share of area. Measured here, a 0.30 share gives a median achieved coverage of 0.699; the feedback it was offered as evidence of has a median of 0.000, a 90th percentile of 0.055 and a maximum of 0.284. The design decision does not rest on the magnitude, and the note now says so. This is the failure the note itself warns about, it survived two review rounds, and the correction is recorded rather than deleted. Two numbers were wrong in the direction that flattered the column that shipped. `covered_grad`'s within-band rho was published as 0.27 to 0.51 over four bands of six where it is 0.27 to 0.59 over five, and that pair is the entire justification for choosing `covered_sd`. It had no producer line and no assertion. Each robustness arm computes its own full-coverage reference against its own raster, and the shipped table kept one row per frame — so arm errors were measured against the native reference, `ref_n` 90,692 cells against 101. Keyed on (airp_id, arm) now, 240 rows. A fix landing in one of two callers, twice: once on the arms loop and not the table feeding the note's headline, and once on two ADJACENT lines. The termination is an enumeration rather than a quiet round. The suite now reads the note, parses all eight of its tables, recomputes every cell from the shipped artifacts at the precision each figure is printed to, and asserts the table count so a ninth cannot be added unchecked. Comparing at printed precision rather than through `tolerance` matters: testthat's tolerance is RELATIVE, so 2e-2 pinned a three-decimal figure only to two percent and 12.529% would have survived drifting to 12.629%. Every one of the eight reddens on a planted wrong cell. Also fixed: `dem_elev_sd`'s documented NA rule said "a single cell" where the common case is zero (1,103 of 11,520 runs), which is the one place it and `dem_coverage` differ; the script and test now spell the held-out population identically rather than equivalently; the note's claim about what the suite recomputes is narrowed to tables, with prose stated as the weaker case it is. PASS 2114, FAIL 0, SKIP 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
Archives the PWF, adds the Key Decision, and bumps the version. No fallback floor and no change to 0.95 — both refused by rules fixed before the numbers existed — and one new column, `dem_elev_sd`, because at a fixed `dem_coverage` the measured error spans 13 to 52 times between frames and nothing on the row separated them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
…58) New column `dem_shortfall_m`: the metres the DEM would have to extend to contain each footprint, so `max(fp$dem_shortfall_m)` is the buffer that would contain the whole batch. Everything measured so far describes the damage. None of it told a caller what to do, and the population result says that is the gap that matters: against a remote MRDEM read partial coverage reaches 66 frames in 1.44 million, so in practice it is a DEM cropped to an area of interest that is too small. The remedy is a bigger crop, not a better statistic. On the frame where `dem_coverage` reads 0.253 and `dem_elev_sd` 94.1 m, this reads 3,822 m. ZERO IS AN ANSWER. Partial coverage has two causes that `dem_coverage` reports identically: an extent that stops short of the frame, which a re-crop recovers exactly, and nodata INSIDE an extent that already spans it, which no re-crop reaches. The warning now names which one you have. That distinction is the part `dem_coverage` structurally cannot make, and it went unbuilt because the question was framed as "describe this better" rather than "what is the caller to do". Three things this cost getting right. It is computed on the rectangle the caller RECEIVES, not the one the second pass sampled. `dem_coverage` must describe the sampled rectangle because counting cells needs a DEM read; the shortfall is pure geometry and has no such excuse. The two differed by 6.4 m on a 3,822 m shortfall — small, and exactly the kind of near-miss that makes a figure impossible to check against the object in hand. Computed once, before the warning that quotes it and the column that reports it, so those cannot come apart; the copy `fly_dem_sample()` briefly returned is gone rather than left computed and unread. A geographic DEM measures the gap in DEGREES. Unconverted it reads 0.010 where the answer is about a kilometre, so it is converted at the footprint's own latitude. That is the axis `inst/notes/terrain-correction.md` says the bundled fixture cannot reach, and it has its own test pinning the three-orders-of- magnitude failure it guards. An automatic top-up from MRDEM was considered and rejected: it partially ignores a documented argument and reaches the network without being asked. The note's table-count enumeration earned its keep immediately — adding the shortfall section took the count to nine and the assertion failed, which is the only reason the new table was accounted for rather than silently unchecked. Every table is now either recomputed or named as carrying no recomputable figure. PASS 2141, FAIL 0, SKIP 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH
NewGraphEnvironment
deleted the
58-measure-what-a-partially-dem-covered-f
branch
September 21, 2026 02:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Measures what a partially DEM-covered footprint costs, and ships one new column. Closes #58.
What the measurement found
11,520 truncations of 120 real frames, cut from eight directions at eight depths by two mechanisms, each compared against that frame's own full-coverage answer.
The error is exactly the elevation bias over the height above ground. Realised width error equals
Δelev / height_aglto 1.6e-13 over 10,248 runs, so the pipeline adds nothing measurable of its own and the result is terrain statistics rather than a property of this code.Against MRDEM-30 — the DEM the package documents as its default — partial coverage is all but absent.
The issue's own figure does not reproduce. #58 reports 18 of 416 frames under 95% from the #50 run; nothing in the catalogue's digital population is partially covered by MRDEM-30. The likely explanation is a DEM cropped to that run's AOI — which is the failure the documentation already calls the ordinary one — but that is inference, and the run left no artifact to check. So the frequency is a property of how a caller crops, not of the catalogue, and the decision below rests on cost rather than rate.
Three of the four possible remedies were refused
Each by a rule fixed before the numbers existed.
No fallback floor. No coverage band exists where the DEM route is worse than the nominal scale it replaced, in the median. Below 20% coverage it is still better by nearly four times (1.49% against 5.76%), though worse on 15% of individual runs there.
fly_dem_coverage_min()stays at 0.95 — but its justification changes from "reprojection slivers" to measurement. The worst linear error at or above 0.95 is 0.794%, still 0.794% at 0.94, and first passes 1% at 0.92. One percent of width is about two percent of area, which is what the terrain note records as the cost of the per-corner ray-casting this model defers — so 0.95 is very nearly where a truncated frame's worst case stops being cheaper than an error already accepted in every frame. It is not moved down despite the median staying under 0.05% to 0.8, because the warning's own remedy (buffer byhalf_side * sqrt(2)) is correct at every coverage.A calibrated error bound is not shipped.
4.97 * dem_elev_sd * (1 - dem_coverage) / height_aglcovers 95.3% of held-out runs, fitted on the training third alone — but it is a median 6.1 times the actual error and holds on only 89.4% of runs below half coverage, which is where a caller would want it.What did ship: two columns, and the second is the actionable one
dem_shortfall_m— the window you needEverything the sweep measures describes the damage. None of it tells a caller what to do, and the population result says that is the gap that matters: against a remote MRDEM read this reaches 66 frames in 1.44 million, so in practice it is a DEM cropped to an AOI that is too small. The remedy is a bigger crop, not a better statistic.
On one frame:
dem_coverage 0.253,dem_elev_sd 94.1 m— anddem_shortfall_m 3,822 m.max(fp$dem_shortfall_m)is the buffer that would contain the whole batch.0is an answer, not an absence. Partial coverage has two causesdem_coveragereports identically:dem_shortfall_mThe warning now names which one you have. Against a remote MRDEM read the shortfall is always
0, because the window is fetched per frame.Three things this cost getting right: it is computed on the rectangle the caller receives, not the one the second pass sampled (those differed by 6.4 m on 3,822 m — the kind of near-miss that makes a figure impossible to check against the object in hand); a geographic DEM measures the gap in degrees, which unconverted reads 0.010 where the answer is a kilometre, so it has its own test pinning that three-orders-of-magnitude failure; and an automatic top-up from MRDEM was rejected, because it partially ignores a documented argument and reaches the network without being asked.
dem_elev_sd— for the gaps you cannot re-cropAt a fixed
dem_coveragethe measured error spans 13 to 52 times between frames, and nothing on the row separated them — 47% covered over a plateau costs nothing, 80% across a valley wall costs a lot, exactly as the issue argued. Frames above their band's median spread sit 2.3 to 4.1 times further from the full-coverage answer than those below.The predictor had to be computable from the cells the DEM actually read, because that is all production has — a relief figure from the full window predicts well and could never be reported. Pooled, it barely beats coverage (0.80 against 0.72), and on that alone no column would have shipped. Pooling is the wrong comparison: coverage dominates it, and the question is what remains once coverage is known.
Reproducibility
data-raw/dem_calibrate-coverage_error.Rreproduces everything from public data (BC Data Catalogue + MRDEM-30).inst/extdata/dem_coverage_{sweep,targets,population}.csvship, and the suite parses all eight of the note's tables and recomputes every cell at the precision each figure is printed to, with the table count asserted so a ninth cannot be added unchecked. Each of the eight reddens on a planted wrong value.Review
Four
/code-checkrounds, ~1.5M subagent tokens — past the usual bound, and worth naming. Rounds 2, 3 and 4 each found a defect inside the previous round's fix; the loop terminated on an enumeration, not a quiet round. Findings are archived inplanning/archive/2026-09-issue-58-dem-coverage-cost/review-round[1-4].md.Three worth reading:
covered_sdwins. Both were its justification, and neither had a producer line or a test.ref_n90,692 cells against 101).Also:
tolerancein testthat is relative, so at 2e-2 a three-decimal figure was pinned only to ±2% and 12.529% would have survived drifting to 12.629%.What this is blind to
dem_coveragecannot see it. Out of scope here, stated as a bound. Note the obvious guard — counting exact-zero cells — can never fire.camera_calibration_urland itsscaleis not an image scale, so it carries no second statement of height to anchor against.Verification
FAIL 0 | WARN 0 | SKIP 0 | PASS 2114(baseline 1852)pkgdown::check_pkgdown()clean;R CMD buildclean, 1.58 MB,data-raw/andplanning/correctly excludedgit statusclean🤖 Generated with Claude Code
https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH