Skip to content

Measure what a partially DEM-covered footprint costs, and report dem_elev_sd (#58) - #64

Merged
NewGraphEnvironment merged 9 commits into
mainfrom
58-measure-what-a-partially-dem-covered-f
Sep 21, 2026
Merged

NewGraphEnvironment merged 9 commits into
mainfrom
58-measure-what-a-partially-dem-covered-f

Conversation

@NewGraphEnvironment

@NewGraphEnvironment NewGraphEnvironment commented Sep 21, 2026

Copy link
Copy Markdown
Owner

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_agl to 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.

population n could reach nodata DEM-sized under 0.95
film, DEM-eligible 1,437,147 113 87 66 (0.005%)
digital 223,667 173 6 0
random film control 3,000 2,975 0

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 by half_side * sqrt(2)) is correct at every coverage.

A calibrated error bound is not shipped. 4.97 * dem_elev_sd * (1 - dem_coverage) / height_agl covers 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 need

Everything 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 — and dem_shortfall_m 3,822 m. max(fp$dem_shortfall_m) is the buffer that would contain the whole batch.

0 is an answer, not an absence. Partial coverage has two causes dem_coverage reports identically:

cause dem_shortfall_m remedy
the DEM's extent stops short of the frame > 0 re-crop by that much; recovered exactly
nodata inside an extent that already spans it 0 none — no re-crop reaches it

The 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-crop

At a fixed dem_coverage the 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.R reproduces everything from public data (BC Data Catalogue + MRDEM-30). inst/extdata/dem_coverage_{sweep,targets,population}.csv ship, 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-check rounds, ~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 in planning/archive/2026-09-issue-58-dem-coverage-cost/review-round[1-4].md.

Three worth reading:

  • A feasibility-probe figure reached the note as a measurement. "A 0.30 target comes back as 0.215 achieved" was the two-frame probe's parameterisation quoted as the sweep's. This issue's own findings file says those numbers must not reach the note; one did, and survived two rounds. Measured, a 0.30 share gives median achieved coverage 0.699.
  • Two numbers were wrong in the direction that flattered the column that shipped — the within-band rho range and the count of bands where covered_sd wins. Both were its justification, and neither had a producer line or a test.
  • Each robustness arm computes its own reference, and the shipped table kept one row per frame, so arm errors were measured against the native one (ref_n 90,692 cells against 101).

Also: tolerance in 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

  • Ocean. MRDEM returns a near-zero surface over near-shore water rather than nodata — 40,000 cells in Hecate Strait read 0.098 to 0.189 m, with no exact zeros. A coastal frame reports coverage near 1 while its mean is dragged toward sea level. Removing cells cannot generate it and dem_coverage cannot see it. Out of scope here, stated as a bound. Note the obvious guard — counting exact-zero cells — can never fire.
  • The reference is each frame's own full-coverage answer, so this is a sensitivity measure (what buffering the DEM buys), not a ground-truth accuracy claim.
  • Film only. A digital frame's DEM eligibility depends on camera_calibration_url and its scale is not an image scale, so it carries no second statement of height to anchor against.
  • One target of 120 failed its premise and is excluded by name.

Verification

  • FAIL 0 | WARN 0 | SKIP 0 | PASS 2114 (baseline 1852)
  • pkgdown::check_pkgdown() clean; R CMD build clean, 1.58 MB, data-raw/ and planning/ correctly excluded
  • Re-running the generator against unchanged inputs leaves git status clean

🤖 Generated with Claude Code

https://claude.ai/code/session_019MapqNGo4kVXGBuQNHqVdH

NewGraphEnvironment and others added 9 commits September 20, 2026 11:49
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
…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
NewGraphEnvironment merged commit e8ec070 into main Sep 21, 2026
1 check passed
@NewGraphEnvironment
NewGraphEnvironment deleted the 58-measure-what-a-partially-dem-covered-f branch September 21, 2026 02:42
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.

Measure what a partially DEM-covered footprint costs, from real frames

1 participant