From bab47047b50eec4cd22eb7bbfe98d9b07479cd78 Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:03:16 -0700 Subject: [PATCH 1/7] Initialize PWF baseline for #60 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- planning/active/findings.md | 83 ++++++++++++++++++++++++++++++++ planning/active/progress.md | 10 ++++ planning/active/task_plan.md | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 planning/active/findings.md create mode 100644 planning/active/progress.md create mode 100644 planning/active/task_plan.md diff --git a/planning/active/findings.md b/planning/active/findings.md new file mode 100644 index 0000000..ac89b3b --- /dev/null +++ b/planning/active/findings.md @@ -0,0 +1,83 @@ +# Findings — ~2,000 film frames carry a FLYING_HEIGHT far too small, and three remedies fit equally (#60) + +## Issue context + +## Problem + +fly#54 found the catalogue's `FLYING_HEIGHT` 10.764 times too large on 1,589 film frames and +repairs it. The same sweep found **1,963 film frames whose `FLYING_HEIGHT` is under half what +their `SCALE x FOCAL_LENGTH` implies** (1,962 of them terrain-sampled; the figures below are over +those), and about 7,400 under 0.9. Those are deliberately +**not** repaired: `fly_footprint(dem = )` marks them `height_source = "implausible"` and sizes +them from nominal scale. + +They are not repaired because three different remedies each explain a comparable share and +the terrain cannot choose between them. Over MRDEM-30, of the 1,962: + +| remedy | frames it brings inside [1/1.6, 1.6] | reading | +|---|---|---| +| x 10.764 | 726 | the fly#54 slip, run the other way | +| x 10 | 729 | a dropped digit in a height in feet — 609 m is 2,000 ft | +| x 2 | 799 (a different set of rolls) | a 153 mm lens catalogued as 305 — 519 of the 799 are catalogued at 305, but 280 are at 153, which this reading cannot explain | + +Nor does 10.764 sort this tail the way it sorts the upper one: it scatters the 1,962 from +-0.70 to 5.38, leaving 130 still below the band and 1,106 above it, where the forward repair +puts all 1,589 at 0.80-1.32 with nothing between 6.69 and 10.01. Examples: `bc79122` 640 m at 1:20000 / 305 mm, +`bc7584`, `bc7675`, `bc81013`, `bc77032`; and at 0.500 `bc80001`, `bcc162`, `bc79209`. +`bc7280` reads 60 m and `bc5598` 2 m, which nothing explains. + +The mirror of the x2 case is on the high side too: a mass centred on r = 2, where 209 of the +223 frames sampled beyond r 1.8 are catalogued at 153 mm — a 305 recorded as 153. + +## Why it is worth an issue + +For these frames nominal scale is the right fallback for the lens-mislabel cases (it never +reads `focal_length`) and an unknown quantity for the rest. Nothing is lost today — most were +already falling back, since 588 of the 1,962 have terrain *above* the recorded height — but +they cannot get the ~14% terrain correction until the height can be trusted. + +## What would settle it + +Something independent of `FLYING_HEIGHT`, `SCALE` and `FOCAL_LENGTH`: + +- the flight log (`flight_log_url`) where one exists, which states the planned altitude; +- adjacent-frame spacing along a leg against the footprint width each remedy implies — + forward overlap is designed to about 60%, so a remedy that implies 5% or 95% is wrong. + `fly_bearing()` already identifies contiguous legs; +- for the lens mislabel specifically, the calibration report or camera named on the roll. + +## Evidence + +`inst/extdata/flying_height_sweep.csv` (`set == "lower_tail"` and `"near_upper"`), +`data-raw/height_calibrate-flying_height_slip.R`, and the "held against the scale" section +of `inst/notes/terrain-correction.md`. + +## Per-roll structure (plan-mode exploration, 2026-09-26) + +From `inst/extdata/flying_height_sweep.csv`, `set == "lower_tail"`: 1,962 frames on 42 rolls. +Most rolls carry a single `flying_height` value (exceptions: bcb05001 29 values, bc5524 3, +bc79067 4, bc7717 2, bc5097 2), and those values are round feet converted to metres. +Implied factor k = (scale_n * f/1000 + elev) / flying_height, per roll median (IQR/median): + +| roll | n | f | scale | H m | k | +|---|---|---|---|---|---| +| bc79122 | 149 | 305 | 20000 | 640 | 9.63 (0.021) | +| bc7717 | 127 | 305 | 12500 | 1676 | 2.33 (0.027) | +| bc79067 | 125 | 305 | 50000 | 4968 | 3.21 (0.030) | +| bc7584 | 124 | 305 | 20000 | 609 | 11.97 (0.039) | +| bc79086 | 107 | 305 | 60000 | 6325 | 3.07 (0.013) | +| bc80001 | 106 | 305 | 10000 | 1524 | 2.03 (0.016) | +| bc5697 | 102 | 153 | 6000 | 457 | 2.03 (0.013) | +| bc79209 | 85 | 305 | 50000 | 7620 | 2.00 (0.002) | +| bc7675 | 84 | 305 | 16000 | 609 | 10.01 (0.053) | +| bc81013 | 59 | 153 | 40000 | 823 | 8.69 (0.067) | + +(full 42-row table regenerated by the Phase 1 script.) bc79209 at 25,000 ft is a 153 mm lens +catalogued as 305 (1:50000 x 0.153 = 7,650 m); doubling its height would put it at 50,000 ft. +The cache `data-raw/.cache/centroids/.rds` holds x, y (EPSG:3005) and frame_number for every +frame, so the spacing instrument needs no network. `flight_log_url` points at JPG logbook pages. + +## Errors Encountered + +| Error | Resolution | +|-------|------------| diff --git a/planning/active/progress.md b/planning/active/progress.md new file mode 100644 index 0000000..2e3b193 --- /dev/null +++ b/planning/active/progress.md @@ -0,0 +1,10 @@ +# Progress — ~2,000 film frames carry a FLYING_HEIGHT far too small, and three remedies fit equally (#60) + +## Session 2026-09-26 + +- /planning-init 54 aborted: #54 closed 2026-09-20, shipped in v0.12.0 +- Plan-mode exploration — found the lower tail is 42 rolls with one round-feet height each; phases approved by user +- Plan gate decision: per-roll table keyed on film_roll + flying_height, with an excluded list +- Created branch `60-lower-tail-flying-height` off main +- Scaffolded PWF baseline from issue #60 with approved phases +- Next: start Phase 1 diff --git a/planning/active/task_plan.md b/planning/active/task_plan.md new file mode 100644 index 0000000..8dada43 --- /dev/null +++ b/planning/active/task_plan.md @@ -0,0 +1,91 @@ +# Task: ~2,000 film frames carry a FLYING_HEIGHT far too small, and three remedies fit equally (#60) + +fly#54 found the catalogue's `FLYING_HEIGHT` 10.764 times too large on 1,589 film frames and +repairs it. The same sweep found **1,963 film frames whose `FLYING_HEIGHT` is under half what +their `SCALE x FOCAL_LENGTH` implies** (1,962 of them terrain-sampled; the figures below are over +those), and about 7,400 under 0.9. Those are deliberately +**not** repaired: `fly_footprint(dem = )` marks them `height_source = "implausible"` and sizes +them from nominal scale. + +They are not repaired because three different remedies each explain a comparable share and +the terrain cannot choose between them. Over MRDEM-30, of the 1,962: + +| remedy | frames it brings inside [1/1.6, 1.6] | reading | +|---|---|---| +| x 10.764 | 726 | the fly#54 slip, run the other way | +| x 10 | 729 | a dropped digit in a height in feet — 609 m is 2,000 ft | +| x 2 | 799 (a different set of rolls) | a 153 mm lens catalogued as 305 — 519 of the 799 are catalogued at 305, but 280 are at 153, which this reading cannot explain | + +Nor does 10.764 sort this tail the way it sorts the upper one: it scatters the 1,962 from +-0.70 to 5.38, leaving 130 still below the band and 1,106 above it, where the forward repair +puts all 1,589 at 0.80-1.32 with nothing between 6.69 and 10.01. Examples: `bc79122` 640 m at 1:20000 / 305 mm, +`bc7584`, `bc7675`, `bc81013`, `bc77032`; and at 0.500 `bc80001`, `bcc162`, `bc79209`. +`bc7280` reads 60 m and `bc5598` 2 m, which nothing explains. + +The mirror of the x2 case is on the high side too: a mass centred on r = 2, where 209 of the +223 frames sampled beyond r 1.8 are catalogued at 153 mm — a 305 recorded as 153. + +## Context + +#54 repaired the ×10.764 slip and left 1,962 lower-tail film frames `"implausible"` because +×10.764, ×10 and ×2 each brought a comparable share into the band. Exploration showed the unit +of evidence is the **roll**, not the frame: the 1,962 frames sit on **42 rolls**, nearly every +roll carries **one** `flying_height` that is a round number of feet (609 m = 2,000 ft, +7,620 m = 25,000 ft), and the implied factor `k = (scale·f + elev) / H` is tight per roll +(IQR mostly <3%) but lands on 2.00 (bc79209, bc80001, bc5697, bc81026), near 10 (bc7675, +bc5655, bc7835) and on 2.2–9 for many rolls that none of the three remedies explains +(bc7717 2.33, bc79067 3.21, bc81013 8.69). The pooled table in the issue hid all of this. + +User decision (plan gate): if evidence separates rolls, ship a **per-roll table keyed on +`film_roll` + recorded `flying_height`**, with an excluded list carrying a reason for every +roll left alone; `fly_footprint()` consults it. If nothing separates, ship measurement + note. + +Three instruments, each independent of the fields in dispute, all public: +1. **Round-feet / per-roll factor** — from the sweep CSV and centroid cache. +2. **Adjacent-frame spacing** — cache (`data-raw/.cache/centroids/`, x/y/frame for 1.67M + frames) gives air base B between frames one number apart; true along-track width ≈ B/(1−p), + p ≈ 0.6 by design. Separates ×2 from ×10 and a wrong `scale` from a wrong height; cannot + separate ×10 from ×10.764 (7.6%) — stated as a bound. +3. **Logbook scans** at `flight_log_url` (JPG pages, e.g. `thumbs/logbooks/1968/roll_pages/bc5282_1.jpg`) + — read the stated altitude by eye for the 42 rolls. + +## Phase 1: Instruments and their controls (data-raw only) +- [ ] `data-raw/height_calibrate-lower_tail_rolls.R` (loads source via `pkgload::load_all()`, reads the #54 cache + sweep; no refetch of what is cached) +- [ ] Per-roll table: n, distinct heights, H in ft, roundness of H·k in ft for k ∈ {1, 2, 10, 10.764}, median/IQR of k +- [ ] Spacing: implied forward overlap per roll under nominal, reported and each remedy, using only frames adjacent by frame number (same rule as `fly_bearing()`), reusing `fly_bearing()`'s adjacency logic rather than re-deriving it +- [ ] Spacing **positive controls before reading the lower tail**: random in-band set (expect ~60% under reported), #54 slipped set after ÷10.764 (known answer), near_upper r≈2 mass (expect nominal ~60%, DEM route wrong); print them first and stop if the controls fail +- [ ] Fetch `flight_log_url` for the 42 rolls via WFS (`bcdata::filter`, not constructed paths); cache JPGs under gitignored `data-raw/.cache/logbooks/`; include 3–4 control rolls (random + #54 slipped) to establish what the logbook altitude means (ASL/AGL, ft/m) +- [ ] Read logbook pages; record roll, page, stated altitude, units, reader note in a transcription CSV + +## Phase 2: Verdict per roll +- [ ] Classify each of the 42 rolls: factor supported by ≥2 independent instruments → correction row; otherwise excluded with reason (disagree / unreadable log / no adjacent frames / unexplained factor) +- [ ] Ship `inst/extdata/flying_height_rolls.csv` (film_roll, flying_height, factor, cause, evidence columns per instrument) and `flying_height_rolls_excluded.csv` (film_roll, flying_height, reason) +- [ ] Re-check against the full population: a roll+height key must match only the lower-tail frames it was measured on (count frames the key would touch in the cache vs frames measured) +- [ ] If no roll reaches two instruments: skip Phase 3, go to Phase 4 as measurement-only + +## Phase 3: `fly_footprint()` consults the table (tests first) +- [ ] Failing tests in `tests/testthat/test-fly_footprint_height.R`: a fixture row on a tabled roll+height is sized from the corrected height with `height_source == "corrected_roll_table"`; same roll at a different height is untouched; an excluded roll stays `"implausible"`; the table-corrected frame is classified before the second DEM pass (assert on the grids, as #54's test does) +- [ ] Table constants read by internal `fly_height_roll_table()` in `R/fly_footprint.R`; applied where `slipped` is computed (~line 1082), only to frames still `disputed` and only where the corrected r lands in band +- [ ] Exclude the new class from `unusable` by name (CLAUDE.md gotcha: `unusable` is a residual) +- [ ] Warning reports table-corrected count once; roxygen for `height_source` documents the new value +- [ ] Test reads the shipped CSV and recomputes each row's factor from `flying_height_sweep.csv` so the table is checked against data, not trusted; restore-the-bug proof for each new assertion +- [ ] `centroid_shapes()` sweep covers the new column path (tibble / bcdc_sf) + +## Phase 4: Record +- [ ] `inst/notes/terrain-correction.md`: replace "three remedies the terrain cannot tell apart" with the per-roll finding, each instrument's control result, and its bound (spacing cannot split 10 from 10.764) +- [ ] CLAUDE.md Key Decisions entry; Architecture line for the new script/CSVs +- [ ] Edit #60 body with the per-roll finding (not a comment); file follow-ups for unexplained rolls and for the near_upper 305-as-153 mass if not handled +- [ ] NEWS.md + version bump as the final commit + +## Validation + +- [ ] Tests pass (`devtools::test()`; `NOT_CRAN=true` for single-file reruns) +- [ ] `/code-check` clean on each commit +- [ ] PWF checkboxes match landed work +- [ ] `/planning-archive` on completion + +## Critical files +`R/fly_footprint.R` (lines ~200–230 constants, ~1060–1130 classification, ~1282 height_source), +`data-raw/height_calibrate-flying_height_slip.R` (cache + sweep pattern, PSOCK for remote reads), +`R/fly_bearing.R` (adjacency rule), `tests/testthat/setup.R` (`height_fixture()`), +`inst/extdata/flying_height_sweep.csv`, `inst/notes/terrain-correction.md`. From 8c2d00d5741c17701c235ec1df2587566cee4777 Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:41:49 -0700 Subject: [PATCH 2/7] Settle the lower tail of flying_height per roll (#60) The 1,962 frames #54 left implausible sit on 42 rolls with one round-feet height each, so the tail is 77 roll-heights. Two instruments that read none of the disputed fields settle 22 of them (1,001 frames): adjacent-frame spacing against the ~60% designed overlap, and the province's scanned flight logbooks, transcribed by hand into data-raw/flying_height_logbooks.csv with three control rolls' catalogue values withheld. Ships inst/extdata/flying_height_rolls.csv (10 dropped-digit, 1 two-digit, 11 right-height-wrong-scale) and an excluded list with a reason for each of the other 55. The height shipped is the logbook's, converted, not the catalogue's times the factor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- data-raw/flying_height_logbooks.csv | 194 +++++++++ data-raw/height_calibrate-lower_tail_rolls.R | 404 ++++++++++++++++++ inst/extdata/flying_height_rolls.csv | 23 + inst/extdata/flying_height_rolls_excluded.csv | 56 +++ planning/active/findings.md | 74 ++++ planning/active/progress.md | 7 + planning/active/review-round1.md | 66 +++ planning/active/review-round2.md | 45 ++ planning/active/review-round3.md | 100 +++++ planning/active/task_plan.md | 32 +- 10 files changed, 985 insertions(+), 16 deletions(-) create mode 100644 data-raw/flying_height_logbooks.csv create mode 100644 data-raw/height_calibrate-lower_tail_rolls.R create mode 100644 inst/extdata/flying_height_rolls.csv create mode 100644 inst/extdata/flying_height_rolls_excluded.csv create mode 100644 planning/active/review-round1.md create mode 100644 planning/active/review-round2.md create mode 100644 planning/active/review-round3.md diff --git a/data-raw/flying_height_logbooks.csv b/data-raw/flying_height_logbooks.csv new file mode 100644 index 0000000..17770c7 --- /dev/null +++ b/data-raw/flying_height_logbooks.csv @@ -0,0 +1,194 @@ +"file","control","film_roll","page_rolls","frames_final","frame_from","frame_to","height_as_written","height_header","height_ft_interpreted","focal_as_written","focal_mm","scale_as_written","legibility","note" +"bc5097__bc5097_1.jpg",FALSE,"bc5097","BC 5097","1-90",1,90,"18.0","M'/M.S.L.","18000","6""","153","","clear","typed form; sheet 1 of 4; camera RC8 355; 18.0 on every row" +"bc5097__bc5097_4.jpg",FALSE,"bc5097","BC 5097","140-195",140,195,"16.0","M'/M.S.L.","16000","6""","153","","clear","sheet 4 of 4; camera RC8 355" +"bc5097__bc5097_4.jpg",FALSE,"bc5097","BC 5097","196",196,196,"6.6","M'/M.S.L.","6600","6""","153","","clear","strip 6 start (Pitt Lake)" +"bc5097__bc5097_4.jpg",FALSE,"bc5097","BC 5097","211",211,211,"6.3","M'/M.S.L.","6300","6""","153","","partial","strip 6 end row; digit after decimal read as 3, could be 6 or 8" +"bc5097__bc5097_4.jpg",FALSE,"bc5097","BC 5097","",,,"6.0","M'/M.S.L.","6000","6""","153","","clear","field 228/232 rows, no final number; marked DO NOT USE" +"bc5224__bc5224_1.jpg",FALSE,"bc5224","5224","1-15",1,15,"6550","M'/M.S.L.","6550","6""","153","","clear","Typed form; heights written as full figures (not thousands), read as feet; camera RC8; frames 26/28 do not use (off line)" +"bc5224__bc5224_1.jpg",FALSE,"bc5224","5224","16-61",16,61,"3550","M'/M.S.L.","3550","6""","153","","clear","Typed; strips E/D/F; full-figure height read as feet" +"bc5224__bc5224_1.jpg",FALSE,"bc5224","5224","62-93",62,93,"6250","M'/M.S.L.","6250","6""","153","","clear","Typed; strip 1A near Courtenay; full-figure height read as feet" +"bc5280__bc5280_1.jpg",FALSE,"bc5280","5280","1-90",1,90,"6.0","TRUE HEIGHT (M'/M.S.L.)","6000","6""","153","","clear","typed form, sheet 1 of 4; Chilliwack-Vedder OP 23/68; camera RC8 502" +"bc5280__bc5280_2.jpg",FALSE,"bc5280","5280","91-99",91,99,"3.0","TRUE HEIGHT (M'/M.S.L.)","3000","6""","153","","clear","sheet 2 of 4; BC Institute of Technology OP 31/68; field remark: LOW LEVEL RUN HAND FIRED" +"bc5280__bc5280_2.jpg",FALSE,"bc5280","5280","100-102",100,102,"1.5","TRUE HEIGHT (M'/M.S.L.)","1500","6""","153","","clear","sheet 2 of 4; same low level hand-fired run" +"bc5280__bc5280_3.jpg",FALSE,"bc5280","5280","103-156",103,156,"8.0","TRUE HEIGHT (M'/M.S.L.)","8000","6""","153","","clear","sheet 3 of 4; booming grounds OPs 48-54/68; remark: exposure counter may be out of sync with film record" +"bc5280__bc5280_4.jpg",FALSE,"bc5280","5280","157-266",157,266,"8.5","TRUE HEIGHT (M'/M.S.L.)","8500","6""","153","","clear","sheet 4 of 4; Harrison-Lillooet Lakes OP 57/68; remark: unable to hold constant interval or level due to heavy turbulence" +"bc5449__bc5449_1.jpg",FALSE,"bc5449","BC 5449","1-3",1,3,"2.0","M1/M.S.L.","2000","","","","clear","sheet 1 of 4; camera RC8-502, focal not written; LOW LEVEL remark; frames are pre-selected photo centres" +"bc5449__bc5449_1.jpg",FALSE,"bc5449","BC 5449","4-7",4,7,"8.0","M1/M.S.L.","8000","","","","partial","8.0 written on field-7 row struck out and rewritten on field-9 row; applies to finals 4-7 by dittos; test exposures (no finals) at 6.0" +"bc5449__bc5449_2.jpg",FALSE,"bc5449","BC 5449","8-46",8,46,"2.50","M1/M.S.L.","2500","","","","clear","sheet 2; camera RC8-502; LOW LEVEL remark (camera unable to give 80% on hillsides)" +"bc5449__bc5449_3.jpg",FALSE,"bc5449","BC 5449","72-87",72,87,"2.50","M1/M.S.L.","2500","","","","clear","sheet 3; camera RC8-502; field 1-10 and 27-33 rows have no final numbers; LOW LEVEL remark" +"bc5524__bc5524_1.jpg",FALSE,"bc5524","5524","1-16",1,16,"6.6","M'/M.S.L.","6600","RC8 502 (no focal written)","153","","clear","Focal not written, camera RC 8 502 (RC8 assumed 6""); 6.6 ditto'd to frame 16" +"bc5524__bc5524_1.jpg",FALSE,"bc5524","5524","17-37",17,37,"1,000","M'/M.S.L.","","RC8 502 (no focal written)","153","","clear","Written ""1,000"" - units ambiguous (not thousands convention; could be feet or metres); left blank; remarks: ""I think you should note the difference in height and exposure on this film""" +"bc5524__bc5524_1.jpg",FALSE,"bc5524","5524","38-55",38,55,"900","M'/M.S.L.","","RC8 502 (no focal written)","153","","clear","900 written on END row (field 56/final 55) of strip starting final 38; units ambiguous (feet or metres?); frames 56-57 later noted as vertical of route to hangar" +"bc5524__bc5524_3.jpg",FALSE,"bc5524","5524","59-60",59,60,"6.6","M'/M.S.L.","6600","RC8 502 (no focal written)","153","","clear","Preselected photo centres; frame 4 field blank" +"bc5524__bc5524_6.jpg",FALSE,"bc5524","5524","184",184,184,"6.6","M'/M.S.L.","6600","RC 8 502 (no focal written)","153","","clear","Single frame 184 (start and end both 184)" +"bc5524__bc5524_6.jpg",FALSE,"bc5524","5524","185-220",185,220,"600","M'/M.S.L.","","RC 8 502 (no focal written)","153","","partial","""600"" written; units ambiguous (feet or metres?); ""Low level of targets""; speed 1/300 over struck value; date written 28 MAR 72 (other sheets 73)" +"bc5536__bc5536_3.jpg",FALSE,"bc5536","5536","226-234",226,234,"2.4","TRUE HEIGHT (M'/M.S.L.)","2400","ZE 21162 (6"")","153","","clear","handwritten, sheet 3 of 3; Blanshard Street Ext OP 150" +"bc5593__bc5593_1.jpg",FALSE,"bc5593","BC 5593","1-22",1,22,"6.60","M1/M.S.L.","6600","RC8-502 (6"")","153","","clear","final end read as 22 (overwritten digit)" +"bc5593__bc5593_1.jpg",FALSE,"bc5593","BC 5593","23-34",23,34,"6.4","M1/M.S.L.","6400","RC8-502 (6"")","153","","clear","" +"bc5593__bc5593_1.jpg",FALSE,"bc5593","BC 5593","35-45",35,45,"6.2","M1/M.S.L.","6200","RC8-502 (6"")","153","","clear","" +"bc5593__bc5593_1.jpg",FALSE,"bc5593","BC 5593","46-57",46,57,"6.6","M1/M.S.L.","6600","RC8-502 (6"")","153","","clear","" +"bc5593__bc5593_2.jpg",FALSE,"bc5593","BC 5593","58-193",58,193,"11.70","M1/M.S.L.","11700","RC8 502 (6"")","153","","clear","" +"bc5593__bc5593_2.jpg",FALSE,"bc5593","BC 5593","194-233",194,233,"11.00","M1/M.S.L.","11000","RC8 502 (6"")","153","","clear","" +"bc5593__bc5593_2.jpg",FALSE,"bc5593","BC 5593","234-248",234,248,"10.40","M1/M.S.L.","10400","RC8 502 (6"")","153","","clear","" +"bc5593__bc5593_3.jpg",FALSE,"bc5593","BC 5593","249-284",249,284,"20.0","M1/M.S.L.","20000","RC8 502 (6"")","153","","partial","focal in camera line faint, read as (6"")" +"bc5593__bc5593_4.jpg",FALSE,"bc5593","BC 5593","285-292",285,292,"2.5","M1/M.S.L.","2500","RC8 502 (6"")","153","","partial","end final read as 292, uncertain; height written 2.5 (faint)" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","212-227",212,227,"8.0","M'/M.S.L.","8000","6""","153","","clear","Camera ZE 21162 (6)" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","228-243",228,243,"7.9","M'/M.S.L.","7900","6""","153","","clear","" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","244-259",244,259,"7.8","M'/M.S.L.","7800","6""","153","","clear","" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","260-271",260,271,"7.7","M'/M.S.L.","7700","6""","153","","partial","final 260 written as '260' / 271 unclear" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","272-283",272,283,"7.6","M'/M.S.L.","7600","6""","153","","partial","final end digit unclear (283 inferred from field 145)" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5596","5596","284-294",284,294,"7.5","M'/M.S.L.","7500","6""","153","","clear","" +"bc5597__bc5596_5597_3.jpg",FALSE,"bc5597","5597","1-33",1,33,"7.4","M'/M.S.L.","7400","6""","153","","clear","Centre of roll at field 157 = 5597 final 1; strips 1 (1-24) and A (25-33) ditto 7.4; remark step wedge at end of 5597" +"bc5597__bc5596_5597_4.jpg",FALSE,"bc5597","5597","34-118",34,118,"6.3","M'/M.S.L.","6300","6""","153","","clear","Strips 1-7 all ditto 6.3" +"bc5597__bc5596_5597_5.jpg",FALSE,"bc5597","5597","119-223",119,223,"7.5","M'/M.S.L.","7500","6""","153","","clear","Strips 1-9 ditto 7.5" +"bc5597__bc5596_5597_5.jpg",FALSE,"bc5597","5597","224-241",224,241,"2550","M'/M.S.L.","2550","6""","153","","clear","Written ""2550"" in full (Falls area strip); read as feet" +"bc5597__bc5596_5597_6.jpg",FALSE,"bc5597","5597","242-248",242,248,"10.5","M'/M.S.L.","10500","6""","153","","clear","Blue River OP93/74" +"bc5597__bc5596_5597_6.jpg",FALSE,"bc5597","5597","249-257",249,257,"9.5","M'/M.S.L.","9500","6""","153","","clear","Clearwater OP94/74" +"bc5597__bc5596_5597_6.jpg",FALSE,"bc5597","5597","258-264",258,264,"9.2","M'/M.S.L.","9200","6""","153","","clear","Ashcroft OP88/74" +"bc5597__bc5596_5597_6.jpg",FALSE,"bc5597","5597","265-270",265,270,"9.8","M'/M.S.L.","9800","6""","153","","clear","Cache Cr. OP89/74" +"bc5598__bc5598_1.jpg",FALSE,"bc5598","5598","1-25",1,25,"8.000","TRUE HEIGHT (M'/M.S.L.)","8000","RC8-502 (6"")","153","","clear","sheet 1 of 5; written as 8.000 (three decimals); interpreted as 8.0 thousand ft; 80% forward overlap" +"bc5598__bc5598_2.jpg",FALSE,"bc5598","5598","26-30",26,30,"11.0","TRUE HEIGHT (M'/M.S.L.)","11000","RC8-502 (6"")","153","","clear","sheet 2 of 5; OP 48/74" +"bc5598__bc5598_3.jpg",FALSE,"bc5598","5598","31-100",31,100,"7.4","TRUE HEIGHT (M'/M.S.L.)","7400","RC8 502 (6"")","153","","clear","sheet 3 of 5; 7.4 restated at final 46; several OPs (127-130,124)" +"bc5598__bc5598_3.jpg",FALSE,"bc5598","5598","101-109",101,109,"4.5","TRUE HEIGHT (M'/M.S.L.)","4500","RC8 502 (6"")","153","","clear","sheet 3 of 5; OP 126" +"bc5598__bc5598_4.jpg",FALSE,"bc5598","5598","110-196",110,196,"7.5","TRUE HEIGHT (M'/M.S.L.)","7500","RC8 502 (6"")","153","","partial","sheet 4 of 5; second digit could be 5 or 3 (7.5 most likely); finals 154-251 said to be on sheet 5" +"bc5598__bc5598_4.jpg",FALSE,"bc5598","5598","238-284",238,284,"6.8","TRUE HEIGHT (M'/M.S.L.)","6800","RC8 502 (6"")","153","","clear","sheet 4 of 5; Salmon River OP 243; end of roll at 284" +"bc5598__bc5598_5.jpg",FALSE,"bc5598","5598","147-152",147,152,"8.6","TRUE HEIGHT (M'/M.S.L.)","8600","RC8 502 (6"")","153","","partial","sheet 5 of 5; Pitin Lake OP 158; first digit after decimal read as 6" +"bc5598__bc5598_5.jpg",FALSE,"bc5598","5598","153-181",153,181,"8.5","TRUE HEIGHT (M'/M.S.L.)","8500","RC8 502 (6"")","153","","clear","sheet 5 of 5; Brenda Lake OP 113" +"bc5598__bc5598_5.jpg",FALSE,"bc5598","5598","182-212",182,212,"7.7","TRUE HEIGHT (M'/M.S.L.)","7700","RC8 502 (6"")","153","","partial","sheet 5 of 5; refly strip; digit read as 7.7, could be 7.2" +"bc5598__bc5598_5.jpg",FALSE,"bc5598","5598","213-237",213,237,"7.5","TRUE HEIGHT (M'/M.S.L.)","7500","RC8 502 (6"")","153","","clear","sheet 5 of 5; OP 124" +"bc5598__bc5598_5.jpg",FALSE,"bc5598","5598","",,,"7.5","TRUE HEIGHT (M'/M.S.L.)","7500","RC8 502 (6"")","153","","clear","sheet 5 of 5; field 189-195 Scotty Creek marked DO NOT USE, no final numbers" +"bc5628__bc5628_1.jpg",FALSE,"bc5628","BC 5628","1-35",1,35,"45.00","M1/M.S.L.","45000","","","","partial","written 4500 with decimal unclear/overwritten, read as 45.00; camera R.C.8 355, focal not written; aircraft Lear Jet" +"bc5628__bc5628_1.jpg",FALSE,"bc5628","BC 5628","36-73",36,73,"27.5","M1/M.S.L.","27500","","","","clear","camera R.C.8 355; focal not written" +"bc5651__bc5651_1.jpg",FALSE,"bc5651","5651","1-11",1,11,"6350","M'/M.S.L.","6350","6""","153","","clear","Written in full 6350; read as feet" +"bc5651__bc5651_2.jpg",FALSE,"bc5651","5651","12-40",12,40,"16.5","M'/M.S.L.","16500","6""","153","","clear","" +"bc5651__bc5651_3.jpg",FALSE,"bc5651","5651","41-81",41,81,"23.0","M'/M.S.L.","23000","6""","153","","clear","" +"bc5651__bc5651_4.jpg",FALSE,"bc5651","5651","82-93",82,93,"9.5","M'/M.S.L.","9500","6""","153","","clear","Remark: pictures 13-20 (field) do not exist - strip 2 to be reflown" +"bc5655__bc5655_1.jpg",FALSE,"bc5655","5655","1-3",1,3,"3.7","TRUE HEIGHT (M'/M.S.L.)","3700","RC8 502 (6"")","153","","clear","sheet 1 of 3; three selected photo centres OP 282/75" +"bc5655__bc5655_1.jpg",FALSE,"bc5655","5655","4-36",4,36,"3.8","TRUE HEIGHT (M'/M.S.L.)","3800","RC8 502 (6"")","153","","clear","sheet 1 of 3; 3.8 restated at finals 12 and 28; OPs 274,273,266/75" +"bc5655__bc5655_1.jpg",FALSE,"bc5655","5655","37-48",37,48,"3.6","TRUE HEIGHT (M'/M.S.L.)","3600","RC8 502 (6"")","153","","clear","sheet 1 of 3; OPs 267,268/75" +"bc5655__bc5655_2.jpg",FALSE,"bc5655","5655","49-63",49,63,"6.0","TRUE HEIGHT (M'/M.S.L.)","6000","RC8 502 '6""'","153","","clear","sheet 2 of 3; Highway 99 OP 62/75; field 100-109 (no finals) ditto 6.0 but noted DO NOT USE flown at wrong height" +"bc5655__bc5655_2.jpg",FALSE,"bc5655","5655","64-69",64,69,"5.2","TRUE HEIGHT (M'/M.S.L.)","5200","RC8 502 '6""'","153","","partial","sheet 2 of 3; 5.2 written at field 110-114 (DO NOT USE, no finals); finals 64-69 carry ditto from that row" +"bc5655__bc5655_2.jpg",FALSE,"bc5655","5655","70-95",70,95,"5.0","TRUE HEIGHT (M'/M.S.L.)","5000","RC8 502 '6""'","153","","clear","sheet 2 of 3" +"bc5655__bc5655_2.jpg",FALSE,"bc5655","5655","96-106",96,106,"5.5","TRUE HEIGHT (M'/M.S.L.)","5500","RC8 502 '6""'","153","","clear","sheet 2 of 3; refly strip 3" +"bc5655__bc5655_3.jpg",FALSE,"bc5655","5655","107-209",107,209,"5.0","TRUE HEIGHT (M'/M.S.L.)","5000","RC8 502 6""","153","","clear","sheet 3 of 3; Okanagan Flood Plain OP 127/75; one strip (field 196-205) DO NOT USE" +"bc5697__bc5697_1.jpg",FALSE,"bc5697","BC 5697","1-52",1,52,"2.5","M1/M.S.L.","2500","RC8-502 (6"")","153","","partial","2.5 written above a struck-out value; first final overwritten" +"bc5697__bc5697_1.jpg",FALSE,"bc5697","BC 5697","53-154",53,154,"1.5","M1/M.S.L.","1500","RC8-502 (6"")","153","","partial","written 1.5 with trailing mark/strike (1.5x?)" +"bc5697__bc5697_1.jpg",FALSE,"bc5697","BC 5697","155-189",155,189,"5.0","M1/M.S.L.","5000","RC8-502 (6"")","153","","clear","" +"bc5697__bc5697_1.jpg",FALSE,"bc5697","BC 5697","190-219",190,219,"5.0","M1/M.S.L.","5000","RC8-502 (6"")","153","","clear","5.0 restated at field 196" +"bc7211__bc7211_1.jpg",FALSE,"bc7211","7211","1-75",1,75,"11.5","M'/M.S.L.","11500","12""","305","","clear","Typed form; 11.5 on every row" +"bc7211__bc7211_2.jpg",FALSE,"bc7211","7211","76-93",76,93,"16000","M'/M.S.L.","16000","12""","305","","clear","Typed; 16000 on first row only; following rows blank" +"bc7280__bc7280_7281_1.jpg",FALSE,"bc7280","7280","1-136",1,136,"20.0","TRUE HEIGHT (M'/M.S.L.)","20000","Zeiss #2/3 110398","","","clear","sheet 1 of 2; focal length not written (camera serial 110398 only); Eagle PSYU forest inventory OP 8/70" +"bc7280__bc7280_7281_2.jpg",FALSE,"bc7280","7280","137-288",137,288,"20.00","TRUE HEIGHT (M'/M.S.L.)","20000","ZE 2/6 110398","","","clear","sheet 2 of 2; focal not written; Longworth PSYU OP 6/70; centre of roll at 288" +"bc7280__bc7280_7281_2.jpg",FALSE,"bc7281","7281","1-229",1,229,"20.00","TRUE HEIGHT (M'/M.S.L.)","20000","ZE 2/6 110398","","","clear","continuation onto roll 7281 by ditto; test strip field 561-568 also 20.0 (no finals)" +"bc7584__bc7583_7584_1.jpg",FALSE,"bc7583","7583","1-285",1,285,"20.0","M'/M.S.L.","20000","12""","305","","clear","Camera ZE 110 398 (12); 20.0 ditto'd" +"bc7584__bc7583_7584_1.jpg",FALSE,"bc7584","7584","1-288",1,288,"20.0","M'/M.S.L.","20000","12""","305","","clear","Continuation after centre of roll; ditto marks" +"bc7675__bc7674_7675_1.jpg",FALSE,"bc7674","7674","1-268",1,268,"20.0","TRUE HEIGHT (M'/M.S.L.)","20000","ZE 122520","","","clear","page covers 7674/7675; focal not written; low level map, Okanagan PSYU OP 15" +"bc7675__bc7674_7675_1.jpg",FALSE,"bc7675","7675","1-118",1,118,"20.0","TRUE HEIGHT (M'/M.S.L.)","20000","ZE 122520","","","clear","focal not written; low level map continues after centre of roll" +"bc7675__bc7674_7675_1.jpg",FALSE,"bc7675","7675","113-248",113,248,"20.2","TRUE HEIGHT (M'/M.S.L.)","20200","ZE 122520","","","partial","Okanagan Kamloops HIGH LEVEL MAP; first final written looks like 113 (possibly 119); strip 62 partly do not use; remark says low-level/high-level instruments" +"bc7692__bc7691_7692_10.jpg",FALSE,"bc7692","BC 7692","231-279",231,279,"14.6","M1/M.S.L.","14600","ZE 122520 (12"")","305","","clear","" +"bc7692__bc7691_7692_11.jpg",FALSE,"bc7692","BC 7692","280-300",280,300,"7.60","M1/M.S.L.","7600","ZE 122520 (12"")","305","","clear","" +"bc7692__bc7691_7692_3.jpg",FALSE,"bc7692","BC 7692","1-80",1,80,"7.3","M1/M.S.L.","7300","ZE 122520 12""","305","","clear","sheet 3 of 11; ticks used as dittos" +"bc7692__bc7691_7692_4.jpg",FALSE,"bc7692","BC 7692","81-90",81,90,"7.4","M1/M.S.L.","7400","","","","clear","sheet 4; camera ZE 122520, focal not written; remark: Kelowna flown 100 ft low because of smoke" +"bc7692__bc7691_7692_4.jpg",FALSE,"bc7692","BC 7692","91-98",91,98,"7.1","M1/M.S.L.","7100","","","","clear","Penticton airport" +"bc7692__bc7691_7692_5.jpg",FALSE,"bc7692","BC 7692","99-129",99,129,"14.8","M1/M.S.L.","14800","ZE 122520 ""12""","305","","partial","14.8 written above a struck-out value (possibly 20.0)" +"bc7692__bc7691_7692_6.jpg",FALSE,"bc7692","BC 7692","130-193",130,193,"18.5","M1/M.S.L.","18500","ZE 122520 (12"")","305","","clear","end final read as 193" +"bc7692__bc7691_7692_7.jpg",FALSE,"bc7692","BC 7692","194-221",194,221,"21.0","M1/M.S.L.","21000","ZE 122520 (12"")","305","","clear","" +"bc7692__bc7691_7692_8.jpg",FALSE,"bc7692","BC 7692","222-227",222,227,"12.0","M1/M.S.L.","12000","ZE 122520 (12"")","305","","clear","pre-selected photo centres" +"bc77014__bc77014_1.jpg",FALSE,"bc77014","77014","1-7",1,7,"23.0","M'/M.S.L.","23000","ZE 124223 (6""), f 153","153","","clear","" +"bc77014__bc77014_3.jpg",FALSE,"bc77014","77014","43-79",43,79,"7.6","M'/M.S.L.","7600","(6""), f 153","153","","clear","" +"bc77014__bc77014_4.jpg",FALSE,"bc77014","77014","80-86",80,86,"7.1","M'/M.S.L.","7100","(6""), f 153","153","","clear","King Edward Lake OP41/77" +"bc77014__bc77014_4.jpg",FALSE,"bc77014","77014","87-115",87,115,"7.3","M'/M.S.L.","7300","(6""), f 153","153","","clear","Nicklen Lake OP42/77" +"bc77014__bc77014_5.jpg",FALSE,"bc77014","77014","116-125",116,125,"27.5","M'/M.S.L.","27500","(6""), f 153","153","","clear","First row 23.0 struck through and 27.5 written below (do-not-use off strip, no final); restart 27.5" +"bc77014__bc77014_5.jpg",FALSE,"bc77014","77014","126-155",126,155,"28.0","M'/M.S.L.","28000","(6""), f 153","153","","clear","" +"bc77014__bc77014_6.jpg",FALSE,"bc77014","77014","156-195",156,195,"12.0","M'/M.S.L.","12000","(6""), f 153","153","","clear","" +"bc7703__bc7703_1.jpg",FALSE,"bc7703","7703","1-49",1,49,"21.3","TRUE HEIGHT (M'/M.S.L.)","21300","Zeiss #2 110398 12""","305","","clear","sheet 1 of 1; Kamloops orthophoto OP 126/75" +"bc7703__bc7703_1.jpg",FALSE,"bc7703","7703","50-59",50,59,"5.12","TRUE HEIGHT (M'/M.S.L.)","5120","Zeiss #2 110398 12""","305","","partial","written 5.12 (two decimals); line 4 low section; remark: nav sight fogged on low level section (line 4)" +"bc77032__bc77032_1.jpg",FALSE,"bc77032","BC 77-32","1-129",1,129,"24.0","M1/M.S.L.","24000","f 610 mm (24"")","610","","clear","camera 125998" +"bc77032__bc77032_1.jpg",FALSE,"bc77032","BC 77-32","130-183",130,183,"23.5","M1/M.S.L.","23500","f 610 mm (24"")","610","","clear","" +"bc77032__bc77032_1.jpg",FALSE,"bc77032","BC 77-32","184-287",184,287,"23.0","M1/M.S.L.","23000","f 610 mm (24"")","610","","clear","" +"bc7717__bc7717_7718_2.jpg",FALSE,"bc7717","7717","99-182",99,182,"5.50","M'/M.S.L.","5500","ZE 110399 (12"")","305","","clear","" +"bc7717__bc7717_7718_2.jpg",FALSE,"bc7717","7717","183-225",183,225,"6.00","M'/M.S.L.","6000","ZE 110399 (12"")","305","","clear","" +"bc7717__bc7717_7718_3.jpg",FALSE,"bc7717","7717","1-98",1,98,"5.40","M'/M.S.L.","5400","ZE 110399 (12"")","305","","clear","Port Alberni OP129" +"bc78104__bc78104_1.jpg",FALSE,"bc78104","BC 78-104","1-42",1,42,"25.0","M1/M.S.L.","25000","f 153 mm","153","","clear","camera 21162; sheet 1 of 4" +"bc78104__bc78104_2.jpg",FALSE,"bc78104","BC 78-104","43-76",43,76,"27.5","M1/M.S.L.","27500","f 153 mm","153","","clear","sheet 2" +"bc78104__bc78104_3.jpg",FALSE,"bc78104","BC 78-104","77-201",77,201,"8.0","M1/M.S.L.","8000","f 153 mm","153","","clear","sheet 3; camera 21163; first final overwritten, read as 77" +"bc78105__bc78105_1.jpg",FALSE,"bc78105","78105","1-17",1,17,"4.25","M'/M.S.L.","4250","f 153","153","","clear","""1.4 A.U. only""" +"bc78105__bc78105_1.jpg",FALSE,"bc78105","78105","18-43",18,43,"13.0","M'/M.S.L.","13000","f 153","153","","clear","" +"bc78105__bc78105_2.jpg",FALSE,"bc78105","78105","44-187",44,187,"14.0","M'/M.S.L.","14000","f 153","153","","clear","Last final '187' overwritten (187 or 188); two do-not-use off strips" +"bc78105__bc78105_3.jpg",FALSE,"bc78105","78105","188-192",188,192,"27.0","M'/M.S.L.","27000","f 153","153","remark: ""not requested at 1:40,000""","partial","Final digits overwritten (188/192 uncertain); remark ""This strip not requested at 1:40,000 but flown to complete coverage""" +"bc78151__bc78151_1.jpg",FALSE,"bc78151","78151","1-10",1,10,"20.0","True Height (M'/M.S.L.)","20000","f 153 mm","153","","clear","sheet 1 of 5; Whistler-Blackcomb refly OP 322/78; camera 21162" +"bc78151__bc78151_1.jpg",FALSE,"bc78151","78151","11-17",11,17,"17.0","True Height (M'/M.S.L.)","17000","f 153 mm","153","","clear","sheet 1 of 5" +"bc78151__bc78151_2.jpg",FALSE,"bc78151","78151","18-115",18,115,"7.0","True Height (M'/M.S.L.)","7000","f 153 mm","153","","clear","sheet 2 of 5; OP 98/78; camera 124223" +"bc78151__bc78151_3.jpg",FALSE,"bc78151","78151","116-153",116,153,"5.0","True Height (M'/M.S.L.)","5000","f 153 mm","153","","clear","sheet 3 of 5; Williams Lake Arterial OP 384/78; one strip DO NOT USE" +"bc78151__bc78151_4.jpg",FALSE,"bc78151","78151","154-164",154,164,"7.7","True Height (M'/M.S.L.)","7700","f 153 mm","153","","partial","sheet 4 of 5; Canal Flats-Radium OP 385/78; second digit could be 7 or 2" +"bc78151__bc78151_5.jpg",FALSE,"bc78151","78151","165-199",165,199,"4.25","True Height (M'/M.S.L.)","4250","f 153 mm","153","","clear","sheet 5 of 5; Kaleden Reservoir OP 379/78" +"bc7835__bc7835_1.jpg",FALSE,"bc7835","BC 7835","1-101",1,101,"7.2","M1/M.S.L.","7200","ZE 122520 (#4) 12""","305","","clear","a struck-out first row reads 20.5 (photography cancelled)" +"bc7835__bc7835_2.jpg",FALSE,"bc7835","BC 7835","102-123",102,123,"20.65","M1/M.S.L.","20650","ZE 122520 (12"")","305","","clear","" +"bc7835__bc7835_2.jpg",FALSE,"bc7835","BC 7835","124-130",124,130,"20.1","M1/M.S.L.","20100","ZE 122520 (12"")","305","","partial","written 20.1 followed by an unclear character (20.1H?)" +"bc7835__bc7835_2.jpg",FALSE,"bc7835","BC 7835","131-171",131,171,"20.25","M1/M.S.L.","20250","ZE 122520 (12"")","305","","clear","" +"bc7835__bc7835_3.jpg",FALSE,"bc7835","BC 7835","172-204",172,204,"20.1","M1/M.S.L.","20100","ZE 122520 (12"")","305","","clear","20.1 restated at final 193" +"bc7835__bc7835_3.jpg",FALSE,"bc7835","BC 7835","205-219",205,219,"20.5","M1/M.S.L.","20500","ZE 122520 (12"")","305","","clear","" +"bc7835__bc7835_3.jpg",FALSE,"bc7835","BC 7835","220-274",220,274,"20.1","M1/M.S.L.","20100","ZE 122520 (12"")","305","","partial","20.1 restated at 269; last final read as 274, uncertain" +"bc7835__bc7835_4.jpg",FALSE,"bc7835","BC 7835","275-303",275,303,"21.0","M1/M.S.L.","21000","ZE 122520 (12"")","305","","partial","first final written over another number, read as 275; remark: camera not level for strip 3, still climbing to altitude" +"bc79067__bc79067_1.jpg",FALSE,"bc79067","79067","1-47",1,47,"15.650","M'/M.S.L.","15650","f 85","","","partial","Focal written ""85"" mm (camera 128316) - digit legible but value unusual; frame finals 26/27 partly overwritten" +"bc79067__bc79067_1.jpg",FALSE,"bc79067","79067","48-57",48,57,"18.450","M'/M.S.L.","18450","f 85","","","partial","End final (57/54) partly overwritten" +"bc79067__bc79067_1.jpg",FALSE,"bc79067","79067","58-65",58,65,"16,625","M'/M.S.L.","16625","f 85","","","clear","A first 16,625 row is struck through and rewritten (on at mile post 470)" +"bc79067__bc79067_1.jpg",FALSE,"bc79067","79067","66-126",66,126,"16,300","M'/M.S.L.","16300","f 85","","","partial","Finals partly illegible (66 and 126 inferred from field numbers 317/377; last final looks like 120)" +"bc79067__bc79067_2.jpg",FALSE,"bc79067","79067","122-213",122,213,"24.0","M'/M.S.L.","24000","f 305","305","","clear","24.0 written on first two rows then ditto" +"bc79067__bc79067_3.jpg",FALSE,"bc79067","79067","214-283",214,283,"27.6","M'/M.S.L.","27600","f 305","305","","clear","" +"bc79086__bc79086_1.jpg",FALSE,"bc79086","79086","1-107",1,107,"20.75","True Height (M'/M.S.L.)","20750","f 85 mm","85","","clear","sheet 1 of 2; camera ZE 128316; remark: remainder of roll exposed on 305 mm camera" +"bc79086__bc79086_2.jpg",FALSE,"bc79086","79086","108-251",108,251,"24.50","True Height (M'/M.S.L.)","24500","f 305 mm","305","","clear","sheet 2 of 2; camera ZE 110398; remark: first 110 exposures of this roll were on the 85 mm camera" +"bc79122__bc79122_1.jpg",FALSE,"bc79122","BC 79-122","1-274",1,274,"21.0","M1/M.S.L.","21000","f 305 mm","305","","clear","camera 122520; 21.0 restated at field 133 block" +"bc79209__bc79209_1.jpg",FALSE,"bc79209","79209","1-85",1,85,"25.0","M'/M.S.L.","25000","f 153","153","","clear","Field roll OSCAR 93, sheet 1 of 15 (other 79209 sheets are field OSCAR 81); camera ZE 21162" +"bc79209__bc79209_2.jpg",FALSE,"bc79209","79209","1-51",1,51,"21,340","M'/M.S.L.","21340","f 305","305","","clear","Field OSCAR 81, sheet 2; final numbering restarts at 1; frames 49-51 fogged" +"bc79209__bc79209_3.jpg",FALSE,"bc79209","79209","137-172",137,172,"14.0","M'/M.S.L.","14000","3058","","","partial","Focal written ""3058"" (possibly 305.8 or 305 with stray mark); camera 110399" +"bc79209__bc79209_4.jpg",FALSE,"bc79209","79209","173-186",173,186,"13.00","M'/M.S.L.","13000","f 305","305","","clear","Charles Creek refly" +"bc79209__bc79209_5.jpg",FALSE,"bc79209","79209","187-219",187,219,"10.50","M'/M.S.L.","10500","f 305","305","","clear","Gilford Island refly; strip 4 coverage inadequate due to fog" +"bc80001__bc80001_1.jpg",FALSE,"bc80001","80001","1-53",1,53,"24.00","True Height (M'/M.S.L.)","24000","f 305 mm","305","","clear","sheet 1 of 3; Scuzzy Creek OP 96/80; camera ZE 110398; field numbers poor" +"bc80001__bc80001_1.jpg",FALSE,"bc80001","80001","54-68",54,68,"24.50","True Height (M'/M.S.L.)","24500","f 305 mm","305","","clear","sheet 1 of 3; Log Creek OP 95/80" +"bc80001__bc80001_1.jpg",FALSE,"bc80001","80001","69-85",69,85,"25.00","True Height (M'/M.S.L.)","25000","f 305 mm","305","","clear","sheet 1 of 3; Mowhokam Creek OP 94/80" +"bc80001__bc80001_1.jpg",FALSE,"bc80001","80001","",,,"22.00","True Height (M'/M.S.L.)","22000","f 305 mm","305","","clear","sheet 1 of 3; strip marked DO NOT USE (too much cloud), no finals" +"bc80001__bc80001_2.jpg",FALSE,"bc80001","80001","86-136",86,136,"24.0","True Height (M'/M.S.L.)","24000","f 305 mm","305","","clear","sheet 2 of 3; Scuzzy Crk & pilot training OP 96/80; remark: 2 different focal length cameras on this roll" +"bc80001__bc80001_3.jpg",FALSE,"bc80001","80001","137-242",137,242,"5.00","True Height (M'/M.S.L.)","5000","f 153 mm","153","","clear","sheet 3 of 3; Qualicum-Parksville OP 206/80; camera ZE 124223; remark: sheet 1 was exposed on 305 mm camera" +"bc81013__bc81013_1.jpg",FALSE,"bc81013","BC 81-013","1-3",1,3,"8.2","M1/M.S.L.","8200","f 153 mm","153","","clear","sheet 1 of 5; pre-selected photo centres" +"bc81013__bc81013_2.jpg",FALSE,"bc81013","BC 81-013","4-62",4,62,"27.0","M1/M.S.L.","27000","f 153 mm","153","","clear","sheet 2 of 5" +"bc81013__bc81013_3.jpg",FALSE,"bc81013","BC 81-013","63-65",63,65,"6.5","M1/M.S.L.","6500","f 153 mm","153","","clear","sheet 3 of 5; an earlier DO NOT USE pair (no finals) also at 6.5" +"bc81013__bc81013_4.jpg",FALSE,"bc81013","BC 81-013","66-247",66,247,"25.0","M1/M.S.L.","25000","f 153 mm","153","","clear","sheet 4 of 5; sheet 5 not in batch" +"bc81026__bc81026_2.jpg",FALSE,"bc81026","81026","124-157",124,157,"8.4","M'/M.S.L.","8400","f 153","153","","clear","" +"bc81026__bc81026_3.jpg",FALSE,"bc81026","81026","159-182",159,182,"2.6","M'/M.S.L.","2600","f 153","153","","clear","First final 158 or 159 (overwritten)" +"bc81026__bc81026_4.jpg",FALSE,"bc81026","81026","183-211",183,211,"1.2","M'/M.S.L.","1200","f 153","153","","clear","" +"bc81026__bc81026_5.jpg",FALSE,"bc81026","81026","212-225",212,225,"2.5","M'/M.S.L.","2500","f 153","153","","clear","" +"bc81027__bc81027_1.jpg",FALSE,"bc81027","81027","1-19",1,19,"24.0","True Height (M'/M.S.L.)","24000","f 153 mm","153","","clear","sheet 1 of 5; Middle Kettle OP 28/81; camera ZE 124223" +"bc81027__bc81027_2.jpg",FALSE,"bc81027","81027","20-86",20,86,"25.0","True Height (M'/M.S.L.)","25000","f 153 mm","153","","clear","sheet 2 of 5; Upper Kettle OP 27/81" +"bc81027__bc81027_3.jpg",FALSE,"bc81027","81027","87-137",87,137,"4.10","True Height (M'/M.S.L.)","4100","f 153 mm","153","","clear","sheet 3 of 5; Castlegar-Kinnaird OP 281/81" +"bc81027__bc81027_4.jpg",FALSE,"bc81027","81027","138-164",138,164,"6.50","True Height (M'/M.S.L.)","6500","f 153 mm","153","","clear","sheet 4 of 5; Needles-Bench Creek OP 279/81" +"bc81027__bc81027_5.jpg",FALSE,"bc81027","81027","165-204",165,204,"12.0","True Height (M'/M.S.L.)","12000","f 153 mm","153","","partial","sheet 5 of 5; Eagle River OP 174/81; first entry overwritten (looks like 12.0 over 20.00 or similar), 12.0 written again at final 182; light leak fogged 210-218" +"bc81027__bc81027_5.jpg",FALSE,"bc81027","81027","",,,"6.75","True Height (M'/M.S.L.)","6750","f 153 mm","153","","clear","sheet 5 of 5; field 174-178, camera u/s, no finals (cleared)" +"bc85036__bc85036_1.jpg",FALSE,"bc85036","BC 85036","1-175",1,175,"22500","M1/M.S.L.","22500","f 305 mm","305","","clear","different form (1985 contractor form); height written as full feet 22500" +"bc85036__bc85036_2.jpg",FALSE,"bc85036","BC 85036","176-254",176,254,"22500","M1/M.S.L.","22500","f 305 mm","305","","clear","height written as full feet 22500" +"bc87070__bc87070_1.jpg",FALSE,"bc87070","87070","1-53",1,53,"14.5","M'/M.S.L.","14500","cfl 152.919 mm","152.919","","partial","Written ""145"" / ""14.5"" (decimal faint); remark ""Job flown 500' high due A.T.C. restriction (minimum en route altitude)""" +"bc87070__bc87070_2.jpg",FALSE,"bc87070","87070","54-168",54,168,"13.5","M'/M.S.L.","13500","cfl 152.919 mm","152.919","","clear","" +"bc87070__bc87070_2.jpg",FALSE,"bc87070","87070","169-20?",,,"13.0","M'/M.S.L.","13000","cfl 152.919 mm","152.919","","partial","Strip 16 169-202 and strip 15 from 203 to END (last final digit illegible, ~206); camera failed to fire on strip 15" +"bc88054__bc88054_1.jpg",FALSE,"bc88054","88054","1-218",1,218,"21500","True Height (M'/M.S.L.)","21500","cfl 304.497 mm","304.497","(1:15,000) in project line","clear","sheet 1 of 1; Lardeau South; height written in full (no decimal); restated per strip" +"bc88054__bc88054_1.jpg",FALSE,"bc88054","88054","219-267",219,267,"22500","True Height (M'/M.S.L.)","22500","cfl 304.497 mm","304.497","(1:15,000) in project line","clear","sheet 1 of 1; strips 24-25" +"bcc162__bcc162_1.jpg",FALSE,"bcc162","BC C162","1-110",1,110,"4.1","M1/M.S.L.","4100","f 305 mm","305","","clear","camera 110398; Aerocolor neg; 65% forward overlap" +"bcc162__bcc162_2.jpg",FALSE,"bcc162","BC C162","111-196",111,196,"8.0","M1/M.S.L.","8000","f 153 mm","153","","clear","sheet 2 uses a different camera (21162, f 153) from sheet 1 (f 305)" +"bcc401__bcc401_1.jpg",FALSE,"bcc401","c401","1-13",1,13,"13500","True Height (M'/M.S.L.)","13500","f 304 mm","304","","clear","sheet 1 of 3; colour neg; Tatalayoko Lake; Wild RC10" +"bcc401__bcc401_1.jpg",FALSE,"bcc401","c401","14-45",14,45,"","True Height (M'/M.S.L.)","","f 304 mm","304","","clear","sheet 1 of 3; height column blank on strip 2 start row" +"bcc401__bcc401_1.jpg",FALSE,"bcc401","c401","",,,"14,500","True Height (M'/M.S.L.)","14500","f 304 mm","304","","clear","sheet 1 of 3; field 12-25 SCRUB off line, no finals" +"bcc401__bcc401_2.jpg",FALSE,"bcc401","c401","46-67",46,67,"14000","True Height (M'/M.S.L.)","14000","f 304 mm","304","","clear","sheet 2 of 3; Valleau Creek; written 14000 (possibly 14,000 with comma), ditto for 56-67" +"bcc401__bcc401_3.jpg",FALSE,"bcc401","c401","68-149",68,149,"14,000","True Height (M'/M.S.L.)","14000","f 304 mm","304","","clear","sheet 3 of 3; Narcosli Creek" +"bcc401__bcc401_3.jpg",FALSE,"bcc401","c401","150-161",150,161,"13,000","True Height (M'/M.S.L.)","13000","f 304 mm","304","","clear","sheet 3 of 3" +"bcc822__bcc822_1.jpg",FALSE,"bcc822","BC C822","1-65",1,65,"22500","M1/M.S.L.","22500","cfl 304.497 mm","304.497","1:15,000 (project line)","clear","1988 form; height in full feet" +"bcc822__bcc822_1.jpg",FALSE,"bcc822","BC C822","66-119",66,119,"23000","M1/M.S.L.","23000","cfl 304.497 mm","304.497","1:15,000 (project line)","clear","" +"bcc822__bcc822_1.jpg",FALSE,"bcc822","BC C822","120-130",120,130,"2300","M1/M.S.L.","","cfl 304.497 mm","304.497","1:15,000 (project line)","clear","written 2300 (four digits) where neighbours are five-digit full feet; possibly a dropped zero for 23000, not interpreted" +"bcc822__bcc822_1.jpg",FALSE,"bcc822","BC C822","131-191",131,191,"22500","M1/M.S.L.","22500","cfl 304.497 mm","304.497","1:15,000 (project line)","clear","22500 restated at final 175" +"bcc94147__bcc94147_1.jpg",FALSE,"bcc94147","bcc94147","1-37",1,37,"14,000","TRUE HEIGHT (no unit shown)","14000","CFL 304.032mm","304.032","SCALE: 1:10,000 (title)","clear","Different typed form (1994, North West Geomatics); strips 1 (1-19) and 2 (20-37); units not stated, read as feet" +"bcc94147__bcc94147_1.jpg",FALSE,"bcc94147","bcc94147","38-92",38,92,"13,000","TRUE HEIGHT (no unit shown)","13000","CFL 304.032mm","304.032","SCALE: 1:10,000 (title)","clear","Strips 3-5; field 9826 not required (reject)" +"bcc94147__bcc94147_2.jpg",FALSE,"bcc94147","bcc94147","93",93,93,"19,000","TRUE HEIGHT (no unit shown)","19000","CFL 304.032mm","304.032","SCALE: 1:52,000 (title)","clear","Horse Thief Creek Fire #2 spot shot; single frame" +"CONTROL_bc7350__bc7349_7350_1.jpg",TRUE,"bc7349","BC 7349","1-217",1,217,"20.0","M1/M.S.L.","20000","","","","clear","page is roll BC 7349 (sheet 1 of 2), not 7350; camera Zeiss 2/4, focal not written" +"CONTROL_bc78065__bc78065_1.jpg",TRUE,"bc78065","78065","1-37",1,37,"24.0","True Height (M'/M.S.L.)","24000","f 153 mm","153","","partial","sheet 1 of 3; Ramsay Creeks OP 136/78; camera 124223; final numbers heavily overwritten in red (read 1-37); remark: complete 6"" work" +"CONTROL_bc78065__bc78065_2.jpg",TRUE,"bc78065","78065","38-61",38,61,"1.35","True Height (M'/M.S.L.)","1350","f 153 mm","153","","partial","sheet 2 of 3; New Westminster OP 332/78; finals overwritten, read as about 38-50 and 51-61 (uncertain)" +"CONTROL_bc78065__bc78065_3.jpg",TRUE,"bc78065","78065","62-270",62,270,"13.0","True Height (M'/M.S.L.)","13000","f 153 mm","153","","partial","sheet 3 of 3; Princeton Area Rivers OP 212/78; finals overwritten in red, read as 62...270 (uncertain); 80% fwd overlap" +"CONTROL_bcc228__bcc228_1.jpg",TRUE,"bcc228","bcc228","1-166",1,166,"12.650","M'/M.S.L.","12650","f 305","305","","clear","Remark: ""Too much turbulence for this job at 7,650' with 6"" cone""" +"CONTROL_bcc228__bcc228_2.jpg",TRUE,"bcc228","bcc228","177-218",177,218,"14.0","M'/M.S.L.","14000","f 305","305","","clear","First final 177 (possibly overwritten 187)" diff --git a/data-raw/height_calibrate-lower_tail_rolls.R b/data-raw/height_calibrate-lower_tail_rolls.R new file mode 100644 index 0000000..858fd1f --- /dev/null +++ b/data-raw/height_calibrate-lower_tail_rolls.R @@ -0,0 +1,404 @@ +# height_calibrate-lower_tail_rolls.R — settle the lower tail of `FLYING_HEIGHT` roll by +# roll (fly#60), with instruments that do not read the three fields in dispute. +# +# fly#54 left 1,962 film frames reading under half the height their `SCALE x FOCAL_LENGTH` +# implies, because x10.764, x10 and x2 each brought a comparable share into the band. Pooled, +# that is undecidable. Per roll it is not: the 1,962 sit on 42 rolls, and nearly every roll +# carries ONE `flying_height`, so each roll is one measurement with many witnesses. +# +# Everything here is public. It reads what `height_calibrate-flying_height_slip.R` cached +# (`data-raw/.cache/centroids/`, every frame's x/y/frame_number) and shipped +# (`inst/extdata/flying_height_sweep.csv`, terrain under the sampled frames) — run that +# first. The logbook stage fetches `flight_log_url` pages into the gitignored +# `data-raw/.cache/logbooks/`. +# +# Usage, from the repo root: +# Rscript data-raw/height_calibrate-lower_tail_rolls.R +# +# See `inst/notes/terrain-correction.md` before changing anything here. + +pkgload::load_all(quiet = TRUE) +suppressMessages({ + library(bcdata) + library(dplyr) +}) + +CACHE_DIR <- "data-raw/.cache/centroids" +LOG_DIR <- "data-raw/.cache/logbooks" +SWEEP <- "inst/extdata/flying_height_sweep.csv" +LAYER <- "WHSE_IMAGERY_AND_BASE_MAPS.AIMG_PHOTO_CENTROIDS_SP" +FT <- 0.3048 +FORMAT_M <- 9 * 0.0254 # the film negative, as `fly_footprint()` assumes it +K <- fly_height_slip_factor() +band <- fly_height_ratio_band() +in_band <- function(r) is.finite(r) & r >= band[1] & r <= band[2] + +stopifnot(dir.exists(CACHE_DIR), file.exists(SWEEP)) + +# --------------------------------------------------------------------------- +# Stage 1 — the frames, and the air base between adjacent ones +# --------------------------------------------------------------------------- + +frames <- do.call(rbind, lapply(list.files(CACHE_DIR, "\\.rds$", full.names = TRUE), readRDS)) +frames <- frames[!is.na(frames$film_roll) & is.finite(frames$frame_number), ] +message(sprintf("cache: %d frames on %d rolls", nrow(frames), length(unique(frames$film_roll)))) + +# Air base: distance to the frame numbered one away on the same roll — the same adjacency +# rule `fly_bearing()` applies, for the same reason: frames further apart by number may be +# on another leg. Keyed on (roll, frame) rather than on row order, so a duplicated frame +# number cannot pair a frame with itself. +frames <- frames[order(frames$film_roll, frames$frame_number), ] +key <- paste(frames$film_roll, frames$frame_number) +dup <- key %in% key[duplicated(key)] +message(sprintf("frames sharing a (roll, frame) key, excluded from spacing: %d", sum(dup))) +f1 <- frames[!dup, ] +nxt <- match(paste(f1$film_roll, f1$frame_number + 1), paste(f1$film_roll, f1$frame_number)) +prv <- match(paste(f1$film_roll, f1$frame_number - 1), paste(f1$film_roll, f1$frame_number)) +d_next <- sqrt((f1$x[nxt] - f1$x)^2 + (f1$y[nxt] - f1$y)^2) +d_prev <- sqrt((f1$x[prv] - f1$x)^2 + (f1$y[prv] - f1$y)^2) +# The smaller of the two: a frame at the end of a leg has one neighbour across the turn, +# and the across-turn distance is never the shorter one. +f1$base <- suppressWarnings(pmin(d_next, d_prev, na.rm = TRUE)) +f1$base[!is.finite(f1$base) | f1$base == 0] <- NA_real_ +base <- f1[, c("airp_id", "base")] + +# --------------------------------------------------------------------------- +# Stage 2 — the sampled frames, each with the width every reading implies +# --------------------------------------------------------------------------- + +s <- read.csv(SWEEP) +s <- merge(s, base, by = "airp_id", all.x = TRUE) +s$f_m <- s$focal_length / 1000 +s$nominal_agl <- s$scale_n * s$f_m +s$r <- (s$flying_height - s$elev) / s$nominal_agl +# Forward overlap the frame WOULD have were its along-track side this long. Designed to +# about 60%: a reading that implies 5% or 95% is not the one the aircraft flew. +overlap <- function(side) 1 - s$base / side +s$p_nominal <- overlap(FORMAT_M * s$scale_n) +s$p_reported <- overlap(FORMAT_M * (s$flying_height - s$elev) / s$f_m) +for (k in c(2, 10, K)) { + s[[sprintf("p_x%s", format(round(k, 3)))]] <- + overlap(FORMAT_M * (s$flying_height * k - s$elev) / s$f_m) +} +s$p_div <- overlap(FORMAT_M * (s$flying_height / K - s$elev) / s$f_m) + +q <- function(x) sprintf("%.2f [%.2f-%.2f] n=%d", median(x, na.rm = TRUE), + quantile(x, .1, na.rm = TRUE), quantile(x, .9, na.rm = TRUE), + sum(is.finite(x))) + +# --- Controls, before anything is read from the lower tail ------------------- +# The instrument is believed only if it returns the known answers first. +message("\n== spacing controls: implied forward overlap, median [10-90%] ==") +rnd <- s[s$set == "random" & in_band(s$r), ] +message("random, in band, as reported: ", q(rnd$p_reported)) +message("random, in band, nominal scale: ", q(rnd$p_nominal)) +slp <- s[s$set == "upper_tail" & in_band((s$flying_height / K - s$elev) / s$nominal_agl), ] +message("#54 slipped, divided by 10.764: ", q(slp$p_div)) +message("#54 slipped, as reported: ", q(slp$p_reported)) +ctl_ok <- abs(median(rnd$p_reported, na.rm = TRUE) - 0.6) < 0.1 && + abs(median(slp$p_div, na.rm = TRUE) - 0.6) < 0.1 && + median(slp$p_reported, na.rm = TRUE) > 0.9 +if (!ctl_ok) stop("spacing controls do not return the known answers; the instrument is not trusted") + +# What "the aircraft flew this" looks like, taken from the ordinary frames rather than chosen: +# the central 95% of per-FRAME overlap on in-band random frames. A roll is judged on its +# median, which scatters far less than one frame does, so this window is generous. +p_window <- unname(quantile(rnd$p_reported, c(.025, .975), na.rm = TRUE)) +fits <- function(p) is.finite(p) & p >= p_window[1] & p <= p_window[2] +message(sprintf("overlap window (random, 2.5-97.5%%): %.3f to %.3f", p_window[1], p_window[2])) + +# --- Not a control: the note's claim about the r ~ 2 mass -------------------- +# `terrain-correction.md` said the mass beyond r 1.8 is a 305 mm lens catalogued as 153, so +# nominal scale is right there. That was a hypothesis, and the spacing splits it in two: on +# some rolls nominal gives the designed overlap (the lens reading), on others the REPORTED +# height does and nominal implies ~20% (a scale recorded at half its true denominator). The +# second group is drawn at half width by the fallback. Printed per roll; fly#60 reports it. +mis <- s[s$set == "near_upper" & s$r > 1.8 & s$focal_length == 153, ] +mis_rolls <- mis |> + group_by(film_roll, photo_year, scale_n, flying_height) |> + summarise(n = n(), r = round(median(r), 2), + p_nominal = round(median(p_nominal, na.rm = TRUE), 2), + p_reported = round(median(p_reported, na.rm = TRUE), 2), .groups = "drop") |> + mutate(fits = case_when(fits(p_nominal) & !fits(p_reported) ~ "nominal", + fits(p_reported) & !fits(p_nominal) ~ "reported", + TRUE ~ "neither")) |> + as.data.frame() +message("\n== near_upper r > 1.8 at 153 mm, per roll-height: which reading gives ~60% ==") +print(mis_rolls[order(-mis_rolls$n), ], row.names = FALSE) +message(sprintf("frames: nominal fits %d, reported fits %d, neither %d", + sum(mis_rolls$n[mis_rolls$fits == "nominal"]), + sum(mis_rolls$n[mis_rolls$fits == "reported"]), + sum(mis_rolls$n[mis_rolls$fits == "neither"]))) + +# --------------------------------------------------------------------------- +# Stage 3 — the lower tail, per roll +# --------------------------------------------------------------------------- + +lower <- s[s$set == "lower_tail", ] +lower$k <- (lower$nominal_agl + lower$elev) / lower$flying_height +# How far a height sits from a round number of feet, in feet: the catalogue's heights are +# planned altitudes in feet converted to metres (609 m = 2,000 ft), so a correct reading of +# the recorded value, multiplied back out, should land on a round figure again. +off_round <- function(m, step = 100) { + ft <- m / FT + abs(ft - round(ft / step) * step) +} +rolls <- do.call(rbind, lapply(split(lower, list(lower$film_roll, lower$flying_height), + drop = TRUE), function(d) { + h <- d$flying_height[1] + data.frame( + film_roll = d$film_roll[1], photo_year = d$photo_year[1], + flying_height = h, h_ft = round(h / FT), + n = nrow(d), focal_length = paste(sort(unique(d$focal_length)), collapse = "/"), + scale_n = paste(sort(unique(d$scale_n)), collapse = "/"), + k_med = round(median(d$k), 3), k_spread = round(IQR(d$k) / median(d$k), 3), + round_x1 = round(off_round(h)), round_x10 = round(off_round(h * 10)), + round_xK = round(off_round(h * K)), + n_base = sum(is.finite(d$base)), + p_nominal = round(median(d$p_nominal, na.rm = TRUE), 3), + p_reported = round(median(d$p_reported, na.rm = TRUE), 3), + p_x2 = round(median(d$p_x2, na.rm = TRUE), 3), + p_x10 = round(median(d$p_x10, na.rm = TRUE), 3), + p_xK = round(median(d[[sprintf("p_x%s", format(round(K, 3)))]], na.rm = TRUE), 3), + in_x2 = mean(in_band((h * 2 - d$elev) / d$nominal_agl)), + in_x10 = mean(in_band((h * 10 - d$elev) / d$nominal_agl)), + in_xK = mean(in_band((h * K - d$elev) / d$nominal_agl)) + ) +})) +rolls <- rolls[order(-rolls$n), ] +# Which readings the spacing accepts, per roll-height. More than one can fit: every reading +# whose r lands in the band implies nearly the nominal width, so spacing separates readings a +# factor of ~1.6 apart and no closer. x10 and x10.764 are 7.6% apart and always fit together. +rolls$spacing_fits <- apply( + cbind(reported = fits(rolls$p_reported), nominal = fits(rolls$p_nominal), + x2 = fits(rolls$p_x2), x10 = fits(rolls$p_x10), xK = fits(rolls$p_xK)), 1, + function(v) paste(c("reported", "nominal", "x2", "x10", "x10.764")[v], collapse = "+")) +rolls$spacing_fits[rolls$n_base == 0] <- "no_adjacent_frames" +stopifnot(sum(rolls$n) == nrow(lower)) +message(sprintf("\n== lower tail: %d frames on %d roll-heights (%d rolls) ==", + nrow(lower), nrow(rolls), length(unique(rolls$film_roll)))) +print(rolls, row.names = FALSE) +saveRDS(rolls, "data-raw/.cache/lower_tail_rolls.rds") + +# --------------------------------------------------------------------------- +# Stage 4 — the logbooks: what height and lens the crew wrote down +# --------------------------------------------------------------------------- +# +# `flight_log_url` points at scanned logbook pages. Their TRUE HEIGHT column is thousands of +# feet above mean sea level ("20.0" is 20,000 ft). The pages were transcribed by reading every +# image, with the catalogue values for the CONTROL_ rolls withheld from the reader, into +# `data-raw/flying_height_logbooks.csv` — a hand transcription, so it is an input here and is +# not regenerated. Fetching the pages again is the first half of this stage; reading them is +# not something a script can do. + +fetch_logbooks <- function(rolls) { + dir.create(LOG_DIR, recursive = TRUE, showWarnings = FALSE) + u <- bcdc_query_geodata(LAYER) |> + filter(FILM_ROLL %in% !!rolls) |> + select(FILM_ROLL, FLIGHT_LOG_URL) |> + collect() |> + sf::st_drop_geometry() |> + distinct(FILM_ROLL, FLIGHT_LOG_URL) |> + filter(!is.na(FLIGHT_LOG_URL)) + h <- curl::new_handle(timeout = 60) + status <- vapply(seq_len(nrow(u)), function(i) { + dest <- file.path(LOG_DIR, paste0(u$FILM_ROLL[i], "__", basename(u$FLIGHT_LOG_URL[i]))) + if (file.exists(dest)) return(200L) + resp <- tryCatch(curl::curl_fetch_disk(u$FLIGHT_LOG_URL[i], dest, handle = h), + error = function(e) NULL) + if (is.null(resp)) return(NA_integer_) + if (resp$status_code != 200) unlink(dest) + resp$status_code + }, integer(1)) + message(sprintf("logbook pages for %d rolls: %d fetched or cached, %d failed", + length(rolls), sum(status %in% 200L), sum(!status %in% 200L))) + invisible(u) +} +if (!dir.exists(LOG_DIR)) fetch_logbooks(unique(rolls$film_roll)) + +logs <- read.csv("data-raw/flying_height_logbooks.csv", colClasses = "character") +logs$frame_from <- as.integer(logs$frame_from) +logs$frame_to <- as.integer(logs$frame_to) +logs$log_ft <- as.numeric(logs$height_ft_interpreted) +logs$log_focal <- as.numeric(logs$focal_mm) + +# --- Controls: rolls whose answer is known, read blind ------------------------- +# The two clean rolls must read back exactly as catalogued. bc78065 is a #54 slipped roll. +ctl <- logs[logs$control == "TRUE", c("film_roll", "frames_final", "log_ft", "log_focal")] +message("\n== logbook controls (catalogue values withheld from the reader) ==") +print(ctl, row.names = FALSE) +clean <- list(bcc228 = c(3856, 4267), bc7349 = 6096) +for (roll in names(clean)) { + got <- round(ctl$log_ft[ctl$film_roll == roll] * FT) + if (!all(vapply(clean[[roll]], function(m) any(abs(got - m) <= 2), logical(1)))) { + stop("logbook control ", roll, " does not read back its catalogued height") + } +} + +# --- Each lower-tail frame, joined to the logbook row covering its frame number ----- +fn <- f1[, c("airp_id", "frame_number")] +lt <- merge(lower, fn, by = "airp_id") +stopifnot(nrow(lt) == nrow(lower)) +lt$log_ft <- NA_real_ +lt$log_focal <- NA_real_ +# What the logbook says about each frame, as one of four states — never folded into "no +# height", which is how an earlier version reported frames under a conflict or an unread +# row as frames no logbook page covered (code-check round 3): +# read one covering row with a height, or several that agree +# conflict covering rows with heights that disagree; left unread, not guessed +# uninterpreted covered only by rows whose height was not read, or on a roll with a +# row whose frame range could not be parsed ("169-20?") +# none no row on any page reaches this frame +lt$log_state <- "none" +unparsed_roll <- unique(logs$film_roll[is.na(logs$frame_from) & nzchar(logs$frames_final)]) +for (i in seq_len(nrow(lt))) { + cover <- which(logs$film_roll == lt$film_roll[i] & !is.na(logs$frame_from) & + lt$frame_number[i] >= logs$frame_from & lt$frame_number[i] <= logs$frame_to) + hit <- cover[is.finite(logs$log_ft[cover])] + if (length(hit) && length(unique(logs$log_ft[hit])) == 1) { + lt$log_state[i] <- "read" + lt$log_ft[i] <- logs$log_ft[hit[1]] + foc <- unique(logs$log_focal[hit][is.finite(logs$log_focal[hit])]) + if (length(foc) == 1) lt$log_focal[i] <- foc + } else if (length(hit)) { + lt$log_state[i] <- "conflict" + } else if (length(cover) || lt$film_roll[i] %in% unparsed_roll) { + lt$log_state[i] <- "uninterpreted" + } +} +stopifnot(identical(lt$log_state == "read", is.finite(lt$log_ft))) +# The factor the logbook implies, accepted only where it is one of the named slips to within +# 2%. The catalogue stores whole metres, so a round figure of feet comes back a fraction off. +ratio <- lt$log_ft * FT / lt$flying_height +named <- c(1, 10, 100) +lt$log_factor <- vapply(ratio, function(q) { + hit <- named[is.finite(q) & abs(q / named - 1) <= 0.02] + if (length(hit)) hit else NA_real_ +}, numeric(1)) +# A 6" lens is written as 152-153 mm and a 12" one anywhere from 304 to 305. +lt$focal_agrees <- is.finite(lt$log_focal) & abs(lt$log_focal - lt$focal_length) <= 3 + +# Grouped on the same four fields the shipped key carries, so a row can never describe +# frames its key does not reach, or reach frames it does not describe. +verdict <- do.call(rbind, lapply(split(lt, list(lt$film_roll, lt$flying_height, + lt$focal_length, lt$scale_n), drop = TRUE), + function(d) { + read <- is.finite(d$log_factor) + fac <- if (any(read)) as.numeric(names(which.max(table(d$log_factor[read])))) else NA_real_ + agree <- read & d$log_factor %in% fac + # The height the table ships is the LOGBOOK's, converted — never the catalogue's times the + # factor. The catalogue stores whole metres of a converted figure, so bc7280's 20,000 ft + # is catalogued as 60 m, and 60 x 100 is 6,000 m against the 6,096 the crew wrote down. + h_true <- if (any(agree)) median(d$log_ft[agree]) * FT else NA_real_ + data.frame( + film_roll = d$film_roll[1], photo_year = d$photo_year[1], flying_height = d$flying_height[1], + focal_length = d$focal_length[1], scale_n = d$scale_n[1], n = nrow(d), + n_logbook = sum(is.finite(d$log_ft)), n_named = sum(read), + n_conflict = sum(d$log_state == "conflict"), + n_uninterpreted = sum(d$log_state == "uninterpreted"), + factor = fac, n_agree = sum(agree), + log_ft = if (any(agree)) median(d$log_ft[agree]) else NA_real_, + height_m = round(h_true, 1), + focal_logbook = paste(sort(unique(d$log_focal[is.finite(d$log_focal)])), collapse = "/"), + # Only a LEGIBLE focal length that differs counts against the row; a blank or unread one + # says nothing either way. + n_focal_conflict = sum(is.finite(d$log_focal) & !d$focal_agrees), + n_base = sum(is.finite(d$base[agree])), + p_corrected = if (any(agree)) median(1 - d$base[agree] / (FORMAT_M * (h_true - d$elev[agree]) / + d$f_m[agree]), na.rm = TRUE) else NA_real_, + r_corrected = if (any(agree)) median((h_true - d$elev[agree]) / d$nominal_agl[agree]) else NA_real_ + ) +})) +verdict <- verdict[order(-verdict$n), ] +stopifnot(sum(verdict$n) == nrow(lower)) +message("\n== lower tail against the logbooks, per roll-height ==") +print(verdict, row.names = FALSE) +saveRDS(list(verdict = verdict, frames = lt), "data-raw/.cache/lower_tail_verdict.rds") + +# --------------------------------------------------------------------------- +# Stage 5 — the verdict, and the two shipped tables +# --------------------------------------------------------------------------- +# +# The rule, fixed before any roll was classified by it. A roll-height is corrected only where +# 1. the logbook covers at least half its frames, and at least 90% of the covered frames +# name the same factor (1, 10 or 100); +# 2. no legible logbook focal length contradicts the catalogue's — a different lens is a +# different defect, and nominal scale is already right for it; +# 3. spacing under the corrected height is inside the window the random frames set. +# Two instruments, independent of each other and of the three fields in dispute. A factor +# of 1 means the logbook confirms the catalogued height and the spacing accepts it: the +# SCALE is the wrong field, and the fallback to nominal scale is what draws those frames +# wrong. + +v <- verdict +v$covered <- v$n_logbook / v$n +v$agreeing <- ifelse(v$n_logbook > 0, v$n_agree / v$n_logbook, 0) +v$focal_conflict <- v$n_focal_conflict > 0 +v$spacing_ok <- fits(v$p_corrected) +v$accept <- is.finite(v$factor) & v$covered >= 0.5 & v$agreeing >= 0.9 & + !v$focal_conflict & v$spacing_ok +# Where the logbook read under half the frames, the reason names the state most of the +# unread frames are in — the predicate that actually fired, not the one next to it. +unread_state <- ifelse(v$n_conflict >= v$n_uninterpreted & v$n_conflict > 0, "conflict", + ifelse(v$n_uninterpreted > 0, "uninterpreted", "none")) +v$reason <- dplyr::case_when( + v$accept ~ NA_character_, + v$covered < 0.5 & unread_state == "conflict" ~ "logbook rows covering these frames disagree", + v$covered < 0.5 & unread_state == "uninterpreted" ~ "logbook height or frame range not read", + v$n_logbook == 0 ~ "no logbook page covers these frames", + v$covered < 0.5 ~ "logbook covers under half the frames", + !is.finite(v$factor) | v$agreeing < 0.9 ~ "logbook height is not a named multiple of the catalogue's", + v$focal_conflict ~ "logbook names a different lens; nominal scale already sizes it", + v$n_base == 0 ~ "no adjacent frames to measure spacing on", + TRUE ~ "spacing rejects the logbook's height" +) +v$cause <- dplyr::case_when( + !v$accept ~ NA_character_, + v$factor == 1 ~ "scale_wrong", + v$factor == 10 ~ "height_digit_dropped", + v$factor == 100 ~ "height_two_digits_dropped" +) + +rolls_out <- data.frame( + film_roll = v$film_roll, flying_height = v$flying_height, focal_length = v$focal_length, + scale_n = v$scale_n, factor = v$factor, cause = v$cause, logbook_ft = v$log_ft, + height_m = v$height_m, + frames_measured = v$n, frames_logbook = v$n_agree, + overlap_corrected = round(v$p_corrected, 3), r_corrected = round(v$r_corrected, 3) +)[v$accept, ] +rolls_out <- rolls_out[order(rolls_out$film_roll, rolls_out$flying_height), ] +excluded_out <- data.frame( + film_roll = v$film_roll, flying_height = v$flying_height, focal_length = v$focal_length, + scale_n = v$scale_n, frames_measured = v$n, reason = v$reason +)[!v$accept, ] +excluded_out <- excluded_out[order(excluded_out$film_roll, excluded_out$flying_height), ] +stopifnot(nrow(rolls_out) + nrow(excluded_out) == nrow(v), + sum(rolls_out$frames_measured) + sum(excluded_out$frames_measured) == nrow(lower)) + +message(sprintf("\n== verdict: %d roll-heights corrected (%d frames), %d excluded (%d frames) ==", + nrow(rolls_out), sum(rolls_out$frames_measured), + nrow(excluded_out), sum(excluded_out$frames_measured))) +print(table(rolls_out$cause)) +print(tapply(rolls_out$frames_measured, rolls_out$cause, sum)) +print(table(excluded_out$reason)) +print(rolls_out, row.names = FALSE) + +# The key reaches only the frames it was measured on: count what each row would touch +# across the whole catalogue, on the same four fields `fly_footprint()` matches. +# Numbers formatted as `fly_footprint()` formats them, so the two keys cannot spell one +# value two ways (100000L is "100000" to `paste()`, 100000 is "1e+05"). +num <- function(x) formatC(as.numeric(x), format = "f", digits = 3) +key4 <- function(roll, h, f, sc) paste(roll, num(h), num(f), num(sc)) +key_all <- key4(frames$film_roll, frames$flying_height, frames$focal_length, + suppressWarnings(as.numeric(sub("^1:", "", frames$scale)))) +reach <- vapply(seq_len(nrow(rolls_out)), function(i) { + sum(key_all == key4(rolls_out$film_roll[i], rolls_out$flying_height[i], + rolls_out$focal_length[i], rolls_out$scale_n[i])) +}, integer(1)) +stopifnot(all(reach >= rolls_out$frames_measured)) +message(sprintf("frames the corrections reach in the whole catalogue: %d (measured: %d)", + sum(reach), sum(rolls_out$frames_measured))) + +write.csv(rolls_out, "inst/extdata/flying_height_rolls.csv", row.names = FALSE, na = "") +write.csv(excluded_out, "inst/extdata/flying_height_rolls_excluded.csv", row.names = FALSE, + na = "") diff --git a/inst/extdata/flying_height_rolls.csv b/inst/extdata/flying_height_rolls.csv new file mode 100644 index 0000000..8f80c2c --- /dev/null +++ b/inst/extdata/flying_height_rolls.csv @@ -0,0 +1,23 @@ +"film_roll","flying_height","focal_length","scale_n","factor","cause","logbook_ft","height_m","frames_measured","frames_logbook","overlap_corrected","r_corrected" +"bc5224",1082,153,15840,1,"scale_wrong",3550,1082,46,46,0.614,0.333 +"bc5449",609,153,15000,1,"scale_wrong",2000,609.6,3,3,0.585,0.261 +"bc5536",731,153,12000,1,"scale_wrong",2400,731.5,9,9,0.604,0.384 +"bc5651",701,153,31680,10,"height_digit_dropped",23000,7010.4,41,41,0.64,1.121 +"bc5655",1158,153,75062,1,"scale_wrong",3800,1158.2,9,9,0.586,0.096 +"bc5697",457,153,6000,1,"scale_wrong",1500,457.2,102,102,0.619,0.487 +"bc7280",60,305,16000,100,"height_two_digits_dropped",20000,6096,101,101,0.652,1.073 +"bc7584",609,305,20000,10,"height_digit_dropped",20000,6096,124,124,0.647,0.805 +"bc7692",4511,305,63360,1,"scale_wrong",14800,4511,31,31,0.639,0.197 +"bc77014",853,153,50000,10,"height_digit_dropped",28000,8534.4,7,7,0.614,0.887 +"bc7703",1561,305,20000,1,"scale_wrong",5120,1560.6,10,10,0.618,0.19 +"bc77032",701,610,10000,10,"height_digit_dropped",23000,7010.4,54,54,0.608,0.905 +"bc7717",1676,305,12500,1,"scale_wrong",5500,1676.4,84,84,0.628,0.409 +"bc7717",1829,305,12500,1,"scale_wrong",6000,1828.8,43,43,0.656,0.405 +"bc78105",1295,153,20000,1,"scale_wrong",4250,1295.4,17,17,0.637,0.216 +"bc78151",1295,153,20000,1,"scale_wrong",4250,1295.4,35,35,0.668,0.22 +"bc7835",640,305,20000,10,"height_digit_dropped",21000,6400.8,8,8,0.629,0.995 +"bc79122",640,305,20000,10,"height_digit_dropped",21000,6400.8,149,149,0.618,1.039 +"bc81013",823,153,40000,10,"height_digit_dropped",27000,8229.6,59,59,0.643,1.176 +"bc81027",732,153,40000,10,"height_digit_dropped",24000,7315.2,18,18,0.631,1.026 +"bc85036",686,305,15000,10,"height_digit_dropped",22500,6858,34,34,0.606,1.176 +"bc88054",655,305,15000,10,"height_digit_dropped",21500,6553.2,17,17,0.602,1.072 diff --git a/inst/extdata/flying_height_rolls_excluded.csv b/inst/extdata/flying_height_rolls_excluded.csv new file mode 100644 index 0000000..0549964 --- /dev/null +++ b/inst/extdata/flying_height_rolls_excluded.csv @@ -0,0 +1,56 @@ +"film_roll","flying_height","focal_length","scale_n","frames_measured","reason" +"bc5097",2012,153,31680,15,"logbook covers under half the frames" +"bc5097",2073,153,31680,1,"logbook height is not a named multiple of the catalogue's" +"bc5280",457,153,6000,3,"spacing rejects the logbook's height" +"bc5524",243,153,12000,30,"logbook height or frame range not read" +"bc5524",274,153,12000,18,"logbook height or frame range not read" +"bc5524",304,153,12000,21,"logbook height or frame range not read" +"bc5593",1066,153,15000,8,"logbook height is not a named multiple of the catalogue's" +"bc5597",777,153,12000,18,"spacing rejects the logbook's height" +"bc5598",2,153,16000,29,"logbook rows covering these frames disagree" +"bc5628",1371,153,80000,35,"spacing rejects the logbook's height" +"bc7211",4877,305,32000,18,"spacing rejects the logbook's height" +"bc7675",609,305,16000,84,"logbook covers under half the frames" +"bc78104",1295,153,20000,52,"no logbook page covers these frames" +"bc79067",4770,305,50000,47,"spacing rejects the logbook's height" +"bc79067",4968,305,50000,60,"spacing rejects the logbook's height" +"bc79067",5067,305,50000,8,"spacing rejects the logbook's height" +"bc79067",5624,305,50000,10,"spacing rejects the logbook's height" +"bc79086",6325,305,60000,107,"logbook names a different lens; nominal scale already sizes it" +"bc79209",7620,305,50000,85,"logbook rows covering these frames disagree" +"bc80001",1524,305,10000,106,"logbook names a different lens; nominal scale already sizes it" +"bc81026",762,305,5000,11,"logbook names a different lens; nominal scale already sizes it" +"bc87070",396,153,23000,34,"logbook height or frame range not read" +"bcb05001",1051,305,20000,2,"no logbook page covers these frames" +"bcb05001",1053,305,20000,3,"no logbook page covers these frames" +"bcb05001",1054,305,20000,2,"no logbook page covers these frames" +"bcb05001",1055,305,20000,2,"no logbook page covers these frames" +"bcb05001",1056,305,20000,3,"no logbook page covers these frames" +"bcb05001",1057,305,20000,2,"no logbook page covers these frames" +"bcb05001",1058,305,20000,4,"no logbook page covers these frames" +"bcb05001",1059,305,20000,4,"no logbook page covers these frames" +"bcb05001",1060,305,20000,2,"no logbook page covers these frames" +"bcb05001",1061,305,20000,1,"no logbook page covers these frames" +"bcb05001",1062,305,20000,3,"no logbook page covers these frames" +"bcb05001",1063,305,20000,1,"no logbook page covers these frames" +"bcb05001",1064,305,20000,1,"no logbook page covers these frames" +"bcb05001",1065,305,20000,2,"no logbook page covers these frames" +"bcb05001",1066,305,20000,1,"no logbook page covers these frames" +"bcb05001",1069,305,20000,1,"no logbook page covers these frames" +"bcb05001",1215,305,20000,1,"no logbook page covers these frames" +"bcb05001",1216,305,20000,1,"no logbook page covers these frames" +"bcb05001",1217,305,20000,1,"no logbook page covers these frames" +"bcb05001",1218,305,20000,1,"no logbook page covers these frames" +"bcb05001",1220,305,20000,1,"no logbook page covers these frames" +"bcb05001",1222,305,20000,1,"no logbook page covers these frames" +"bcb05001",1225,305,20000,2,"no logbook page covers these frames" +"bcb05001",1226,305,20000,2,"no logbook page covers these frames" +"bcb05001",1227,305,20000,1,"no logbook page covers these frames" +"bcb05001",1228,305,20000,2,"no logbook page covers these frames" +"bcb05001",1229,305,20000,1,"no logbook page covers these frames" +"bcb05001",1230,305,20000,2,"no logbook page covers these frames" +"bcb05001",1231,305,20000,1,"no logbook page covers these frames" +"bcc162",2438,305,16000,86,"logbook names a different lens; nominal scale already sizes it" +"bcc401",1219,305,10000,12,"logbook height is not a named multiple of the catalogue's" +"bcc822",701,305,15000,11,"logbook height or frame range not read" +"bcc94147",5511,305,52000,1,"logbook height is not a named multiple of the catalogue's" diff --git a/planning/active/findings.md b/planning/active/findings.md index ac89b3b..a3d5126 100644 --- a/planning/active/findings.md +++ b/planning/active/findings.md @@ -81,3 +81,77 @@ frame, so the spacing instrument needs no network. `flight_log_url` points at JP | Error | Resolution | |-------|------------| + +## Phase 1 — instruments and controls (2026-09-26) + +Script: `data-raw/height_calibrate-lower_tail_rolls.R`. Logbook transcription: +`data-raw/flying_height_logbooks.csv` (120 pages, 193 rows; three parallel readers, catalogue +values for CONTROL_ rolls withheld). + +**Spacing controls (implied forward overlap, median [10-90%]):** random in-band as reported +0.63 [0.59-0.69] n=2481; #54 slipped after /10.764 0.62 [0.58-0.66] n=1589, as reported 0.97. +Window set from the random frames' 2.5-97.5%: 0.557-0.780. + +**The note's r~2 claim is half wrong.** near_upper r>1.8 at 153 mm splits per roll: 1978-81 +rolls fit nominal (305 catalogued as 153 — nominal right), but the 1972-76 bc5xxx rolls +(bc5702, bc5699, bc5642, bc5648, bc5510...) fit the REPORTED height with nominal implying ~20% +overlap: the scale is wrong, and the fallback draws them at half width. Follow-up issue. + +**Logbook controls:** bcc228 reads 12.650 / 14.0 (3,856 / 4,267 m catalogued — exact); +bc7349 page (served as bc7350's URL) 20.0 = 6,096 m exact. So TRUE HEIGHT is thousands of feet +MSL and the catalogue converts it faithfully when read right. bc78065 (a #54 slipped roll) reads +**1.35** = 1,350 ft where the catalogue holds 13,500 ft (4,115 m): a decimal slip, x10, not +x10.764. + +**#54's factor, cross-checked by round feet:** pre-2000 slipped roll-heights land on exact +round feet under /10 (bc5596 8,600; bc78065 1,350; bc78078 3,100; bc79103 7,200; bcc00085 +18,000/18,500/19,000) and not under /10.764. The 2003 rolls carry measured per-frame heights, +round under neither. Follow-up issue — changing a shipped constant is out of scope here. + +## Phase 2 — verdict + +Rule fixed before classification: logbook covers >= 50% of the roll-height's frames and >= 90% +of those agree on a named factor (1, 10, 100); no legible logbook focal contradicts the +catalogue; spacing under the corrected height inside the window. + +| cause | roll-heights | frames | +|---|---|---| +| height_digit_dropped (x10) | 10 | 511 | +| height_two_digits_dropped (x100, bc7280) | 1 | 101 | +| scale_wrong (height verified, factor 1) | 11 | 389 | +| excluded | 55 | 961 | + +Excluded reasons: no logbook row 36, spacing rejects 8, not a named multiple 4, different lens 4 +(bc79086 85 mm, bc80001/bcc162/bc81026 153 mm — nominal already right), under half covered 3. +On the four-field key `fly_footprint()` matches (roll, height, lens, scale) the corrections +reach exactly the 1,001 measured frames. A two-field key reached 1,025: the 24 extra are bc5655 +frames at the same height and 1:7200, which the catalogued scale_n (75062) keeps off the table. +(An earlier draft here credited the "disputed only" condition; code-check round 1 corrected it.) + +Round 1 also found the shipped height must be the LOGBOOK's, converted (`height_m`), not the +catalogue's times the factor: bc7280's 20,000 ft is catalogued as 60 m, and x100 of that is +6,000 m against 6,096. + +x10.764 is refused for every lower-tail roll the logbook reads: the logbook gives round feet +exactly x10 (bc79122 21,000; bc7584 20,000; bc81013 27,000; bc77014 28,000). + +## Code-check (Phase 1-3), 2026-09-26 + +| Round | Findings | Fixed | Inside previous fix? | +|---|---|---|---| +| 1 | 1 bug, 3 fragile | 4 | — | +| 2 | 2 fragile | 2 | n | +| 3 | 2 bug, 1 fragile | 3 | **y** — the reason labels round 1 had repaired still merged three logbook states into "no row covers" | + +Mechanism (round 3's naming): one fact in two representations with nothing checking they agree — +catalogue x factor vs logbook height; illegible vs conflicting focal; 2- vs 4-field grouping; +reason string vs the predicate that fired; the test's height_source list vs the function's; +100000L vs 100000. + +Ended by enumeration, not a quiet round: every one of the 77 shipped labels (22 accepts, 55 +exclusion reasons) re-derived from the per-frame logbook states by an independent predicate, +with the overlap window recomputed from scratch — 77 of 77 agree; the 30 "no page" keys are +all-`none` at the frame level. Every figure in the note's fly#60 section traced to the script +output or the CSVs. Round 3 corrected the exclusions to 30 no page / 8 spacing / 5 not read / +4 no named multiple / 4 lens / 2 disagree / 2 under half, and the note's shares to 22 of 77 +roll-heights, 1,001 of 1,962 frames. diff --git a/planning/active/progress.md b/planning/active/progress.md index 2e3b193..1f02c5c 100644 --- a/planning/active/progress.md +++ b/planning/active/progress.md @@ -8,3 +8,10 @@ - Created branch `60-lower-tail-flying-height` off main - Scaffolded PWF baseline from issue #60 with approved phases - Next: start Phase 1 +- Phase 1: spacing + round-feet + logbook instruments with controls; three parallel readers transcribed 120 logbook pages +- Phase 2: verdict — 22 roll-heights corrected (1,001 frames), 55 excluded with reasons; tables written +- Found: the note's r~2 claim is half wrong; #54's /10.764 looks like /10 on pre-2000 rolls — both to follow-up issues +- Phase 3: fly_footprint() consults flying_height_rolls.csv; height_source "corrected_roll_table"; 9 tests, 7 restored defects each go red +- /code-check: 3 rounds, 8 findings fixed; ended by enumerating all 77 labels against an independent predicate (77/77) +- Filed follow-ups #71 (#54's /10.764 looks like /10 on pre-2000 rolls) and #72 (half the r~2 mass is a wrong scale) +- Suite: 2,279 passed, 0 failed diff --git a/planning/active/review-round1.md b/planning/active/review-round1.md new file mode 100644 index 0000000..f07b817 --- /dev/null +++ b/planning/active/review-round1.md @@ -0,0 +1,66 @@ +# Review round 1 — fly#60 lower-tail roll calibration + +Method: read the diff, the script, both shipped CSVs, the logbook transcription and +findings.md; ran the script in a copy (`scratchpad/flycopy`) — rc 0, both shipped CSVs +reproduce byte-identical (`cmp`). Then probed the cached verdict/frames objects for the +failure modes below. Re-derived every count in findings.md Phase 2 (cause table, excluded +reasons 36/8/4/4/3, 1,001 + 961 = 1,962, reach 1,025): all match the artifacts. + +Checked and found fine (no action): no duplicate airp_id or (roll, frame) key in the cache +(so the merges cannot inflate rows); no NA `elev` in the sweep (so the `s$r > 1.8` subset at +line 116 cannot emit all-NA rows); no verdict group mixes focal_length or scale_n today; the +two clean logbook controls read back 3,856 / 4,267 / 6,096 m exactly; no logbook ratio sits +near 10.764 (unnamed ratios are 0.715, 0.926, 3.251, 1.051), so excluding 10.764 from +`named` refuses nothing the logbook actually says; every accepted factor-1 row has nominal +overlap outside the window (worst bc5697 0.816 vs 0.780), so "scale_wrong" is discriminated +by spacing, not just asserted. + +## Findings + +- **[severity: bug]** data-raw/height_calibrate-lower_tail_rolls.R:260-265, 328-336 / + inst/extdata/flying_height_rolls.csv row bc7280 — the shipped correction is an exact + named factor, but the catalogue value it multiplies is truncated. bc7280: catalogue 60 m, + logbook 20,000 ft = 6,096 m, ratio 101.6 (accepted as 100 inside the 2% tolerance), so + `flying_height x factor` = 6,000 m — 96 m / 1.6% low, and `r_corrected` (1.053) is + computed on the low value. A consumer applying `factor` sizes all 101 frames ~2% narrow + over ~5 km of AGL — larger than the 0.5-1% errors this repo treats as material elsewhere. + The x10 rows are fine (worst bc7584: 6,090 vs 6,096 m, 0.1%). Either ship the corrected + height as `logbook_ft * 0.3048` (the table already carries `logbook_ft`), or state in the + table/note which of the two the runtime must use; as shipped, `factor` is the column whose + name invites use and it is the wrong one for x100. + +- **[severity: fragile]** data-raw/height_calibrate-lower_tail_rolls.R:282, 313 — + `focal_conflict` is `nzchar(focal_logbook) & n_focal_agrees < n_logbook`, and + `focal_agrees` is FALSE for a covered frame whose logbook focal is illegible (NA) or + ambiguous (two focal values → NA at line 255). So a roll-height where some covered frames + carry a matching legible focal and others are illegible is excluded as "logbook names a + different lens" — contradicting the rule text at lines 302-303 ("no legible logbook focal + length contradicts"). No current row is in that state (every covered roll-height is either + all-legible or all-illegible — bc7280, bc5449, bc7675, bc5628, bc79067 are all-NA), so the + shipped tables are unaffected; it misfires on the next transcription that is partly + legible. Count contradictions as `sum(is.finite(log_focal) & !focal_agrees)`. + +- **[severity: fragile]** data-raw/height_calibrate-lower_tail_rolls.R:270, 276, 333 — + the verdict groups on (film_roll, flying_height) but the shipped key is four fields, filled + from `d$focal_length[1]` / `d$scale_n[1]`. If a roll-height ever mixes focal or scale (Stage + 3 anticipates it — it pastes "a/b" at lines 152-153), the shipped key covers only the first + frame's subset while `frames_measured`, `p_corrected` and `r_corrected` describe all of + them. No group mixes today; nothing asserts it. Split on all four fields, or + `stopifnot(length(unique(d$focal_length)) == 1, length(unique(d$scale_n)) == 1)`. + Related: the "reach" check at lines 357-360 keys on two fields, not the shipped four, so + it reports 1,025 where the shipped key reaches 1,001 (the 24 are bc5655 frames at 1:7200, + excluded by scale_n=75062). findings.md explains the 24 by "the runtime (disputed frames + only) does not touch them"; the actual reason they are untouched is the scale_n in the key. + +- **[severity: fragile]** data-raw/height_calibrate-lower_tail_rolls.R:315, 323 — a + roll-height whose agreeing frames have no adjacent-frame base gets `p_corrected` NA → + `spacing_ok` FALSE → reason "spacing rejects the logbook's height", when spacing measured + nothing. Not triggered now (every agreeing group has n_base > 0), but the shipped + `reason` column would then state a measurement that was not made. + +## Clean otherwise + +No security issues. `fetch_logbooks()` (lines 194-217) caches a partial file from a +transport failure as a 200 and never retries once `LOG_DIR` exists, and it fetches only the +lower-tail rolls (not the control rolls bcc228/bc7349/bc78065), but its output feeds nothing +downstream — the transcription CSV is the input — so no number depends on it. diff --git a/planning/active/review-round2.md b/planning/active/review-round2.md new file mode 100644 index 0000000..afb2377 --- /dev/null +++ b/planning/active/review-round2.md @@ -0,0 +1,45 @@ +# Code-check round 2 — fly#60 (lower-tail roll table) + +Reviewed: diff_r2.patch, R/fly_footprint.R (whole DEM route), the new test file, the +calibration script, both shipped CSVs, setup.R. Ran `test-fly_footprint_height_rolls.R` +and `test-fly_footprint_invariants.R` with NOT_CRAN=true in a copy (/tmp/fly_review_r2): +both green. + +Round-1 fixes checked and holding: runtime sizes from `height_m` (bc7280 -> 6096); +focal conflict counts only legible mismatches; verdict grouped on the same 4-field key the +runtime matches, with the reach check on those 4 fields; "no adjacent frames" is its own +reason, reached only when spacing is non-finite. + +Runtime classification checked: `tabled` is excluded from `implausible` by name, and a +tabled frame always has a finite first-pass elev (r_reported finite), so it lands in +`corrected` and never in the `unusable` residual; the second pass samples +`resize(first$elev, tab_height)`, the corrected rectangle; the NA/"NA" key hole is closed +by `keyed`; `out_of_band` (not `disputed`) is what lets bc7280's below-terrain height reach +the table, and a tabled factor that fails to reconcile falls back exactly as before. + +## Findings + +- **[fragile]** tests/testthat/test-fly_footprint_invariants.R:46 — the invariant + `has_height <- fp$height_source %in% c("reported", "corrected_unit_slip")`, asserted + identical to `!is.na(fp$height_agl)` and to `footprint_terrain == "dem_agl"`, is now + false for the new value: a `"corrected_roll_table"` frame has a `height_agl` and is + `dem_agl`, but is not in the set. It stays green only because no `footprint_cases()` + input sits on a table row (the bundled frames are bc5282/1968). The closed set was not + widened when a third "has a height" value was added, so the guard now encodes a wrong + contract and will fail the day a case reaches the table (e.g. if `roll_fixture()` is + added to the sweep), or — worse — mislead a reader into treating + `corrected_roll_table` as heightless. Add the new value to the set. + +- **[fragile]** R/fly_footprint.R:1133-1135 — the key is built with `paste()` on + mixed types: `tab$scale_n` / `tab$flying_height` / `tab$focal_length` are read by + `read.csv()` as **integer**, while `fh` and `scale_num` are **double**. `paste()` formats + them differently for round values: `paste(100000L)` is `"100000"` but `paste(100000)` is + `"1e+05"` (measured; likewise 1e6). No row in today's table has such a value (largest + scale_n 75062), so nothing is mis-matched now, but a regenerated table carrying a + 1:100000 row would silently never match — the frame falls back to "implausible" with no + error. The script's reach check (`key4` over `frames`, both numeric) and the test's + fixture check (numeric scale vs integer table, same as runtime) cannot see it. Format + both sides explicitly (e.g. `format(x, scientific = FALSE, trim = TRUE)` or + `sprintf("%.0f")`) or coerce both to the same type before pasting. + +No bugs found that produce a wrong footprint, height or classification on today's table. diff --git a/planning/active/review-round3.md b/planning/active/review-round3.md new file mode 100644 index 0000000..d6417ab --- /dev/null +++ b/planning/active/review-round3.md @@ -0,0 +1,100 @@ +# Review round 3 — fly#60 + +Reviewed the staged diff, plus the unstaged edits to `inst/notes/terrain-correction.md` and +the vignette. I ran `data-raw/height_calibrate-lower_tail_rolls.R` in a copy +(`/tmp/fly_review_r3`), with RC=0 and every control passing. The two CSVs it wrote are +byte-identical to the shipped ones (`cmp`). I recomputed every count in the new note section +from the shipped CSVs and the run log. + +## Mechanism + +One fact is represented in two places, and nothing checks that the two agree. Usually the +second representation also merges states that the first kept apart: + +- the table height as catalogue times factor, against the logbook height; +- an illegible focal length, against a conflicting one; +- a 2-field grouping, against a 4-field key; +- a reason string, against the predicate that actually fired; +- the test's list of `height_source` values, against the function's; +- `100000L`, against `100000`. + +Each time, the downstream site re-derives the meaning from a proxy. That proxy agrees with the +source on the cases in hand and loses a distinction elsewhere. In this round the live proxy is +`is.finite(log_ft)`. It merges "no logbook row covers the frame", "a row covers it but its +height or frame range was not interpreted", and "two covering rows disagree" into one NA. The +reason labels, and the note's counts, then report all three as "no row covers". + +## Where the mechanism reaches, and what was checked + +- **`fly_footprint()` key** (`num()` on both sides, NA guard, `out_of_band` mask): correct. In + the sweep, all 1,001 table frames pass the per-frame gate (factor > 1 in band; factor 1 + with `r_reported > 0`). +- **`height_source` value lists:** + - The invariants test is fixed. + - `setup.R` and `test-fly_footprint_height.R` list fixture-specific values, which is + correct. + - `test-fly_footprint_coverage.R:299` and `data-raw/dem_calibrate-coverage_error.R:870` + use `!%in% "reported"`, which keeps its meaning. + - No other `R/` consumer reads the column. +- **Script `key4()`** (line 368): still uses `paste()`, the representation r2 removed from the + function. Today it matches: the cache holds integers, and the table has no scale of 1e5 or + more. A mismatch would fail loud through `stopifnot(reach >= ...)`. So it is fragile, not + wrong, and I have not listed it as a finding. Note the consequence: the note's claim that + the key "reaches exactly the 1,001" comes from a different key builder than the one + `fly_footprint()` uses. The two only happen to agree. +- **Script verdict grouping, and test `key()`:** the same 4 fields and the same `read.csv` + representation on both sides. Consistent. +- **Script reasons from `is.finite(log_ft)`:** wrong. See finding 1. +- **The note's rates, compared against the shipped counts:** see finding 2. + +## Findings + +- **[severity: bug]** `data-raw/height_calibrate-lower_tail_rolls.R:248,252,284,328-329`. The + exclusion reasons treat any NA `log_ft` as "not covered", so 6 of the 36 roll-heights + labelled `"no logbook row covers these frames"` are in fact covered by a logbook row: + - `bc5524` 243/274/304 (69 frames): the covering rows read "1,000", "900" and "600", with + units left uninterpreted. + - `bcc822` 701 (11 frames): the covering row "2300" was deliberately not interpreted. + - `bc5598` 2 (29 frames, 153-181): two covering rows conflict, 110-196 at 7.5 against + 153-181 at 8.5. + - `bc87070` 396 (34 frames): the page covers them as "169-20?", a range the script cannot + parse. + + `bc79209` 7620 is labelled `"logbook covers under half the frames"`, but every one of its + 85 frames is covered. 51 of them sit under two disagreeing rows (sheet 1 "1-85" at 25,000, + sheet 2 "1-51" at 21,340), and that is the only reason they are not counted as covered. + + These labels ship in `inst/extdata/flying_height_rolls_excluded.csv`, and the note repeats + them. `terrain-correction.md` line ~310 says "36 with no logbook page covering the frames" + and "3 under half covered". The page-covering count is 30, and one of the 3 is a + conflict, not partial coverage. This is the same class as r1's "spacing rejects" label + written where no spacing was measured. A reader acting on "no page covers" would go and + fetch or read pages that are already transcribed. + + Fix: count covering rows before the `is.finite(logs$log_ft)` filter. Then give "covering + rows disagree" and "logbook height or frame range not interpreted" their own reasons ahead + of the coverage reasons. + +- **[severity: bug]** `inst/notes/terrain-correction.md:244` and `:271`. There are two + published rates for how much of the lower tail fly#60 settled, and they contradict each + other. The data supports neither: + - Line 244 says "three quarters of it was settled per roll". The actual share is 1,001 of + 1,962 frames (51%). Even counting the 4 different-lens exclusions as settled only gives + 1,311 of 1,962 (67%). + - Line 271 says the two instruments "settle about half of them". In context, "them" is the + 77 roll-heights, and 22 of 77 is 29%. + + Supported wordings: + - about half the frames (1,001 of 1,962); + - 22 of 77 roll-heights; + - 21 of 42 rolls. + +- **[severity: fragile]** `inst/notes/terrain-correction.md:238` and `R/fly_footprint.R:214` + say "the 1972-76 rolls" in the r ≈ 2 mass fit their reported height. The script's own + per-roll table disagrees in both directions: + - 12 of the 105 reported-fitting frames are from 1965, 1978 and 1979 (`bc5138`, + `bc79141`, `bc79039`, `bc78110`). + - `bc7407` (1972) fits nominal, and `bc7454` (1973) fits neither. + + "About half" (105 of 209) is correct. The year range is a description, not the classifier, + and fly#72 should key on the per-roll fit, not on the years. diff --git a/planning/active/task_plan.md b/planning/active/task_plan.md index 8dada43..b34c80b 100644 --- a/planning/active/task_plan.md +++ b/planning/active/task_plan.md @@ -50,26 +50,26 @@ Three instruments, each independent of the fields in dispute, all public: — read the stated altitude by eye for the 42 rolls. ## Phase 1: Instruments and their controls (data-raw only) -- [ ] `data-raw/height_calibrate-lower_tail_rolls.R` (loads source via `pkgload::load_all()`, reads the #54 cache + sweep; no refetch of what is cached) -- [ ] Per-roll table: n, distinct heights, H in ft, roundness of H·k in ft for k ∈ {1, 2, 10, 10.764}, median/IQR of k -- [ ] Spacing: implied forward overlap per roll under nominal, reported and each remedy, using only frames adjacent by frame number (same rule as `fly_bearing()`), reusing `fly_bearing()`'s adjacency logic rather than re-deriving it -- [ ] Spacing **positive controls before reading the lower tail**: random in-band set (expect ~60% under reported), #54 slipped set after ÷10.764 (known answer), near_upper r≈2 mass (expect nominal ~60%, DEM route wrong); print them first and stop if the controls fail -- [ ] Fetch `flight_log_url` for the 42 rolls via WFS (`bcdata::filter`, not constructed paths); cache JPGs under gitignored `data-raw/.cache/logbooks/`; include 3–4 control rolls (random + #54 slipped) to establish what the logbook altitude means (ASL/AGL, ft/m) -- [ ] Read logbook pages; record roll, page, stated altitude, units, reader note in a transcription CSV +- [x] `data-raw/height_calibrate-lower_tail_rolls.R` (loads source via `pkgload::load_all()`, reads the #54 cache + sweep; no refetch of what is cached) +- [x] Per-roll table: n, distinct heights, H in ft, roundness of H·k in ft for k ∈ {1, 2, 10, 10.764}, median/IQR of k +- [x] Spacing: implied forward overlap per roll under nominal, reported and each remedy, using only frames adjacent by frame number (same rule as `fly_bearing()`), reusing `fly_bearing()`'s adjacency logic rather than re-deriving it +- [x] Spacing **positive controls before reading the lower tail**: random in-band set (expect ~60% under reported), #54 slipped set after ÷10.764 (known answer), near_upper r≈2 mass (expect nominal ~60%, DEM route wrong); print them first and stop if the controls fail +- [x] Fetch `flight_log_url` for the 42 rolls via WFS (`bcdata::filter`, not constructed paths); cache JPGs under gitignored `data-raw/.cache/logbooks/`; include 3–4 control rolls (random + #54 slipped) to establish what the logbook altitude means (ASL/AGL, ft/m) +- [x] Read logbook pages; record roll, page, stated altitude, units, reader note in a transcription CSV ## Phase 2: Verdict per roll -- [ ] Classify each of the 42 rolls: factor supported by ≥2 independent instruments → correction row; otherwise excluded with reason (disagree / unreadable log / no adjacent frames / unexplained factor) -- [ ] Ship `inst/extdata/flying_height_rolls.csv` (film_roll, flying_height, factor, cause, evidence columns per instrument) and `flying_height_rolls_excluded.csv` (film_roll, flying_height, reason) -- [ ] Re-check against the full population: a roll+height key must match only the lower-tail frames it was measured on (count frames the key would touch in the cache vs frames measured) -- [ ] If no roll reaches two instruments: skip Phase 3, go to Phase 4 as measurement-only +- [x] Classify each of the 42 rolls: factor supported by ≥2 independent instruments → correction row; otherwise excluded with reason (disagree / unreadable log / no adjacent frames / unexplained factor) +- [x] Ship `inst/extdata/flying_height_rolls.csv` (film_roll, flying_height, factor, cause, evidence columns per instrument) and `flying_height_rolls_excluded.csv` (film_roll, flying_height, reason) +- [x] Re-check against the full population: a roll+height key must match only the lower-tail frames it was measured on (count frames the key would touch in the cache vs frames measured) +- [x] If no roll reaches two instruments: skip Phase 3, go to Phase 4 as measurement-only ## Phase 3: `fly_footprint()` consults the table (tests first) -- [ ] Failing tests in `tests/testthat/test-fly_footprint_height.R`: a fixture row on a tabled roll+height is sized from the corrected height with `height_source == "corrected_roll_table"`; same roll at a different height is untouched; an excluded roll stays `"implausible"`; the table-corrected frame is classified before the second DEM pass (assert on the grids, as #54's test does) -- [ ] Table constants read by internal `fly_height_roll_table()` in `R/fly_footprint.R`; applied where `slipped` is computed (~line 1082), only to frames still `disputed` and only where the corrected r lands in band -- [ ] Exclude the new class from `unusable` by name (CLAUDE.md gotcha: `unusable` is a residual) -- [ ] Warning reports table-corrected count once; roxygen for `height_source` documents the new value -- [ ] Test reads the shipped CSV and recomputes each row's factor from `flying_height_sweep.csv` so the table is checked against data, not trusted; restore-the-bug proof for each new assertion -- [ ] `centroid_shapes()` sweep covers the new column path (tibble / bcdc_sf) +- [x] Failing tests in `tests/testthat/test-fly_footprint_height.R`: a fixture row on a tabled roll+height is sized from the corrected height with `height_source == "corrected_roll_table"`; same roll at a different height is untouched; an excluded roll stays `"implausible"`; the table-corrected frame is classified before the second DEM pass (assert on the grids, as #54's test does) +- [x] Table constants read by internal `fly_height_roll_table()` in `R/fly_footprint.R`; applied where `slipped` is computed (~line 1082), only to frames still `disputed` and only where the corrected r lands in band +- [x] Exclude the new class from `unusable` by name (CLAUDE.md gotcha: `unusable` is a residual) +- [x] Warning reports table-corrected count once; roxygen for `height_source` documents the new value +- [x] Test reads the shipped CSV and recomputes each row's factor from `flying_height_sweep.csv` so the table is checked against data, not trusted; restore-the-bug proof for each new assertion +- [x] `centroid_shapes()` sweep covers the new column path (tibble / bcdc_sf) ## Phase 4: Record - [ ] `inst/notes/terrain-correction.md`: replace "three remedies the terrain cannot tell apart" with the per-roll finding, each instrument's control result, and its bound (spacing cannot split 10 from 10.764) From 2295715de9a1dbb80c2dd4ec908817420637cadd Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:41:58 -0700 Subject: [PATCH 3/7] Size tabled roll-heights from their measured height (#60) fly_footprint(dem = ) consults flying_height_rolls.csv before #54's repair, keyed on film_roll, flying_height, focal_length and scale with numbers formatted identically on both sides, and marks those frames height_source "corrected_roll_table". A height slip must still reconcile the frame's own ratio; a wrong-scale row cannot be asked that, but its height must clear the ground. The table reaches frames catalogued below the terrain, which is how two dropped digits arrive. The terrain note gains a fly#60 section and corrects its r ~ 2 claim, which holds for about half that mass (#72). The #54 decimal-point finding is recorded as #71, not changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- R/fly_footprint.R | 95 ++++++- inst/notes/terrain-correction.md | 108 ++++++-- man/fly_footprint.Rd | 8 + .../test-fly_footprint_height_rolls.R | 250 ++++++++++++++++++ .../testthat/test-fly_footprint_invariants.R | 3 +- vignettes/airphoto-selection.Rmd | 11 +- 6 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 tests/testthat/test-fly_footprint_height_rolls.R diff --git a/R/fly_footprint.R b/R/fly_footprint.R index 8cec4b9..ce4d19a 100644 --- a/R/fly_footprint.R +++ b/R/fly_footprint.R @@ -210,7 +210,9 @@ fly_height_slip_factor <- function() 3.28084^2 # frames land at 0.80-1.32 once repaired against 10.0-15.8 before. The UPPER edge sits in # the trough between that mass and the next one out, centred on 2.0, which is a 305 mm lens # catalogued as 153 — a frame the DEM route would draw at twice its true width, and the -# nominal route gets right because it never reads `focal_length`. The lower edge is that +# nominal route gets right because it never reads `focal_length`. For about half of that +# mass: fly#60's frame spacing shows 105 of its 209 sampled frames carry the right height +# and a wrong scale, which the nominal route draws at half width (fly#72). The lower edge is that # factor inverted, because the error is a ratio either way; it is set by symmetry and not # by a trough, since ordinary frames simply thin out below 0.8 with no second mass at 0.5. # See `inst/notes/terrain-correction.md`. @@ -226,6 +228,32 @@ fly_height_ratio_band <- function() c(1 / 1.6, 1.6) # a DEM window is ever built from the height in question. fly_flying_height_max <- function() 16000 +# Roll-heights whose `flying_height` disagreement was settled by measurement (fly#60). +# +# fly#54 left the lower tail — film frames reading under half the height their scale +# implies — refused, because pooled, x10.764, x10 and x2 each explained a comparable share. +# Per roll they do not: the lower tail is 42 rolls, nearly each carrying one height, and two +# instruments that read none of the disputed fields settle 22 roll-heights — the scanned +# logbooks at `flight_log_url`, whose TRUE HEIGHT column the catalogue transcribed, and the +# spacing between frames adjacent by number, which must imply the ~60% forward overlap the +# flight was designed to. `factor` names the defect: 10 or 100 where digits were dropped (a +# logbook "20.0", thousands of feet, catalogued as 2,000 ft), 1 where the logbook confirms +# the height and the SCALE is the wrong field. `height_m` is the height used — the logbook's, +# converted, since the catalogue's rounding survives multiplying by the factor. +# +# Keyed on roll, height, lens and scale together, so the table reaches only the frames it +# was measured on. Every lower-tail roll-height it does not correct is listed with its +# reason in `flying_height_rolls_excluded.csv`: an unlisted roll is unmeasured, not clean. +# Produced by `data-raw/height_calibrate-lower_tail_rolls.R`; see +# `inst/notes/terrain-correction.md`. +fly_height_roll_table <- function() { + path <- system.file("extdata/flying_height_rolls.csv", package = "fly") + if (!nzchar(path)) { + stop("`flying_height_rolls.csv` is missing from the installed package.", call. = FALSE) + } + utils::read.csv(path, colClasses = c(film_roll = "character", cause = "character")) +} + # The DEM-aligned grid a single footprint is counted against. # # Named and separate so the "one frame at a time" invariant can be asserted @@ -655,6 +683,14 @@ fly_is_square <- function(footprints) { #' `height_agl` is the height used. Your `flying_height` column is **not** #' overwritten, so on these rows `flying_height - height_agl` is not the ground #' elevation} +#' \item{`"corrected_roll_table"`}{they disagree, and the frame sits on one of +#' the roll-heights in `inst/extdata/flying_height_rolls.csv`, which the +#' province's flight logbooks and the spacing between adjacent frames settled +#' (fly#60): a height with one or two digits dropped (a logbook "20.0", +#' thousands of feet, catalogued as 2,000 ft), or a correct height beside a +#' wrong `scale`. Sized from the measured height; matched on `film_roll`, +#' `flying_height`, `focal_length` and `scale` together, so it needs a +#' `film_roll` column. As with the slip, `flying_height` is not overwritten} #' \item{`"implausible"`}{they disagree some other way, or `flying_height` is #' above 16,000 m or below the terrain. Nothing can say whether the height, the #' scale or the focal length is the wrong one — a 305 mm lens catalogued as @@ -1081,8 +1117,48 @@ fly_footprint <- function(centroids_sf, negative_size = 9, format_size = NULL, # `r_reported > 0`: terrain at or above the aircraft is already its own case below, # with its own warning, and stays there. disputed <- comparable & is.finite(r_reported) & r_reported > 0 & !in_band(r_reported) - slipped <- disputed & in_band(r_repaired) + + # A frame on a roll-height the logbooks and frame spacing have settled (fly#60). + # Consulted before #54's repair, since it names the cause for these frames rather than + # inferring one. A height slip must still reconcile THIS frame's ratio, as #54's does; + # a factor of 1 says the scale is the wrong field, so the ratio against that scale is + # exactly what cannot be asked of it — but that height must still clear the ground. + # + # Not only `disputed` frames: a height with two digits dropped sits BELOW the terrain + # (bc7280 is catalogued at 60 m), which `disputed` hands to the terrain-above-aircraft + # case, so the table would never reach the frames it was measured on. + out_of_band <- comparable & is.finite(r_reported) & !in_band(r_reported) + tab_factor <- rep(NA_real_, n) + tab_height <- rep(NA_real_, n) + if ("film_roll" %in% names(centroids_sf) && any(out_of_band)) { + tab <- fly_height_roll_table() + # Every number formatted the same way on both sides. `read.csv()` gives the table's + # columns as integers and the frame's arrive as doubles, and `paste()` writes those + # differently once they are round enough: 100000L is "100000", 100000 is "1e+05". + num <- function(x) formatC(as.numeric(x), format = "f", digits = 3) + tab_key <- paste(tab$film_roll, num(tab$flying_height), num(tab$focal_length), + num(tab$scale_n)) + frame_key <- paste(as.character(centroids_sf$film_roll), num(fh), + num(centroids_sf$focal_length), num(scale_num)) + hit <- match(frame_key, tab_key) + # `match()` pairs NA with NA, and `paste()` turns NA into the string "NA", so a frame + # missing any key column is kept off the table explicitly rather than by luck. + keyed <- !is.na(centroids_sf$film_roll) & is.finite(fh) & + is.finite(centroids_sf$focal_length) & is.finite(scale_num) + hit[!keyed | !out_of_band] <- NA_integer_ + tab_factor <- tab$factor[hit] + tab_height <- tab$height_m[hit] + } + # The logbook's height, converted — not the catalogue's times the factor, which carries + # the catalogue's rounding: bc7280's 20,000 ft is catalogued as 60 m, and x100 of that + # is 96 m short. + r_tabled <- (tab_height - first$elev) / nominal_agl + tabled <- out_of_band & is.finite(tab_factor) & + ((tab_factor == 1 & r_reported > 0) | (tab_factor > 1 & in_band(r_tabled))) + + slipped <- disputed & !tabled & in_band(r_repaired) fh_used <- fh + fh_used[tabled] <- tab_height[tabled] fh_used[slipped] <- fh[slipped] / fly_height_slip_factor() # The ceiling needs no terrain, so it applies whether or not the DEM covers the frame \u2014 # except to a frame that HAS a scale to be held against and could not be, because the @@ -1092,7 +1168,7 @@ fly_footprint <- function(centroids_sf, negative_size = 9, format_size = NULL, # findable as `no_dem_coverage`, which is the documented way to learn the DEM needs to # be bigger. untested <- comparable & !is.finite(first$elev) - implausible <- (disputed & !slipped) | + implausible <- (disputed & !slipped & !tabled) | (dem_eligible & !untested & is.finite(fh_used) & fh_used > fly_flying_height_max()) height_refused <- implausible @@ -1212,6 +1288,18 @@ fly_footprint <- function(centroids_sf, negative_size = 9, format_size = NULL, call. = FALSE ) } + roll_tabled <- corrected & tabled + if (any(roll_tabled)) { + warning( + sum(roll_tabled), " of ", sum(dem_eligible), " frames sit on a roll whose ", + "`flying_height` disagreement was settled by the flight logbooks and the spacing ", + "between frames (`flying_height_rolls.csv`): a height with dropped digits, or a ", + "correct height beside a wrong `scale`. Sized from the measured height; ", + "`flying_height` is left as supplied and `height_agl` is the height used. ", + "See `height_source`.", + call. = FALSE + ) + } if (any(implausible)) { warning( sum(implausible), " of ", sum(dem_eligible), " frames have a `flying_height` that ", @@ -1279,6 +1367,7 @@ fly_footprint <- function(centroids_sf, negative_size = 9, format_size = NULL, height_source[corrected] <- "reported" height_source[repaired] <- "corrected_unit_slip" + height_source[roll_tabled] <- "corrected_roll_table" height_source[implausible] <- "implausible" # Terrain at or above the aircraft is a height that was supplied and rejected, so it is # findable the same way. Missing or zero metadata is not: there was no height to doubt. diff --git a/inst/notes/terrain-correction.md b/inst/notes/terrain-correction.md index 276c6f5..d6a7716 100644 --- a/inst/notes/terrain-correction.md +++ b/inst/notes/terrain-correction.md @@ -209,8 +209,10 @@ plain feet instead leaves r at 3.02–4.71, and **0 of the 1,589** inside the ba So `fly_footprint(dem = )` compares the two after its first DEM pass. Inside `fly_height_ratio_band()` the height is used as reported. Outside it, dividing by `fly_height_slip_factor()` is tried, and where that lands inside the band the frame is sized -from the corrected height. Otherwise the frame falls back to nominal scale. `height_source` -records which — `"reported"`, `"corrected_unit_slip"`, `"implausible"` — and the caller's +from the corrected height. Otherwise the frame falls back to nominal scale — unless its roll +was settled by measurement, which fly#60 added and the section after this one describes. +`height_source` records which — `"reported"`, `"corrected_unit_slip"`, +`"corrected_roll_table"`, `"implausible"` — and the caller's `flying_height` column is never overwritten, so `flying_height - height_agl` is **not** the ground elevation on a corrected row. @@ -231,25 +233,22 @@ Four things here were measured and each is load-bearing: **r = 2, and of the 223 frames sampled beyond r 1.8 in that stratum, 209 are catalogued at 153 mm**: a 305 mm lens recorded as a 153. The DEM route draws those at twice their true width; the nominal route gets them right, because it never reads `focal_length`. - Falling back is therefore the correct answer there and not merely the cautious one. The + Falling back is therefore the correct answer there and not merely the cautious one. + **Only for about half of them** — fly#60 measured the spacing between adjacent frames, and + 105 of the 209 frames in that mass fit their *reported* height, with a scale recorded at + half its denominator, so the fallback draws those at half width (fly#72). 91 of the 105 are + on 1972–76 rolls, but the split is per roll, not by year. The lower edge is the same factor inverted, since the error is a ratio either way — set by symmetry, not by a trough: ordinary frames thin out steadily below 0.8 and there is no second mass at 0.5. - **The repair fires on nothing else.** Of 2,733 sampled frames outside the band and not slipped, dividing by 10.764 brings none inside it. -**The slip appears to run the other way as well, and that is deliberately not repaired.** -1,963 frames read under half their nominal height, 1,962 of them sampled as the lower tail -(the other was already in the random draw). Multiplying by 10.764 brings 726 of those into -the band — but multiplying by **10** brings 729 (a dropped digit in a height in feet: -609 m is 2,000 ft), and for a different set of rolls doubling brings 799 (a 153 mm lens -catalogued as 305 would do that, and 519 of them are catalogued at 305 — but 280 are at 153, -which that reading cannot explain). Nor does 10.764 sort the lower tail the way it sorts the -upper one: it scatters the 1,962 from −0.70 to 5.38, leaving 130 still below the band and -1,106 above it, where the forward repair puts all 1,589 at 0.80–1.32 with the nearest -unslipped frame at 6.69 and the nearest slipped one at 10.01. Three remedies the -terrain cannot tell apart, so none is applied and those frames come back `"implausible"` -at nominal scale. Do not "finish" this by picking one. +**The lower tail is not the slip run backwards, and half its frames were settled per roll by +fly#60** — see the next section. Pooled, ×10.764, ×10 and ×2 each brought a comparable +share of the 1,962 frames under half their nominal height into the band, and the terrain +could not choose. That was the wrong unit: the tail is 42 rolls, and each roll is one +measurement. **The ceiling, `fly_flying_height_max()`, is a backstop and not a discriminator.** A digital frame's `scale` is a nominal figure a third of its true image scale, so there is nothing to @@ -267,6 +266,85 @@ scale is the wrong one, falling back to nominal is the worse choice, and nothing know. That is why the `"implausible"` warning names no cause, and why the frames are flagged rather than silently resized. +## The lower tail, settled per roll (fly#60) + +The 1,962 film frames reading under half the height their scale implies sit on **42 rolls**, +and nearly every roll carries one `flying_height` — a planned altitude in round feet (609 m is +2,000 ft). So the tail is 77 roll-heights, not 1,962 frames, and two instruments that read +none of the three disputed fields settle 22 of them — 1,001 of the 1,962 frames. + +**Frame spacing.** Frames one number apart on a roll are an air base apart, and a flight is +designed to about 60% forward overlap, so the base against the along-track width a reading +implies says whether that reading is the one the aircraft flew. Checked on known answers +before anything was read from the tail: + +| control | implied overlap, median [10–90%] | +|---|---| +| random in-band frames, as reported | 0.63 [0.59–0.69], n 2,481 | +| #54 slipped frames, after ÷10.764 | 0.62 [0.58–0.66], n 1,589 | +| the same, as reported | 0.97 | + +The acceptance window is the random frames' own 2.5–97.5%, 0.557–0.780, applied to a +roll-height's median. **Spacing separates readings about 1.6 times apart and no closer**: +every reading whose ratio lands in the band implies nearly the nominal width, so it cannot +tell ×10 from ×10.764 (7.6%), and it cannot tell a doubled height from a halved lens. + +**The logbooks.** `flight_log_url` links scanned logbook pages whose TRUE HEIGHT column is +thousands of feet above sea level — the figure the catalogue transcribed. 120 pages were read +by hand into `data-raw/flying_height_logbooks.csv`, with the catalogue values of three control +rolls withheld from the reader. The two clean controls read back exactly (`bcc228` 12.650 and +14.0 against 3,856 and 4,267 m; the `bc7349/7350` page 20.0 against 6,096 m). The third was a +#54 slipped roll, and it said something #54 did not know — below. + +**The rule, fixed before any roll-height was classified:** the logbook covers at least half +its frames and at least 90% of those name one factor of 1, 10 or 100; no legible logbook +focal length contradicts the catalogue's; and spacing under the logbook's height is inside +the window. Two witnesses, independent of each other and of the fields in dispute. + +| verdict | roll-heights | frames | +|---|---|---| +| a digit dropped, ×10 (logbook "20.0" catalogued as 2,000 ft) | 10 | 511 | +| two digits dropped, ×100 (`bc7280`, 60 m catalogued, 20,000 ft logged) | 1 | 101 | +| **height right, scale wrong** — the logbook confirms the height, spacing accepts it | 11 | 389 | +| excluded, each with its reason in `flying_height_rolls_excluded.csv` | 55 | 961 | + +The third row is a remedy the issue did not list, and it inverts the fallback: for those +frames it is the **scale** that is wrong, so nominal scale draws them two to ten times too +wide, while the height #54 refused was right all along. The excluded reasons are 30 with no +logbook page covering the frames, 8 where spacing rejects the logbook's height, 5 where the +covering row's height or frame range was not read, 4 where the logbook's height is no named +multiple, 2 where covering rows disagree, 2 under half covered, and 4 where the logbook names a +different lens — `bc79086` an 85 mm, `bc80001`, `bcc162` and `bc81026` a 153 catalogued as +305 — for which nominal scale is already right. + +**×10.764 is refused wherever a logbook reads the lower tail**: the logged figure is exactly +ten times the catalogued one in round feet (`bc79122` 21,000; `bc7584` 20,000; `bc81013` +27,000; `bc77014` 28,000), where the inverse slip would give 22,604 and the like. And the +height shipped is the **logbook's**, converted, never the catalogue's times the factor: the +catalogue stores whole metres of a converted figure, so `bc7280`'s 20,000 ft is 60 m, and +×100 of that is 96 m short. + +`fly_footprint(dem = )` consults `inst/extdata/flying_height_rolls.csv` before #54's repair, +keyed on roll, height, lens and scale together — which reaches exactly the 1,001 measured +frames — and marks those frames `"corrected_roll_table"`. A height slip must still reconcile +the frame's own ratio, as #54's does. A scale-wrong row cannot be asked that, since the ratio +is against the wrong scale, but its height must clear the ground. The table also reaches a +frame whose catalogued height sits *below* the terrain, which is how a height missing two +digits arrives. **Only on the DEM route**: without `dem`, a scale-wrong frame is still sized +from its wrong scale. + +**What the controls said about #54.** Roll `bc78065`'s 1:2000 frames are catalogued at 4,115 m +(13,500 ft), and its logbook reads **1.35** — 1,350 ft. That is a decimal point, ×10, not +×10.764. Round feet agree across the planned-altitude rolls #54 repairs (under ÷10, `bc5596` +8,600, `bc78078` 3,100, `bc79103` 7,200, `bcc00085` 18,000–19,000; not under ÷10.764), while +the 2003 rolls, which carry measured per-frame heights, fit ÷10.764 better. Both factors put +all 1,589 frames in the band, which is why #54 could not see it. Recorded as fly#71, not +changed here. + +Reproduce with `data-raw/height_calibrate-lower_tail_rolls.R`, which prints every figure in +this section; `test-fly_footprint_height_rolls.R` holds the table to the sweep and the +logbook heights it was measured from. + ## What a partially covered footprint costs (fly#58) A footprint hanging off the edge of the DEM is sized from the mean of the part that is diff --git a/man/fly_footprint.Rd b/man/fly_footprint.Rd index b4f861b..f0361d4 100644 --- a/man/fly_footprint.Rd +++ b/man/fly_footprint.Rd @@ -220,6 +220,14 @@ do; sized from \code{flying_height} as supplied} \code{height_agl} is the height used. Your \code{flying_height} column is \strong{not} overwritten, so on these rows \code{flying_height - height_agl} is not the ground elevation} +\item{\code{"corrected_roll_table"}}{they disagree, and the frame sits on one of +the roll-heights in \code{inst/extdata/flying_height_rolls.csv}, which the +province's flight logbooks and the spacing between adjacent frames settled +(fly#60): a height with one or two digits dropped (a logbook "20.0", +thousands of feet, catalogued as 2,000 ft), or a correct height beside a +wrong \code{scale}. Sized from the measured height; matched on \code{film_roll}, +\code{flying_height}, \code{focal_length} and \code{scale} together, so it needs a +\code{film_roll} column. As with the slip, \code{flying_height} is not overwritten} \item{\code{"implausible"}}{they disagree some other way, or \code{flying_height} is above 16,000 m or below the terrain. Nothing can say whether the height, the scale or the focal length is the wrong one — a 305 mm lens catalogued as diff --git a/tests/testthat/test-fly_footprint_height_rolls.R b/tests/testthat/test-fly_footprint_height_rolls.R new file mode 100644 index 0000000..53b6423 --- /dev/null +++ b/tests/testthat/test-fly_footprint_height_rolls.R @@ -0,0 +1,250 @@ +# The per-roll table of measured `flying_height` corrections (fly#60). +# +# fly#54 left the lower tail — frames reading under half the height their scale implies — +# "implausible", because three remedies fit it equally when pooled. Per roll they do not: +# the scanned logbooks and the spacing between adjacent frames settle 22 roll-heights, +# shipped in `inst/extdata/flying_height_rolls.csv`. These tests hold `fly_footprint()` to +# that table and hold the table to the sweep it was measured from. +# +# Every fixture row is keyed to a REAL table row, over level ground, so each expected +# height is arithmetic. Keyed on the catalogue's roll, height, lens and scale together: a +# frame that shares only the roll is a different frame. + +roll_fixture <- function() { + sf::st_sf( + airp_id = 1:7, + film_roll = c("bc7584", "bc7717", "bc7280", "bc7717", "bc7584", "bc79086", "bc7584"), + scale = c("1:20000", "1:12500", "1:16000", "1:12500", "1:25000", "1:60000", "1:20000"), + media = "Film - BW", + focal_length = c(305, 305, 305, 305, 305, 305, 153), + flying_height = c(609, 1676, 60, 1700, 609, 6325, 609), + geometry = sf::st_sfc( + lapply(seq(-126.60, by = 0.02, length.out = 7), function(x) sf::st_point(c(x, 54.40))), + crs = 4326 + ) + ) +} + +roll_dem <- function(elev = 300) { + bb <- sf::st_bbox(sf::st_transform(roll_fixture(), 3005)) + r <- terra::rast(xmin = bb[["xmin"]] - 30000, xmax = bb[["xmax"]] + 30000, + ymin = bb[["ymin"]] - 30000, ymax = bb[["ymax"]] + 30000, + resolution = 100, crs = "EPSG:3005") + terra::values(r) <- elev + r +} + +roll_width <- function(fp) { + vapply(sf::st_geometry(sf::st_transform(fp, 3005)), function(g) { + if (sf::st_is_empty(g)) return(NA_real_) + diff(range(sf::st_coordinates(g)[, 1])) + }, numeric(1)) +} + + +test_that("the fixture is keyed to real table rows, and only where it says so", { + tab <- fly_height_roll_table() + key <- function(roll, h, f, s) paste(roll, h, f, s) + in_tab <- key(tab$film_roll, tab$flying_height, tab$focal_length, tab$scale_n) + rf <- roll_fixture() + scale_n <- as.numeric(sub("^1:", "", rf$scale)) + hit <- key(rf$film_roll, rf$flying_height, rf$focal_length, scale_n) %in% in_tab + # Rows 1-3 are table rows (x10, scale wrong, x100); 4-7 share a roll with one and differ + # in exactly one of height, scale or lens, or sit on a roll the table excludes. + expect_identical(hit, c(TRUE, TRUE, TRUE, FALSE, FALSE, FALSE, FALSE)) + rf_key <- key(rf$film_roll, rf$flying_height, rf$focal_length, scale_n) + expect_equal(tab$factor[match(rf_key[1:3], in_tab)], c(10, 1, 100)) + excl <- utils::read.csv(system.file("extdata/flying_height_rolls_excluded.csv", + package = "fly")) + expect_true("bc79086" %in% excl$film_roll) + # Every row is refused without the table: the premise the table exists to change. + band <- fly_height_ratio_band() + r <- (rf$flying_height - 300) / (scale_n * rf$focal_length / 1000) + expect_true(all(r < band[1])) + # Row 3 sits below the ground as catalogued, which is how a height missing two digits + # arrives; the rest are above it, so each reaches the ratio check and not the terrain one. + expect_identical(r < 0, c(FALSE, FALSE, TRUE, FALSE, FALSE, FALSE, FALSE)) +}) + + +test_that("a tabled frame is sized from the height the table measured", { + skip_if_no_terra() + rf <- roll_fixture() + fp <- suppressWarnings(fly_footprint(rf, dem = roll_dem())) + + expect_identical( + fp$height_source, + c("corrected_roll_table", "corrected_roll_table", "corrected_roll_table", + "implausible", "implausible", "implausible", "implausible") + ) + expect_identical(fp$footprint_terrain[1:3], rep("dem_agl", 3)) + # x10: 609 m catalogued is 2,000 ft where the logbook reads 20,000 — and the height used + # is the logbook's, 6,096 m, not the catalogue's rounding times ten. + expect_equal(fp$height_agl[1], 20000 * 0.3048 - 300) + # Factor 1: the height was right and the SCALE was wrong, so the reported height is used + # as it stands — and the frame is drawn at the width that height implies (1.0 km), not + # at the nominal-scale width the fallback would have drawn (2.9 km at 1:12500). + expect_equal(fp$height_agl[2], 5500 * 0.3048 - 300) + expect_equal(roll_width(fp)[2], 9 * 0.0254 * (5500 * 0.3048 - 300) / 0.305, + tolerance = 1e-3) + # x100 on bc7280, catalogued at 60 m: 6,096 m from the logbook, where 60 x 100 would have + # left it 96 m short. + expect_equal(fp$height_agl[3], 20000 * 0.3048 - 300) + # The caller's column is never overwritten. + expect_identical(fp$flying_height, rf$flying_height) + # The rest keep the nominal-scale footprint exactly as without the table. + flat <- fly_footprint(rf) + expect_equal(roll_width(fp)[4:7], roll_width(flat)[4:7]) +}) + + +test_that("a tabled factor that does not reconcile THIS frame is not applied", { + skip_if_no_terra() + # Over ground at 4,000 m, x10 leaves bc7584 at (6096 - 4000) / 6100 = 0.34 of its scale: + # the table names the slip, but for this frame the corrected height still disagrees, so + # it stays refused exactly as #54's repair does. + rf <- roll_fixture()[1, ] + fp <- suppressWarnings(fly_footprint(rf, dem = roll_dem(4000))) + expect_identical(fp$height_source, "implausible") + expect_true(is.na(fp$height_agl)) +}) + + +test_that("the table is reported once, and its frames are not counted as implausible", { + skip_if_no_terra() + w <- character() + withCallingHandlers( + fly_footprint(roll_fixture(), dem = roll_dem()), + warning = function(x) { + w <<- c(w, conditionMessage(x)) + invokeRestart("muffleWarning") + } + ) + tabled <- grep("flying_height_rolls.csv", w, value = TRUE, fixed = TRUE) + expect_length(tabled, 1) + expect_match(tabled, "^3 of ") + implausible <- grep("implausible", w, value = TRUE) + expect_length(implausible, 1) + expect_match(implausible, "^4 of ") + expect_false(any(grepl("missing, zero", w, fixed = TRUE))) +}) + + +test_that("without a film_roll column the table cannot be consulted", { + skip_if_no_terra() + rf <- roll_fixture() + rf$film_roll <- NULL + fp <- suppressWarnings(fly_footprint(rf, dem = roll_dem())) + expect_identical(fp$height_source, rep("implausible", 7)) +}) + + +test_that("the table is consulted for every class of input", { + skip_if_no_terra() + rf <- roll_fixture() + shapes <- list( + plain = rf, + tibble = sf::st_as_sf(tibble::as_tibble(rf)), + grouped = sf::st_as_sf(dplyr::group_by(tibble::as_tibble(rf), .data$film_roll)) + ) + expect_true(inherits(shapes$tibble, "tbl_df")) + expect_true(inherits(shapes$grouped, "grouped_df")) + out <- lapply(shapes, function(x) suppressWarnings(fly_footprint(x, dem = roll_dem()))) + for (nm in names(out)) { + expect_identical(out[[nm]]$height_source, out$plain$height_source, info = nm) + expect_equal(out[[nm]]$height_agl, out$plain$height_agl, info = nm) + } +}) + + +test_that("no window is built from a tabled frame's refused height", { + skip_if_no_terra() + # A tabled frame is classified after the first pass and BEFORE the second, like #54's + # slip: the second pass must sample the corrected rectangle, not the refused nominal one. + sizes <- c() + real_grid <- fly_dem_grid + testthat::local_mocked_bindings( + fly_dem_grid = function(dem, geom) { + g <- real_grid(dem, geom) + sizes <<- c(sizes, prod(dim(g)[1:2])) + g + } + ) + rf <- roll_fixture()[2, ] # scale wrong: nominal window 2.9 km, true 1.0 km + suppressWarnings(fly_footprint(rf, dem = roll_dem())) + expect_length(sizes, 2) + true_side <- 9 * 0.0254 * (5500 * 0.3048 - 300) / 0.305 / 100 + nominal_side <- 9 * 0.0254 * 12500 / 100 + expect_lt(sizes[2], (true_side + 3)^2) + expect_gt(sizes[1], 4 * sizes[2]) # premise: the first pass was the nominal one + expect_gt(nominal_side, 2.5 * true_side) +}) + + +test_that("the table holds against the sweep and the logbooks it was measured from", { + tab <- fly_height_roll_table() + excl <- utils::read.csv(system.file("extdata/flying_height_rolls_excluded.csv", + package = "fly")) + s <- utils::read.csv(system.file("extdata/flying_height_sweep.csv", package = "fly")) + lt <- s[s$set == "lower_tail", ] + + # Contract: the named slips, one cause each, and nothing else. + expect_setequal(unique(tab$factor), c(1, 10, 100)) + expect_identical(tab$cause, c(`1` = "scale_wrong", `10` = "height_digit_dropped", + `100` = "height_two_digits_dropped")[as.character(tab$factor)], + ignore_attr = TRUE) + expect_false(anyNA(tab)) + expect_false(anyNA(excl$reason) || any(!nzchar(excl$reason))) + + # Every lower-tail roll-height is either corrected or excluded with a reason — never both, + # never neither — and the frame counts reconcile to the census exactly. + key <- function(d) paste(d$film_roll, d$flying_height, d$focal_length, d$scale_n) + lt_keys <- table(key(lt)) + expect_length(intersect(key(tab), key(excl)), 0) + expect_setequal(c(key(tab), key(excl)), names(lt_keys)) + expect_identical(as.integer(lt_keys[key(tab)]), as.integer(tab$frames_measured)) + expect_identical(as.integer(lt_keys[key(excl)]), as.integer(excl$frames_measured)) + expect_identical(sum(tab$frames_measured) + sum(excl$frames_measured), nrow(lt)) + + # Each row's corrected ratio recomputed from the sweep, at the precision it is published. + for (i in seq_len(nrow(tab))) { + d <- lt[key(lt) == key(tab)[i], ] + r <- median((tab$height_m[i] - d$elev) / (d$scale_n * d$focal_length / 1000)) + expect_equal(round(r, 3), tab$r_corrected[i], tolerance = 1e-9, info = key(tab)[i]) + # A height-slip row must land in the band; a scale-wrong row by definition does not. + band <- fly_height_ratio_band() + if (tab$factor[i] == 1) { + expect_true(r < band[1], info = key(tab)[i]) + } else { + expect_true(r >= band[1] && r <= band[2], info = key(tab)[i]) + } + # The height used is the logbook's, converted; and it is the catalogue's times the factor + # to within the catalogue's rounding, which is what makes the factor the defect's name. + expect_equal(tab$height_m[i], round(tab$logbook_ft[i] * 0.3048, 1), info = key(tab)[i]) + expect_true(abs(tab$height_m[i] / (tab$flying_height[i] * tab$factor[i]) - 1) <= 0.02, + info = key(tab)[i]) + } +}) + + +test_that("the key matches however round the numbers are", { + skip_if_no_terra() + # `read.csv()` hands the table back as integers and a frame's values arrive as doubles; + # `paste()` writes 100000L as "100000" and 100000 as "1e+05". No shipped row is round + # enough to show it, so the table is replaced with one that is. + testthat::local_mocked_bindings( + fly_height_roll_table = function() data.frame( + film_roll = "bcx1", flying_height = 1000L, focal_length = 153L, scale_n = 100000L, + factor = 10L, height_m = 15240, stringsAsFactors = FALSE + ) + ) + rf <- roll_fixture()[1, ] + rf$film_roll <- "bcx1" + rf$scale <- "1:100000" + rf$focal_length <- 153 + rf$flying_height <- 1000 + expect_identical(paste(100000), "1e+05") # premise: the two spellings differ + fp <- suppressWarnings(fly_footprint(rf, dem = roll_dem())) + expect_identical(fp$height_source, "corrected_roll_table") + expect_equal(fp$height_agl, 15240 - 300) +}) diff --git a/tests/testthat/test-fly_footprint_invariants.R b/tests/testthat/test-fly_footprint_invariants.R index ec44730..67d8009 100644 --- a/tests/testthat/test-fly_footprint_invariants.R +++ b/tests/testthat/test-fly_footprint_invariants.R @@ -43,7 +43,8 @@ test_that("every frame gets a basis, and the reporting columns keep their types" # A height is named as the source of `height_agl` exactly where there is one (fly#54). # "implausible" is the other way round: it says why there is NOT one, and like # `width_source` it survives an empty geometry for that reason. - has_height <- fp$height_source %in% c("reported", "corrected_unit_slip") + has_height <- fp$height_source %in% + c("reported", "corrected_unit_slip", "corrected_roll_table") expect_identical(has_height, !is.na(fp$height_agl), info = nm) expect_identical(has_height, fp$footprint_terrain %in% "dem_agl", info = nm) # The invariant fly#37 violated: handed a POLYGON, fly_footprint() returned diff --git a/vignettes/airphoto-selection.Rmd b/vignettes/airphoto-selection.Rmd index 054d96c..f772100 100644 --- a/vignettes/airphoto-selection.Rmd +++ b/vignettes/airphoto-selection.Rmd @@ -407,10 +407,13 @@ times too large on 1,589 film frames from 13 rolls flown between 1974 and draw a 1:35000 frame 110 km across. A film frame states its height twice, once as `flying_height` and once as scale times focal length, so the two are compared. Where dividing by that factor brings them back into agreement the -corrected height is used and the frame is marked `"corrected_unit_slip"`; where they disagree some -other way nothing can say which is wrong, so the frame is sized from nominal -scale and marked `"implausible"`. Your `flying_height` column is never -overwritten. The bundled frames all agree with their own scale: +corrected height is used and the frame is marked `"corrected_unit_slip"`. On 22 +roll-heights the province's flight logbooks and the spacing between adjacent +frames settled the disagreement — dropped digits in the height, or a right height +beside a wrong scale — and those frames are sized from the measured height and +marked `"corrected_roll_table"`. Where they disagree any other way nothing can +say which is wrong, so the frame is sized from nominal scale and marked +`"implausible"`. Your `flying_height` column is never overwritten. The bundled frames all agree with their own scale: ```{r height-source, eval = requireNamespace("terra", quietly = TRUE)} table(terrain$height_source) From de5288be703cf4c559e59fd6c08c7f9a979e6ac3 Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:42:46 -0700 Subject: [PATCH 4/7] Record the fly#60 decision in CLAUDE.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- CLAUDE.md | 30 ++++++++++++++++++++++++++++++ planning/active/progress.md | 1 + planning/active/task_plan.md | 6 +++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8bf992..2a6e912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,12 @@ the note and NEWS quote. Ships `inst/extdata/flying_height_sweep.csv` and `flying_height_population.csv`, which `test-fly_footprint_height.R` reads so the three constants are checked against the data. Use a PSOCK cluster for remote DEM reads — `mclapply()` forks abort on GDAL's curl handles on macOS while the wrapper exits 0 +- `data-raw/height_calibrate-lower_tail_rolls.R` — settles the lower tail of `flying_height` +per roll (fly#60) with adjacent-frame spacing and the hand transcription of the province's +logbook scans in `data-raw/flying_height_logbooks.csv`, which is an input and is never +regenerated. Writes `inst/extdata/flying_height_rolls.csv` and +`flying_height_rolls_excluded.csv`; `test-fly_footprint_height_rolls.R` holds both to the +sweep - `height_fixture()` and `flat_dem()` in `tests/testthat/setup.R` — eight frames over level ground, each reaching one height check by a stated route; rows 7 and 8 exist because two deliberate defects survived the first six @@ -269,6 +275,30 @@ first DEM pass and **before** the second, and a camera-table frame over the ceil seeded — classifying after both passes returns the right footprint and still fetches 110 km of terrain per frame, so the test asserts on the grids. Read `inst/notes/terrain-correction.md` +- **The lower tail of `flying_height` is settled per roll, from the logbooks, and 389 of +the 1,001 frames it settled were never a height error** (v0.15.0, #60) — #54 left 1,962 film frames +under half their nominal height `"implausible"` because ×10.764, ×10 and ×2 fit equally +pooled. That was the wrong unit: they are 42 rolls, nearly each carrying one round-feet +height, so 77 roll-heights. Two witnesses that read none of the disputed fields — spacing +between frames adjacent by number against the ~60% designed overlap, and the TRUE HEIGHT +column of the logbook scans at `flight_log_url` — settle 22 roll-heights (1,001 frames): +10 dropped a digit, 1 dropped two, and **11 carry the right height beside a wrong +`scale`**, where #54's fallback to nominal was the defect. `fly_footprint()` reads +`flying_height_rolls.csv` and marks them `"corrected_roll_table"`. + + **Four things are load-bearing.** The shipped height is the **logbook's**, converted, +never the catalogue's times the factor — `bc7280`'s 20,000 ft is catalogued as 60 m and +×100 of that is 96 m short. The key is roll, height, lens and scale with numbers +**formatted identically** on both sides, since `paste()` spells 100000L and 100000 +differently. The table reaches frames catalogued **below the terrain**, which is how a +height missing two digits arrives, so it is gated on the band and not on `disputed`. And +every excluded roll-height carries the reason that actually fired — no page, rows that +disagree, a row not read are three states, and an earlier draft folded them into one. +Spacing separates readings ~1.6× apart and no closer, so it cannot tell ×10 from ×10.764; +the logbook can. The control roll `bc78065` suggests #54's own factor is ×10 on pre-2000 +rolls (fly#71), and half the r ≈ 2 mass is a wrong scale, not a mislabelled lens (fly#72). +Do not change `fly_height_slip_factor()` from this; read `inst/notes/terrain-correction.md` + ## Gotchas - `.lintr` must be single-line DCF format — multi-line breaks newer lintr versions diff --git a/planning/active/progress.md b/planning/active/progress.md index 1f02c5c..ba7da4f 100644 --- a/planning/active/progress.md +++ b/planning/active/progress.md @@ -15,3 +15,4 @@ - /code-check: 3 rounds, 8 findings fixed; ended by enumerating all 77 labels against an independent predicate (77/77) - Filed follow-ups #71 (#54's /10.764 looks like /10 on pre-2000 rolls) and #72 (half the r~2 mass is a wrong scale) - Suite: 2,279 passed, 0 failed +- Phase 4: note section, CLAUDE.md Key Decision + Architecture, #60 body outcome; follow-ups #71 #72 filed diff --git a/planning/active/task_plan.md b/planning/active/task_plan.md index b34c80b..c173ea3 100644 --- a/planning/active/task_plan.md +++ b/planning/active/task_plan.md @@ -72,9 +72,9 @@ Three instruments, each independent of the fields in dispute, all public: - [x] `centroid_shapes()` sweep covers the new column path (tibble / bcdc_sf) ## Phase 4: Record -- [ ] `inst/notes/terrain-correction.md`: replace "three remedies the terrain cannot tell apart" with the per-roll finding, each instrument's control result, and its bound (spacing cannot split 10 from 10.764) -- [ ] CLAUDE.md Key Decisions entry; Architecture line for the new script/CSVs -- [ ] Edit #60 body with the per-roll finding (not a comment); file follow-ups for unexplained rolls and for the near_upper 305-as-153 mass if not handled +- [x] `inst/notes/terrain-correction.md`: replace "three remedies the terrain cannot tell apart" with the per-roll finding, each instrument's control result, and its bound (spacing cannot split 10 from 10.764) +- [x] CLAUDE.md Key Decisions entry; Architecture line for the new script/CSVs +- [x] Edit #60 body with the per-roll finding (not a comment); file follow-ups for unexplained rolls and for the near_upper 305-as-153 mass if not handled - [ ] NEWS.md + version bump as the final commit ## Validation From 69c1fc73cbd3d9b749b9ad1c85eb5bf3b7ceb649 Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:43:38 -0700 Subject: [PATCH 5/7] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- DESCRIPTION | 2 +- NEWS.md | 8 ++++++++ tests/testthat/test-fly_footprint_height_rolls.R | 10 ++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index c3a28bd..8b52da9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: fly Title: Historic Airphoto Footprints, Selection and Georeferencing for British Columbia -Version: 0.14.1 +Version: 0.15.0 Date: 2026-09-21 Authors@R: c( person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 5eb3b8d..144e946 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # fly (development version) +## 0.15.0 (2026-09-26) + +- **Half of the lower tail of `flying_height` is now settled per roll, and 389 of those 1,001 frames were never a height error** ([#60](https://github.com/NewGraphEnvironment/fly/issues/60)). #54 left 1,962 film frames reading under half the height their scale implies as `"implausible"`, because ×10.764, ×10 and ×2 each explained a comparable share when pooled. Per roll they do not: the tail is 42 rolls, nearly each carrying one height, so it is 77 roll-heights rather than 1,962 frames. Two instruments that read none of the three disputed fields settle 22 of them (1,001 frames): the province's **scanned flight logbooks** at `flight_log_url`, whose TRUE HEIGHT column the catalogue transcribed, and the **spacing between frames adjacent by number**, which must imply the ~60% forward overlap a flight is designed to +- New `height_source` value **`"corrected_roll_table"`**, read from the new `inst/extdata/flying_height_rolls.csv` and keyed on `film_roll`, `flying_height`, `focal_length` and `scale` together, which reaches exactly the 1,001 measured frames. Ten roll-heights (511 frames) dropped a digit, a logbook "20.0" in thousands of feet catalogued as 2,000 ft; one (`bc7280`, 101 frames) dropped two, 60 m catalogued where 20,000 ft was flown. **Eleven (389 frames) carry the right height beside a wrong `scale`**, which is a remedy the issue did not list: for those the fallback #54 chose was the defect, drawing them two to ten times too wide. The height used is always the logbook's, converted, never the catalogue's times the factor, since the catalogue's rounding survives the multiplication and left `bc7280` 96 m short. Needs a `film_roll` column and a `dem`; without a DEM a wrong-scale frame is still sized from its scale +- **Every lower-tail roll-height the table does not correct is listed with its reason** in `flying_height_rolls_excluded.csv` (55 roll-heights, 961 frames — 30 with no logbook page, 8 where spacing rejects the logbook's height, 5 where the covering row was not read, 4 no named multiple, 4 where the logbook names a different lens and nominal scale is already right, 2 where covering rows disagree, 2 under half covered), so an unlisted roll reads as unmeasured rather than clean. The test suite recomputes every row from the shipped sweep and asserts the two files partition the census exactly; every one of the 77 labels was re-derived from the per-frame logbook states by an independent predicate, and all agree +- **Both instruments were held to known answers first.** Spacing reads 0.63 on random in-band frames and 0.62 on #54's slipped frames after repair, against 0.97 before. The logbooks were transcribed with three control rolls' catalogue values withheld, and the two clean ones read back to the metre. The rule — logbook covers half the frames and 90% agree on a factor of 1, 10 or 100; no legible logbook lens contradicts the catalogue; spacing inside the random frames' own 2.5–97.5% — was fixed before any roll was classified. `data-raw/height_calibrate-lower_tail_rolls.R` reproduces all of it, and `data-raw/flying_height_logbooks.csv` is the transcription +- **×10.764 is refused wherever a logbook reads the lower tail**, and the third control suggests it is not the whole story on the upper side either: #54's slipped roll `bc78065` logs 1,350 ft where the catalogue holds 13,500, a decimal point rather than a unit conversion, and the planned-altitude rolls #54 repairs land on round feet under ÷10 but not ÷10.764. Filed as [#71](https://github.com/NewGraphEnvironment/fly/issues/71), not changed here. And the note's claim that the mass beyond r ≈ 2 is a mislabelled lens, so nominal scale is right there, holds for about half of it: spacing puts 105 of its 209 sampled frames, identified per roll, at a wrong scale that the fallback draws at half width — [#72](https://github.com/NewGraphEnvironment/fly/issues/72) + ## 0.14.1 (2026-09-21) - **`R CMD check` now runs in CI, on three platforms** ([#52](https://github.com/NewGraphEnvironment/fly/issues/52)). `.github/workflows/` held only `pkgdown.yaml`, so a green PR check meant the docs site built — not that the package checks, and not that the test suite runs anywhere but the author's machine. The new workflow runs on **ubuntu, macOS and Windows at R release**, on every push to `main` and every PR, gating at the r-lib action's default `error-on: "warning"`. No exported behaviour changes diff --git a/tests/testthat/test-fly_footprint_height_rolls.R b/tests/testthat/test-fly_footprint_height_rolls.R index 53b6423..30fdf28 100644 --- a/tests/testthat/test-fly_footprint_height_rolls.R +++ b/tests/testthat/test-fly_footprint_height_rolls.R @@ -233,10 +233,12 @@ test_that("the key matches however round the numbers are", { # `paste()` writes 100000L as "100000" and 100000 as "1e+05". No shipped row is round # enough to show it, so the table is replaced with one that is. testthat::local_mocked_bindings( - fly_height_roll_table = function() data.frame( - film_roll = "bcx1", flying_height = 1000L, focal_length = 153L, scale_n = 100000L, - factor = 10L, height_m = 15240, stringsAsFactors = FALSE - ) + fly_height_roll_table = function() { + data.frame( + film_roll = "bcx1", flying_height = 1000L, focal_length = 153L, scale_n = 100000L, + factor = 10L, height_m = 15240, stringsAsFactors = FALSE + ) + } ) rf <- roll_fixture()[1, ] rf$film_roll <- "bcx1" From 364baf267e03a138df407b09359643098fcb7ad0 Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:44:02 -0700 Subject: [PATCH 6/7] Archive planning files for issue #60 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- .../README.md | 55 +++++++++++++++++++ .../findings.md | 0 .../progress.md | 0 .../review-round1.md | 0 .../review-round2.md | 0 .../review-round3.md | 0 .../task_plan.md | 10 ++-- 7 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 planning/archive/2026-09-issue-60-lower-tail-flying-height/README.md rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/findings.md (100%) rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/progress.md (100%) rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/review-round1.md (100%) rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/review-round2.md (100%) rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/review-round3.md (100%) rename planning/{active => archive/2026-09-issue-60-lower-tail-flying-height}/task_plan.md (96%) diff --git a/planning/archive/2026-09-issue-60-lower-tail-flying-height/README.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/README.md new file mode 100644 index 0000000..c9aefb0 --- /dev/null +++ b/planning/archive/2026-09-issue-60-lower-tail-flying-height/README.md @@ -0,0 +1,55 @@ +## Outcome + +fly#54 left 1,962 film frames reading under half the height their scale implies as +`"implausible"`, because ×10.764, ×10 and ×2 each explained a comparable share when pooled. +Plan-mode exploration found that was the wrong unit. The frames sit on 42 rolls, nearly each +carrying one round-feet `flying_height`, so the tail is 77 roll-heights. + +Two instruments independent of the disputed fields settled 22 of them (1,001 frames). One was +the spacing between frames adjacent by number, measured against the ~60% designed overlap. The +other was the province's flight logbook scans, 120 pages transcribed by three parallel readers +with the catalogue values of three control rolls withheld. The corrections ship as +`inst/extdata/flying_height_rolls.csv`, which `fly_footprint(dem = )` reads, marking those +frames `"corrected_roll_table"`; the other 55 roll-heights ship with reasons in +`flying_height_rolls_excluded.csv`. + +The largest surprise was a remedy the issue did not list. On 11 roll-heights the height is +right and the **scale** is wrong, so #54's fallback to nominal was itself the defect. The +controls also exposed two findings outside the issue, both filed rather than acted on: #71 +(#54's own factor looks like ×10 on pre-2000 rolls) and #72 (half the r ≈ 2 mass is a wrong +scale, not a mislabelled lens). + +## Measurement + +- **Spacing controls.** Random in-band frames read 0.63 [0.59–0.69] (n 2,481). #54's slipped + frames read 0.62 after ÷10.764 and 0.97 before. The acceptance window, the random frames' + 2.5–97.5%, is 0.557–0.780. Spacing separates readings ~1.6× apart and no closer. +- **Logbook controls.** `bcc228` reads 12.650 / 14.0 against 3,856 / 4,267 m, and the + `bc7349/7350` page reads 20.0 against 6,096 m, both exact. `bc78065` (#54-slipped) reads + 1.35, i.e. 1,350 ft, where the catalogue has 13,500 ft: a decimal point, not ×10.764. +- **Verdict.** + - ×10: 10 roll-heights, 511 frames. + - ×100: 1, 101 frames. + - Height right, scale wrong: 11, 389 frames. Nominal scale drew these 2.05–10.4× too wide. + - Excluded: 55, 961 frames — 30 no page, 8 spacing rejects, 5 not read, 4 no named + multiple, 4 different lens, 2 rows disagree, 2 under half covered. + - The four-field key reaches exactly the 1,001 measured frames. +- **Wrong turns, kept.** The script first treated the note's r≈2 claim as a control, and it + failed as one (nominal median 0.50 [0.13–0.69]). Split per roll it became #72. +- **Code-check.** + - Round 1: shipping catalogue × factor left `bc7280` 96 m short; now ships the logbook + height. + - Round 2: `paste()` spelt 100000L and 100000 differently in the key. + - Round 3 found a defect inside round 1's fix: three logbook states were folded into + "no page". This moved the exclusion counts from 36/3 to 30/5/2/2. + - Ended by enumerating all 77 labels against an independent predicate: 77/77 agree. +- **Suite:** 2,279 passed, 0 failed. Seven restored defects each turned a test red. + +## Evidence + +`data-raw/height_calibrate-lower_tail_rolls.R` prints every figure above from the gitignored +`data-raw/.cache/centroids/*` and `data-raw/.cache/logbooks/*`. The transcription is +`data-raw/flying_height_logbooks.csv`, and the review rounds are `review-round*.md` in this +directory. + +Closed by: PR for branch `60-lower-tail-flying-height` (v0.15.0) diff --git a/planning/active/findings.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/findings.md similarity index 100% rename from planning/active/findings.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/findings.md diff --git a/planning/active/progress.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/progress.md similarity index 100% rename from planning/active/progress.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/progress.md diff --git a/planning/active/review-round1.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round1.md similarity index 100% rename from planning/active/review-round1.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round1.md diff --git a/planning/active/review-round2.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round2.md similarity index 100% rename from planning/active/review-round2.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round2.md diff --git a/planning/active/review-round3.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round3.md similarity index 100% rename from planning/active/review-round3.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/review-round3.md diff --git a/planning/active/task_plan.md b/planning/archive/2026-09-issue-60-lower-tail-flying-height/task_plan.md similarity index 96% rename from planning/active/task_plan.md rename to planning/archive/2026-09-issue-60-lower-tail-flying-height/task_plan.md index c173ea3..12521b0 100644 --- a/planning/active/task_plan.md +++ b/planning/archive/2026-09-issue-60-lower-tail-flying-height/task_plan.md @@ -75,14 +75,14 @@ Three instruments, each independent of the fields in dispute, all public: - [x] `inst/notes/terrain-correction.md`: replace "three remedies the terrain cannot tell apart" with the per-roll finding, each instrument's control result, and its bound (spacing cannot split 10 from 10.764) - [x] CLAUDE.md Key Decisions entry; Architecture line for the new script/CSVs - [x] Edit #60 body with the per-roll finding (not a comment); file follow-ups for unexplained rolls and for the near_upper 305-as-153 mass if not handled -- [ ] NEWS.md + version bump as the final commit +- [x] NEWS.md + version bump as the final commit ## Validation -- [ ] Tests pass (`devtools::test()`; `NOT_CRAN=true` for single-file reruns) -- [ ] `/code-check` clean on each commit -- [ ] PWF checkboxes match landed work -- [ ] `/planning-archive` on completion +- [x] Tests pass (`devtools::test()`; `NOT_CRAN=true` for single-file reruns) +- [x] `/code-check` clean on each commit +- [x] PWF checkboxes match landed work +- [x] `/planning-archive` on completion ## Critical files `R/fly_footprint.R` (lines ~200–230 constants, ~1060–1130 classification, ~1282 height_source), From 86c4c264b2b6fbd200179beaef6dddcd3ad1f05c Mon Sep 17 00:00:00 2001 From: almac2022 Date: Sat, 26 Sep 2026 15:53:00 -0700 Subject: [PATCH 7/7] Take as_tibble from dplyr, not the undeclared tibble R CMD check on all three runners: "::" import not declared from tibble. dplyr is in Imports and re-exports it. Checked locally with tools:::.check_packages_used_in_tests(), which reports tibble with the old line restored and nothing with the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115csb82i78r86GWFzwPzis --- tests/testthat/test-fly_footprint_height_rolls.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-fly_footprint_height_rolls.R b/tests/testthat/test-fly_footprint_height_rolls.R index 30fdf28..1eb19bb 100644 --- a/tests/testthat/test-fly_footprint_height_rolls.R +++ b/tests/testthat/test-fly_footprint_height_rolls.R @@ -144,8 +144,8 @@ test_that("the table is consulted for every class of input", { rf <- roll_fixture() shapes <- list( plain = rf, - tibble = sf::st_as_sf(tibble::as_tibble(rf)), - grouped = sf::st_as_sf(dplyr::group_by(tibble::as_tibble(rf), .data$film_roll)) + tibble = sf::st_as_sf(dplyr::as_tibble(rf)), + grouped = sf::st_as_sf(dplyr::group_by(dplyr::as_tibble(rf), .data$film_roll)) ) expect_true(inherits(shapes$tibble, "tbl_df")) expect_true(inherits(shapes$grouped, "grouped_df"))