diff --git a/CLAUDE.md b/CLAUDE.md index d7fc9cd..3971e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,10 @@ Experimental package — breaking all the time and loving the learning curve. St **Prefix:** `lnk_` **Branch:** `main` (v0.44.0 as of 2026-07-03) +## Status (2026-07-04) — v0.44.1 shipped (#226 vignette accessible_km) + +Extended the PARS vignette with an **Accessible habitat (km)** section proving `accessible_km` bcfp-equivalence (link 6,822.5 vs bcfp 6,822.9 km BT, **−0.01%**; table from cached `inst/vignette-data/pars_accessible.rds`). **Gotcha that bit hard:** "regenerate the vignette artifacts" was NOT docs-only — the two persist configs drift in segmentation because only WSGs re-modelled post-#223 are dense. `fresh` (bcfp config) had PARS at 97,538 segs but `fresh_default` (default/grayling) was still pre-#223 (48,558); the gpkg's single `streams` layer joins `fresh` geometry to `fresh_default` `mapping_code_gr` on `id_segment`, so a naive regen attaches grayling tokens to mismatched geometry → corrupt GR map. Fix: re-model the lagging config (`data-raw/wsg_run_one.R` + `merge=TRUE` recompute via `wsg_recompute_one.R` for cross-WSG `;DAM`) so both share segmentation; `wsg_vignette_data.R` now carries a **segmentation-parity guard** that refuses a mixed build. Any cross-config artifact joined on `id_segment` must verify both sides share segmentation first. mapping_code parity refreshed 99.04%→98.91% (denser post-#223). Open follow-ups unchanged: **#224**, **#225**, **#227**. + ## Status (2026-07-03) — v0.44.0 shipped (#221 + #223 accessible_km) Fixed the BT/ST `accessible_km` over-credit: streams now break at **every** gradient frontier (`lnk_pipeline_prepare.R` unions the raw per-model positions into `gradient_barriers_minimal`, not the `frs_barriers_minimal` reduction) — matching bcfp. Added the `accessible_km` roll-up column + `lnk_rollup_wsg()` (#221). Proven across 11 WSGs × 8 species: `accessible_km` 44/44 within 0.05%, habitat holds (parked BULK SK = fresh#190). Validator/proof: `data-raw/parity_crosssection.R` + `research/parity_accessible_habitat_2026_07_03.md`. **Gotcha that bit hard:** `fresh.streams_vw_bcfp` spawning/rearing/access_ are coded 0/1/2/3 → parity uses `IN (1,2)` (a `= 1` under-counts; see memory `bcfp-vw-column-coding`). Segment count now 2–3.5× (bcfp-matching; intersects #205). Open follow-ups: **#225** (rename `gradient_barriers_minimal` → `gradient_barriers_break`), **#226** (vignette accessible_km demo), **#227** (`wsg_outlet` builder + single-WSG guard; relates to #222), **#224** (bcfp `dam_dnstr_ind` reservoir-inflow quirk — reference-side, not ours). diff --git a/DESCRIPTION b/DESCRIPTION index 4ab6949..7dd6a40 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: link Title: Stream Network Habitat Interpretation (Experimental) -Version: 0.44.0 -Date: 2026-07-03 +Version: 0.44.1 +Date: 2026-07-04 Authors@R: c( person("Allan", "Irvine", , "airvine@newgraphenvironment.com", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 0f37107..a6189b1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +# link 0.44.1 + +Extend the PARS vignette to demonstrate `accessible_km` bcfishpass-equivalence ([#226](https://github.com/NewGraphEnvironment/link/issues/226)). `vignettes/pars-habitat-connectivity.Rmd` gains an **Accessible habitat (km)** section: a link-vs-bcfishpass roll-up table (bull-trout accessible / spawning / rearing, from the new cached `pars_accessible.rds`) plus computed prose — link models 6,822.5 km of accessible bull-trout habitat against bcfishpass's 6,822.9 km, a −0.01% difference (the [#223](https://github.com/NewGraphEnvironment/link/issues/223) access-segmentation fix; spawning/rearing agree within the 5% habitat band). `data-raw/wsg_vignette_data.R` grows the accessible roll-up (mirroring `data-raw/parity_crosssection.R`'s `lnk_rollup_wsg()` + `fresh.streams_vw_bcfp` `IN (1,2)` computation) and a **segmentation-parity guard** that refuses to build when `fresh` and `fresh_default` disagree by more than 1%. All cached artifacts were regenerated from the post-#223 state: this required re-modelling the `default` (Arctic grayling) config for `PARS` into `fresh_default`, which had been left on the pre-#223 segmentation (48,558 segments vs `fresh`'s 97,538) — a naive regeneration would have joined the grayling `mapping_code` onto mismatched geometry and corrupted the map. The re-model (`data-raw/wsg_run_one.R` + a `merge = TRUE` recompute for cross-WSG `;DAM`) brings both configs onto the same segmentation; the previously hardcoded grayling map-caption counts are now computed inline from the loaded layer so they track the artifact. Bull-trout `mapping_code` parity refreshes to 98.91% (from 99.04%, on the denser post-#223 segmentation). Verified with a database-free knit. + # link 0.44.0 Fix a BT/ST `accessible_km` over-credit and add the per-WSG `accessible_km` roll-up ([#223](https://github.com/NewGraphEnvironment/link/issues/223), [#221](https://github.com/NewGraphEnvironment/link/issues/221)). The stream break source `gradient_barriers_minimal` was fed the `fresh::frs_barriers_minimal()` downstream-most reduction as a *segmentation* source, so a single stream segment straddled the accessibility frontier and its whole reach — including the blocked reach above the barrier — was credited accessible (`accessible_km` +40.4% PCEA, +23.6% FINA). The fix (`R/lnk_pipeline_prepare.R`) unions the **raw** per-model gradient + falls positions into `gradient_barriers_minimal` so streams break at every frontier — mirroring the orphan path already in the code and matching bcfishpass, which segments at every gradient barrier; `fresh::frs_barriers_minimal()` is now unused in link. `lnk_compare_rollup()` gains an `accessible_km` column and a reusable `lnk_rollup_wsg()` (tunnel-free vs `fresh.streams_vw_bcfp`, `access_ IN (1,2)`). Proven with `data-raw/parity_crosssection.R` across 11 WSGs × 8 species (Peace / Fraser / Skeena / Columbia): `accessible_km` converges 44/44 within 0.05%, spawning/rearing hold — the only two over-tolerance being the documented parked BULK SK ([fresh#190](https://github.com/NewGraphEnvironment/fresh/issues/190) dual-rearing-lake topology). The fix is habitat-neutral (byte-identical spawn/rear pre/post); segment count grows 2–3.5× (inherent to bcfp-matching, intersects #205). Follow-ups: #224 (bcfp `dam_dnstr_ind` reservoir-inflow propagation), #225 (rename `gradient_barriers_minimal` → `gradient_barriers_break`), #226 (PARS vignette `accessible_km` demo), #227 (`public.wsg_outlet` builder + single-WSG guard). diff --git a/data-raw/wsg_vignette_data.R b/data-raw/wsg_vignette_data.R index a3e7a4e..222ba4b 100644 --- a/data-raw/wsg_vignette_data.R +++ b/data-raw/wsg_vignette_data.R @@ -81,12 +81,62 @@ if (!persisted("fresh") || !persisted("fresh_default")) { } message("[wsg_vignette_data] authoritative ", aoi, " state present in fresh + fresh_default") +# --- segmentation-parity guard (link#226) ------------------------------------ +# The gpkg's streams layer joins `fresh` geometry + mapping_code_bt to +# `fresh_default` mapping_code_gr on id_segment, so BOTH configs must sit on the +# SAME post-#223 segmentation. If one lags (e.g. fresh_default not re-modelled +# after the #223 access-segmentation fix), the GR token lands on the wrong +# geometry — refuse to build rather than ship a corrupt grayling map. +seg_count <- function(schema) DBI::dbGetQuery(conn, sprintf( + "SELECT count(*)::int n FROM %s.streams WHERE watershed_group_code = %s", + schema, DBI::dbQuoteLiteral(conn, aoi)))$n +.n_fresh <- seg_count("fresh") +.n_fdef <- seg_count("fresh_default") +if (abs(.n_fresh - .n_fdef) > 0.01 * max(.n_fresh, .n_fdef)) { + stop(sprintf( + paste0("%s segmentation mismatch: fresh %d vs fresh_default %d segments (>1%%). ", + "Re-model the lagging config post-#223 before generating vignette data ", + "(link#226) — the gpkg joins fresh geometry to fresh_default ", + "mapping_code_gr on id_segment."), + aoi, .n_fresh, .n_fdef), call. = FALSE) +} +message(sprintf("[wsg_vignette_data] segmentation parity OK: fresh %d / fresh_default %d segments", + .n_fresh, .n_fdef)) + # --- tunnel-free parity (BT is the only bcfp-config species in the Peace) ----- parity <- lnk_compare_mapping_code(conn, aoi = aoi, cfg = cfg_bcfp) saveRDS(parity, file.path(out_dir, paste0(stub, "_parity.rds"))) message("[wsg_vignette_data] parity rows: ", nrow(parity), " (median match_pct = ", stats::median(parity$match_pct, na.rm = TRUE), ")") +# --- accessible / spawning / rearing km parity (BT, tunnel-free; link#226) ---- +# link side = the #221 roll-up; bcfp side = the fresh.streams_vw_bcfp reference +# with the IN (1,2) predicate (bcfp codes access/spawn/rear 0/1/2/3, so a bare +# `= 1` under-counts). Same computation as the #223 proof +# (data-raw/parity_crosssection.R). accessible_km is the #223 target and matches +# exactly; spawning/rearing agree within habitat-methodology tolerance. +metrics_km <- c("accessible_km", "spawning_km", "rearing_km") +acc_link <- lnk_rollup_wsg(conn, aoi = aoi, species = "BT", schema = "fresh") +acc_bcfp <- DBI::dbGetQuery(conn, sprintf( + "SELECT + round((coalesce(sum(length_metre) FILTER (WHERE access_bt IN (1,2)),0)/1000)::numeric,2) AS accessible_km, + round((coalesce(sum(length_metre) FILTER (WHERE spawning_bt IN (1,2)),0)/1000)::numeric,2) AS spawning_km, + round((coalesce(sum(length_metre) FILTER (WHERE rearing_bt IN (1,2)),0)/1000)::numeric,2) AS rearing_km + FROM fresh.streams_vw_bcfp WHERE watershed_group_code = %s", + DBI::dbQuoteLiteral(conn, aoi))) +.link_v <- as.numeric(unlist(acc_link[1, metrics_km])) +.bcfp_v <- as.numeric(unlist(acc_bcfp[1, metrics_km])) +accessible <- data.frame( + metric = c("accessible", "spawning", "rearing"), + link_km = .link_v, + bcfp_km = .bcfp_v, + diff_pct = ifelse(.bcfp_v == 0, NA_real_, round(100 * (.link_v - .bcfp_v) / .bcfp_v, 2)), + stringsAsFactors = FALSE) +saveRDS(accessible, file.path(out_dir, paste0(stub, "_accessible.rds"))) +message("[wsg_vignette_data] accessible km (link|bcfp|diff%): ", + paste(sprintf("%s %.1f|%.1f|%+.2f", accessible$metric, accessible$link_km, + accessible$bcfp_km, accessible$diff_pct), collapse = " ")) + # --- spatial layers ----------------------------------------------------------- aoi_lit <- DBI::dbQuoteLiteral(conn, aoi) diff --git a/inst/vignette-data/pars.gpkg b/inst/vignette-data/pars.gpkg index 98b2ea0..7f32c07 100644 Binary files a/inst/vignette-data/pars.gpkg and b/inst/vignette-data/pars.gpkg differ diff --git a/inst/vignette-data/pars_accessible.rds b/inst/vignette-data/pars_accessible.rds new file mode 100644 index 0000000..033cf1e Binary files /dev/null and b/inst/vignette-data/pars_accessible.rds differ diff --git a/inst/vignette-data/pars_parity.rds b/inst/vignette-data/pars_parity.rds index 8c04367..62794df 100644 Binary files a/inst/vignette-data/pars_parity.rds and b/inst/vignette-data/pars_parity.rds differ diff --git a/planning/archive/2026-07-issue-226-vignette-accessible-km/.gitkeep b/planning/archive/2026-07-issue-226-vignette-accessible-km/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/archive/2026-07-issue-226-vignette-accessible-km/README.md b/planning/archive/2026-07-issue-226-vignette-accessible-km/README.md new file mode 100644 index 0000000..c2766d4 --- /dev/null +++ b/planning/archive/2026-07-issue-226-vignette-accessible-km/README.md @@ -0,0 +1,27 @@ +## Outcome + +Extended `vignettes/pars-habitat-connectivity.Rmd` to demonstrate `accessible_km` bcfishpass-equivalence +(#226): a new **Accessible habitat (km)** section with a link-vs-bcfp roll-up table (from cached +`pars_accessible.rds`) + computed prose — link 6,822.5 km vs bcfp 6,822.9 km accessible bull-trout habitat, +**−0.01%** (the #223 access-segmentation fix); spawning/rearing within the 5% habitat band. `data-raw/wsg_vignette_data.R` +gained the accessible roll-up cache (mirroring `parity_crosssection.R`'s `lnk_rollup_wsg()` + `streams_vw_bcfp` +`IN (1,2)`) and a segmentation-parity guard. + +**The load-bearing discovery:** "regenerate the artifacts from the merged state" was *not* docs-only. The Plan +agent + live DB probes found `fresh_default` (the Arctic-grayling schema) was still on **pre-#223 segmentation** +(48,558 PARS segs vs `fresh`'s 97,538). The gpkg's single `streams` layer joins `fresh` geometry + +`mapping_code_bt` to `fresh_default` `mapping_code_gr` on `id_segment`, so a naive regeneration would have +attached grayling tokens to mismatched geometry and shipped a corrupted GR map. Fixed by re-modelling the +`default` config for PARS into `fresh_default` (`wsg_run_one.R`, 5.1 min) + a `merge=TRUE` recompute +(`wsg_recompute_one.R`, 1.6 min) for cross-WSG `;DAM`, bringing both configs onto the same segmentation. The +join is then faithful (exact GR count 19,232; 99.93% aggregate length; residual per-segment wiggle is local +habitat-break redistribution, sub-pixel at basin scale — single-layer gpkg design retained). Stale hardcoded +map-caption counts (19,233/31,932/1,764) are now computed inline (→ 19,232/38,622/257). mapping_code parity +refreshed 99.04% → 98.91% (denser post-#223). Verified with a DB-free knit; `/code-check` fixed a latent +`diff_pct` div-by-zero on the generic-reuse path. + +**Reusable lesson:** the two persist configs (`fresh` / `fresh_default`) can drift in segmentation because only +WSGs re-modelled post-#223 are dense. Any cross-config artifact joined on `id_segment` must verify both sides +share segmentation first — the new guard in `wsg_vignette_data.R` enforces this. + +Closed by: branch `226-vignette-accessible-km` (commits `fb0de90..7cb748d`), shipped v0.44.1. Closes #226. diff --git a/planning/archive/2026-07-issue-226-vignette-accessible-km/findings.md b/planning/archive/2026-07-issue-226-vignette-accessible-km/findings.md new file mode 100644 index 0000000..8406cb0 --- /dev/null +++ b/planning/archive/2026-07-issue-226-vignette-accessible-km/findings.md @@ -0,0 +1,70 @@ +# Findings — Extend PARS vignette to demonstrate accessible_km bcfp-equivalence (#226) + +## Issue context + +#221 added the per-WSG `accessible_km` roll-up and #223 fixed the access segmentation so `accessible_km` +converges to the bcfp reference **exactly** (proof: `research/parity_accessible_habitat_2026_07_03.md` — +44/44 within 0.05% across 8 species × 11 WSGs). + +Extend `vignettes/pars-habitat-connectivity.Rmd` (currently demonstrates `mapping_code` parity at 99.04% BT) +to also demonstrate **accessible_km bcfp-equivalence**, and regenerate the cached artifacts via +`data-raw/wsg_vignette_data.R` from the merged state. Kept as its own PR (user-facing docs + a regenerated +binary `.gpkg` + pkgdown CI build). Relates to #221, #223. + +## Live-verified DB state (2026-07-04, local `:5432` fwapg, read-only) + +**PARS·BT roll-up — link vs bcfp (the numbers the vignette will assert):** + +| metric | link km | bcfp km | diff | +|---|---|---|---| +| accessible | 6822.47 | 6822.88 | **−0.01%** (exact; the #223 target) | +| spawning | 1683.38 | 1667.92 | +0.93% (within habitat tol) | +| rearing | 2575.06 | 2588.91 | −0.53% (within habitat tol) | + +- Link side reproduces `lnk_rollup_wsg(conn, aoi="PARS", species="BT", schema="fresh")`. +- Bcfp side = `fresh.streams_vw_bcfp` PARS, `access_bt/spawning_bt/rearing_bt IN (1,2)`, `sum(length_metre)/1000`. +- accessible −0.01% matches the proof doc's PARS·BT row exactly → persisted `fresh` is post-#223. + +**The blocker (verified — drove the full-regen scope choice):** + +| schema | PARS segs | avg length | state | +|---|---|---|---| +| `fresh` (BT) | 97,538 | 142.2 m | post-#223 | +| `fresh_default` (GR) | 48,558 | 285.6 m | **pre-#223** | + +The gpkg `streams` query (`wsg_vignette_data.R:103-118`) pulls geometry + `mapping_code_bt` from `fresh` but +LEFT-JOINs `mapping_code_gr` from `fresh_default` on `(id_segment, watershed_group_code)`. Two different +segmentations → the GR token attaches to the wrong geometry. Regenerating the gpkg *now* corrupts the GR map. +Plan-agent measured: `gr_only` 1,764 → 12,749, streams layer 33,696 → 51,371 — pure join artifact. + +**Why a local re-model suffices (no cyphers):** PARS downstream closure WSGs are all persisted in +`fresh_default` — PCEA (33,763), FINA (26,094), PARA (19,079), LBTN (28,304), LPCE (11,483), UPCE (25,024), +NECR (27,989). So the `;DAM` recompute can run locally. Pre-#223 both configs segmented PARS identically +(48,558 = 48,558) → expect both → ~97,538 post-#223, restoring a valid `id_segment` join. + +`fresh` is a **patchwork**: only the proof WSGs (FINA/PARS/PCEA + the other 7) are post-#223; the rest still +match `fresh_default` (pre-#223). Only PARS's own segmentation matters for this gpkg. + +## Existing structure (from Explore agents) + +- Vignette output `bookdown::html_vignette2`; loads `inst/vignette-data/pars.gpkg` (11.5 MB) + + `pars_parity.rds` (272 B, 1-row: wsg/species/total_segs/match_pct/n_diffs/top_pattern/top_pattern_count). +- Parity section `## Reproducing bcfishpass (parity)` (~L212): `parity-table` kable + `parity-pct` computed + prose (99.04% inline, auto-updates). No `\@ref` cross-refs anywhere. +- Two-species: BT (bcfp parity) + GR (link extension, "nothing to compare"). Parity portion is BT-only. +- **Blocker 2:** map-gr `fig.cap` (`Rmd:388`) + map-detail prose (`Rmd:411`) hardcode 19,233 / 31,932 / 1,764 + — won't auto-update; must inline-compute post-regen. +- `data-raw/wsg_vignette_data.R`: `LNK_LOAD=loadall Rscript ...`, reads persisted `fresh` + `fresh_default` + on `:5432`; computes ONLY mapping_code + geometry (no rollup). `bookdown`/`sf`/`gq` in Suggests; + `ggplot2` NOT declared (new content stays kable+prose). pkgdown CI builds on push to main, cache-only. + +## API to reuse + +- `R/lnk_rollup_wsg.R` — `lnk_rollup_wsg(conn, aoi, species, schema="fresh")` → data.frame + `wsg/species/accessible_km/spawning_km/rearing_km`. accessible = `access_ IN (1,2)` from + `streams_access` LEFT-joined to `streams` on full PK; length from `streams`. +- `data-raw/parity_crosssection.R:55-69` — the exact bcfp `streams_vw_bcfp` `IN (1,2)` rollup SQL to mirror. +- `data-raw/wsg_run_one.R` / `wsg_recompute_one.R` — Phase 1 re-model + cheap `merge=TRUE` recompute. + +## Reference +- bcfp reference: local `fresh.streams_vw_bcfp` snapshot, `smnorris/bcfishpass@v0.7.15-41-g2917790` (tunnel-free). diff --git a/planning/archive/2026-07-issue-226-vignette-accessible-km/progress.md b/planning/archive/2026-07-issue-226-vignette-accessible-km/progress.md new file mode 100644 index 0000000..ef1fe7f --- /dev/null +++ b/planning/archive/2026-07-issue-226-vignette-accessible-km/progress.md @@ -0,0 +1,29 @@ +# Progress — Extend PARS vignette to demonstrate accessible_km bcfp-equivalence (#226) + +## Session 2026-07-04 + +- Cleared leftover memory-migration + run-logs onto `main` (commits `3846a3d`, `16b41aa`; pushed). Left + `.claude/settings.local.json` uncommitted — it carries a live bcfp-tunnel password (flagged to user). +- Plan-mode exploration: 3 Explore agents (vignette / data-gen+build / API+numbers) + 1 Plan-agent review. +- Live-verified the PARS·BT roll-up numbers and the `fresh_default` pre-#223 blocker (see findings.md). +- User chose **full faithful regeneration** (re-model the stale grayling schema, not accessible-only). +- Created branch `226-vignette-accessible-km` off main; scaffolded PWF baseline with approved phases. +- **Phase 1 done:** re-modelled PARS default → `fresh_default` (`wsg_run_one.R`, 5.1 min) + `merge=TRUE` + recompute (`wsg_recompute_one.R`, 1.6 min). Gate: `fresh_default` PARS = 97,537 segs / 142.2 m (matches + `fresh` 97,538). Join nuance surfaced + reassessed: 94.49% exact-length but **99.93% aggregate-length + + exact GR count** (19,232 = native); residual is local habitat-break redistribution, sub-pixel at basin + scale → single-layer gpkg design retained (user informed). +- **Phase 2 done:** `data-raw/wsg_vignette_data.R` — added accessible/spawning/rearing km cache + (`pars_accessible.rds`, mirrors `parity_crosssection.R` link+bcfp `IN (1,2)`) + segmentation-parity guard + (refuses mixed-segmentation gpkg). +- **Phase 3 done:** regenerated all artifacts. accessible **6822.47 | 6822.88 | −0.01%** (exact), + spawning +0.93%, rearing −0.53%; mapping_code parity 98.91%; gpkg 11.9 MB (no balloon), all 8 layers, + tunnel context included. +- **Phase 4 done:** vignette gains `### Accessible habitat (km)` (bridging table + computed prose: + 6,822.5 vs 6,822.9 km, −0.01%), loads `pars_accessible.rds`, and the stale map captions + (19,233/31,932/1,764) are now inline-computed (`n_gr`/`n_bt`/`n_gronly` → 19,232/38,622/257). +- **Phase 5 verify:** cache-only knit passed (exit 0) — accessible numbers + computed captions render, + stale values gone; the only `lnk_db_conn()` sits in the `eval=FALSE` demo chunk. `/code-check` round 1: + 1 low latent finding (diff_pct div-by-zero on the generic-reuse path) fixed with an NA guard; PARS + artifact unchanged. +- Next: NEWS + patch bump, archive PWF, open PR (Closes #226; ref sred#24). diff --git a/planning/archive/2026-07-issue-226-vignette-accessible-km/task_plan.md b/planning/archive/2026-07-issue-226-vignette-accessible-km/task_plan.md new file mode 100644 index 0000000..f4846ee --- /dev/null +++ b/planning/archive/2026-07-issue-226-vignette-accessible-km/task_plan.md @@ -0,0 +1,55 @@ +# Task: Extend PARS vignette to demonstrate accessible_km bcfp-equivalence (#226) + +#221 added the per-WSG `accessible_km` roll-up; #223 fixed the access-segmentation over-credit so link's +`accessible_km` converges to bcfishpass **exactly** (proof: `research/parity_accessible_habitat_2026_07_03.md` +— 44/44 within 0.05%). Extend `vignettes/pars-habitat-connectivity.Rmd` (currently only per-segment +`mapping_code` parity, 99.04% BT) to also demonstrate the aggregate `accessible_km` bcfp-equivalence, and +**regenerate the cached artifacts from the merged v0.44.0 state**. User chose **full faithful regeneration** +(re-model the stale grayling schema, not just add the accessible table). + +## Phase 1: Re-model PARS default config to post-#223 (modelling prerequisite) +- [x] Re-model PARS default config → `fresh_default` post-#223 via `lnk_pipeline_run(aoi="PARS", + cfg=lnk_config("default"), schema="fresh_default", mapping_code=TRUE)` (reuse `data-raw/wsg_run_one.R` + if it selects config/schema; else call directly). Local `:5432`. +- [x] Recompute PARS access `merge=TRUE` + `lnk_mapping_code` so cross-WSG `;DAM` is settled against the + consolidated barrier set (downstream Peace WSGs already persisted in `fresh_default`). +- [x] **Gate:** `fresh_default.streams` PARS ≈ 97,538 / ~142 m (matches `fresh`) and the gpkg `id_segment` + join fresh↔fresh_default is ~100% length-consistent. If not, STOP and reassess. + +## Phase 2: Extend data-gen — accessible_km cache + segmentation guard +- [x] `data-raw/wsg_vignette_data.R`: LINK `lnk_rollup_wsg(aoi, species="BT", schema="fresh")` + BCFP + `fresh.streams_vw_bcfp` (`access_bt`/`spawning_bt`/`rearing_bt` `IN (1,2)`, coalesce, schema-qualified, + mirror `parity_crosssection.R:55-69`) → 3-row `accessible` (metric/link_km/bcfp_km/diff_pct) → + `saveRDS` `inst/vignette-data/pars_accessible.rds`. +- [x] Harden `persisted()` guard (`wsg_vignette_data.R:65-81`): assert `fresh.streams` vs + `fresh_default.streams` AOI segment counts agree — refuse a mixed-segmentation gpkg. + +## Phase 3: Regenerate all cached artifacts +- [x] `LNK_LOAD=loadall Rscript data-raw/wsg_vignette_data.R`. +- [x] Confirm `pars.gpkg` (post-#223 geometry, valid GR join), `pars_parity.rds` (refreshed BT parity), + `pars_accessible.rds` (new). +- [x] Sanity: accessible −0.01% (6822.47/6822.88); mapping_code ~99%; **measure gpkg size** (~2× → ~22 MB); + if it balloons bump `st_simplify(dTolerance=)` (L123) — note R CMD installed-size NOTE. + +## Phase 4: Vignette — accessible_km subsection + fix stale captions +- [x] `load` chunk (~L154): `readRDS(pars_accessible.rds)`; inline-compute `n_bt`/`n_gr`/`n_gronly` from the + loaded `streams` layer (DB-free). +- [x] Replace hardcoded caption counts — map-gr `fig.cap` (`Rmd:388`) + map-detail prose (`Rmd:411`) → vars. +- [x] Add `### Accessible habitat (km)` under `## Reproducing bcfishpass (parity)`: prose + captioned kable + + computed `results="asis"` sentence (accessible −0.01%, bridge to the 416 mapping_code disagreements; + spawn/rear within-tolerance, not "exact"). +- [x] Update `## Cached inputs` prose + raw-download links (~L138-152) to include `pars_accessible.rds`. + +## Phase 5: Verify + finalize +- [x] Knit with DB **stopped** — cache-only; numbers + captions + numbering correct. +- [x] `/code-check` on staged diff. +- [x] NEWS.md (patch); `/planning-archive`; `/gh-pr-push` (Closes #226; relates #221/#223; ref + `NewGraphEnvironment/sred#24`). Version bump (patch) as final commit / at `/gh-pr-merge`. + +## Validation +- [x] Phase 1 gate met (segmentation match + length-consistent join) +- [x] Vignette knits from cache with DB stopped; accessible sentence ≈ "6,822.5 km vs 6,822.9 km, −0.01%" +- [x] `pars_accessible.rds` equals the live-verified table (±rounding) +- [x] `/code-check` clean on each commit +- [x] PWF checkboxes match landed work +- [x] `/planning-archive` on completion diff --git a/vignettes/pars-habitat-connectivity.Rmd b/vignettes/pars-habitat-connectivity.Rmd index 26c4ff4..37d55c4 100644 --- a/vignettes/pars-habitat-connectivity.Rmd +++ b/vignettes/pars-habitat-connectivity.Rmd @@ -150,6 +150,9 @@ time. Direct downloads from the repo (open in QGIS or any GDAL-aware tool): context layers `reserves`, `parks`, `roads`, `railways` - [`pars_parity.rds`](https://github.com/NewGraphEnvironment/link/raw/main/inst/vignette-data/pars_parity.rds) — tunnel-free per-species `mapping_code` parity tibble +- [`pars_accessible.rds`](https://github.com/NewGraphEnvironment/link/raw/main/inst/vignette-data/pars_accessible.rds) + — bull-trout accessible / spawning / rearing habitat (km), link's roll-up vs + the local bcfishpass snapshot ```{r load, include = FALSE} library(sf) @@ -157,6 +160,8 @@ library(sf) gpkg <- system.file("vignette-data/pars.gpkg", package = "link", mustWork = TRUE) parity <- readRDS(system.file("vignette-data/pars_parity.rds", package = "link", mustWork = TRUE)) +accessible <- readRDS(system.file("vignette-data/pars_accessible.rds", + package = "link", mustWork = TRUE)) present <- sf::st_layers(gpkg)$name lyr <- function(name) { @@ -184,6 +189,14 @@ roads <- clip_aoi(roads) railways <- clip_aoi(railways) reserves <- clip_aoi(reserves) parks <- clip_aoi(parks) + +# Classified-segment counts for the grayling map captions — derived from the +# loaded layer so they track the cached artifact, never hand-edited (link#226). +.bt_present <- !is.na(streams$mapping_code_bt) & nzchar(streams$mapping_code_bt) +.gr_present <- !is.na(streams$mapping_code_gr) & nzchar(streams$mapping_code_gr) +n_bt <- sum(.bt_present) +n_gr <- sum(.gr_present) +n_gronly <- sum(.gr_present & !.bt_present) ``` The model run itself — shown here for reference, not executed at build time — @@ -247,6 +260,44 @@ The remaining disagreements concentrate on intermittent reaches downstream of dams — segments where the `;INTERMITTENT` and `;DAM` qualifiers interact, and where cross-watershed-group ordering is most sensitive. +### Accessible habitat (km) + +Per-segment `mapping_code` agreement is one lens; the habitat **totals** are +another. `link`'s `accessible_km` roll-up (`lnk_rollup_wsg()`) sums stream length +a species can reach — `access` in {modelled, observed} — and compares it against +the same quantity from the bcfishpass reference view, tunnel-free, using the +identical `IN (1, 2)` predicate. Before the #223 access-segmentation fix a reach +could straddle a gradient frontier and be credited whole; now streams break at +**every** frontier, so the accessible total converges on bcfishpass exactly. + +```{r accessible-table, echo = FALSE} +atab <- accessible +names(atab) <- c("metric", "link km", "bcfishpass km", "diff %") +knitr::kable( + atab, row.names = FALSE, digits = 2, + caption = paste0( + "Bull-trout accessible / spawning / rearing habitat (km) in PARS: link's ", + "roll-up vs the local bcfishpass snapshot. accessible_km is the #223 target ", + "and matches exactly; spawning and rearing agree within habitat-methodology ", + "tolerance (both well inside the 5% parity band)." + ) +) +``` + +```{r accessible-pct, echo = FALSE, results = "asis"} +acc <- accessible[accessible$metric == "accessible", ] +cat(sprintf(paste0("link models **%s km** of bull-trout accessible habitat in ", + "PARS against bcfishpass's **%s km** — a **%+.2f%%** difference. ", + "That aggregate holds to hundredths of a percent even though the ", + "per-segment `mapping_code` above disagrees on %s segments: the ", + "disagreements fall on short reaches that do not move the ", + "habitat totals.\n"), + formatC(acc$link_km, format = "f", digits = 1, big.mark = ","), + formatC(acc$bcfp_km, format = "f", digits = 1, big.mark = ","), + acc$diff_pct, + format(parity$n_diffs[1], big.mark = ","))) +``` + ```{r symbology, include = FALSE} reg <- gq::gq_reg_main() cls <- gq::gq_tmap_classes(reg$layers$streams_salmon) @@ -385,7 +436,7 @@ The map below is rendered with the **same** bcfishpass symbology registry — th token vocabulary (`ACCESS`/`SPAWN`/`REAR` × `NONE`/`MODELLED`/`ASSESSED`/`DAM`/…) is species-agnostic, so one colour lookup styles every species consistently. -```{r map-gr, echo = FALSE, fig.cap = "Arctic grayling per-segment mapping_code across the Parsnip River Watershed Group, link's default configuration — a species bcfishpass does not yet model. Same symbology registry and context layers as the bull-trout map, so the two are directly comparable. The grayling network is smaller than bull trout's (19,233 vs 31,932 classified segments), but every grayling segment is net-new output relative to bcfishpass, and 1,764 of them carry no bull-trout classification at all."} +```{r map-gr, echo = FALSE, fig.cap = sprintf("Arctic grayling per-segment mapping_code across the Parsnip River Watershed Group, link's default configuration — a species bcfishpass does not yet model. Same symbology registry and context layers as the bull-trout map, so the two are directly comparable. The grayling network is smaller than bull trout's (%s vs %s classified segments), but every grayling segment is net-new output relative to bcfishpass, and %s of them carry no bull-trout classification at all.", format(n_gr, big.mark = ","), format(n_bt, big.mark = ","), format(n_gronly, big.mark = ","))} gr <- streams[!is.na(streams$mapping_code_gr) & nzchar(streams$mapping_code_gr), ] gr$col <- cls$values[gr$mapping_code_gr] gr$col[is.na(gr$col)] <- "#999999" @@ -408,7 +459,7 @@ par(op) The full-watershed views compress a lot of network. Cropping to a sub-reach puts bull trout and grayling side by side at full resolution. Grayling's -modelled network is the smaller of the two overall, but 1,764 segments carry a +modelled network is the smaller of the two overall, but `r format(n_gronly, big.mark = ",")` segments carry a grayling classification with no bull-trout classification — the reaches where the extension is doing genuinely new work.