diff --git a/CLAUDE.md b/CLAUDE.md index 6f17ad9..ee02b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,7 +275,7 @@ When changing a `configs//dimensions.csv` or any file that feeds `lnk_rule ## SRED -Relates to NewGraphEnvironment/sred-2025-2026#24 — crossing connectivity interpretation package. +Relates to NewGraphEnvironment/sred#24 — crossing connectivity interpretation package. @@ -822,7 +822,7 @@ traceable record of what was planned, built, and verified. For new packages or major features, work on a branch and merge via PR: ``` -main ← scaffold-branch (PR closes with "Relates to NewGraphEnvironment/sred-2025-2026#N") +main ← scaffold-branch (PR closes with "Relates to NewGraphEnvironment/sred#N") ``` This gives one PR that contains all commits — a single SRED cross-reference diff --git a/DESCRIPTION b/DESCRIPTION index 281e2a5..48137d8 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: link Title: Stream Network Habitat Interpretation (Experimental) -Version: 0.41.4 -Date: 2026-05-29 +Version: 0.42.0 +Date: 2026-06-01 Authors@R: c( person("Allan", "Irvine", , "airvine@newgraphenvironment.com", role = c("aut", "cre"), @@ -30,19 +30,25 @@ Imports: yaml Remotes: NewGraphEnvironment/crate, - NewGraphEnvironment/fresh@v0.32.0 + NewGraphEnvironment/fresh@v0.32.0, + NewGraphEnvironment/gq Suggests: bcdata, + bookdown, digest, dplyr, fresh (>= 0.32.0), + gq, + knitr, lintr, mockery, + rmarkdown, sf, tarchetypes, targets, testthat (>= 3.0.0), withr +VignetteBuilder: knitr Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) diff --git a/NEWS.md b/NEWS.md index bd374f4..b6ad78b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +# link 0.42.0 + +First package vignette: `vignettes/pars-habitat-connectivity.Rmd` — bull trout and Arctic grayling habitat and connectivity classification for the Parsnip River Watershed Group (`PARS`, FWCP Peace), rehearsed end-to-end so it can transfer into the Fish Passage Peace 2025 report appendix ([#215](https://github.com/NewGraphEnvironment/link/issues/215)). Two analyses: (1) **parity** — link's `bcfishpass` config reproduces bcfishpass's per-segment `mapping_code` for bull trout at 99.04%; (2) **extension** — link's `default` config models Arctic grayling, which bcfishpass does not model at all. Map symbology reuses the bcfishpass symbology registry bundled in `gq` (`gq::gq_reg_main()` + `gq_tmap_classes()`, the same recipe `fresh` uses), so stream colours match a bcfishpass QGIS project exactly. The vignette is tunnel-free: the model run + comparison run once locally in `data-raw/wsg_vignette_data.R`, which caches artifacts to `inst/vignette-data/` (`pars.gpkg`, `pars_parity.rds`); the vignette only loads those, so pkgdown CI builds it with no Postgres and no bcfishpass snapshot. New Suggests (`bookdown`, `gq`, `knitr`, `rmarkdown`) + `VignetteBuilder: knitr` + `gq` Remote. + # link 0.41.4 `data-raw/audit_configs.R` is now a trustworthy pre-trifecta gate. The script grew an end-of-run rollup that aggregates every finding and exits non-zero when any fired (previously findings scrolled past inline and the script always exited 0, so the trifecta could not gate on a clean audit), plus a section comparing fresh's canonical `parameters_fresh.csv` column set against each link bundle's copy (flags engine params fresh added that link is missing; treats `observation_*` as expected link-only extensions — the `parameters_fresh` half of the fresh↔link config-drift gap, rules.yaml half tracked in #129). All 30 findings the audit had been emitting were audit-side defects, not config drift (`lnk_config_verify` reports 0 byte/shape drift in both bundles): §1 now calls the canonical `lnk_config_verify()` rather than a divergent homegrown checksum recipe; §2 regenerates rules.yaml with `edge_types="explicit"` to match how the committed copy is built; §3 splits species-axis mismatches into flagged defects vs informational expected asymmetries; §4 resolves declared paths against the bundle dir and compares full relative paths instead of basenames. Audit now reports "No findings — config layers aligned." and exits 0. diff --git a/data-raw/wsg_vignette_data.R b/data-raw/wsg_vignette_data.R new file mode 100644 index 0000000..a3e7a4e --- /dev/null +++ b/data-raw/wsg_vignette_data.R @@ -0,0 +1,229 @@ +#!/usr/bin/env Rscript +# data-raw/wsg_vignette_data.R +# +# Generic — runs for any BC watershed group. Set `aoi` below; every output +# path is namespaced by `stub <- tolower(aoi)`, so re-pointing at another +# study area is a one-line edit (matches the flooded package's +# data-raw/wsg_vignette_data.R). +# +# Generates the cached artifacts that back vignettes/pars-habitat-connectivity.Rmd. +# Runs ONCE locally. pkgdown CI has no Postgres and no bcfp snapshot, so the +# vignette only *loads* these artifacts — it never touches a database at +# build time. This is the flooded data-gen pattern, not the .Rmd.orig +# pre-knit pattern (which breaks bookdown figure numbering). +# +# Produces (inst/vignette-data/): +# .gpkg layers: aoi (WSG boundary), streams (per-segment +# mapping_code_bt from `fresh` + mapping_code_gr from +# `fresh_default`), waterbodies (lakes + rivers + +# manmade), named_streams, plus the basemapping context +# layers reserves, parks, roads, railways +# _parity.rds tunnel-free per-species mapping_code parity +# (lnk_compare_mapping_code vs the local bcfp snapshot) +# +# TWO data sources (mirrors flooded / the Peace 2025 report appendix): +# * MODEL STATE + FWA — local fwapg (localhost:5432). The #175 study-area +# run persisted PARS to `fresh` (bcfishpass config) + `fresh_default` +# (default config) DS-first (most-downstream WSG first) so cross-WSG +# `;DAM` tokens are correct. READ it; do not recompute (a standalone +# single-WSG re-run would diverge on those segments). +# * CONTEXT BASEMAPPING — the db_newgraph full catalog via +# fresh::frs_db_conn() (localhost:63333, dbname `bcfishpass`). reserves +# (whse_admin_boundaries), parks (whse_tantalis), roads + railways +# (whse_basemapping.transport_line / gba_railway_tracks_sp) are not in +# the FWA-only local subset. These are the same `fetch_layer` queries +# flooded uses. If the 63333 tunnel is down, those layers are skipped +# (the gpkg still builds, just without context). Bring the tunnel up to +# ship real context data — see soul/skills/db-newgraph. +# +# Usage: LNK_LOAD=loadall Rscript data-raw/wsg_vignette_data.R + +suppressPackageStartupMessages({ + if (identical(Sys.getenv("LNK_LOAD"), "loadall")) { + pkgload::load_all(quiet = TRUE) + } else { + library(link) + } + library(DBI) + library(RPostgres) + library(sf) +}) + +aoi <- "PARS" +stub <- tolower(aoi) +out_dir <- "inst/vignette-data" +gpkg <- file.path(out_dir, paste0(stub, ".gpkg")) +dir.create(out_dir, showWarnings = FALSE, recursive = TRUE) + +# local fwapg — model state (fresh / fresh_default) + FWA base layers +conn <- lnk_db_conn(dbname = "fwapg", host = "localhost", port = 5432L, + user = "postgres", password = "postgres") + +cfg_bcfp <- lnk_config("bcfishpass") # persists to `fresh` + +# --- model-state guard — read the authoritative #175 persist, do not recompute +persisted <- function(schema) { + q <- sprintf( + "SELECT count(*)::int n FROM %s.streams_mapping_code WHERE watershed_group_code = %s", + schema, DBI::dbQuoteLiteral(conn, aoi)) + DBI::dbGetQuery(conn, q)$n > 0L +} +if (!persisted("fresh") || !persisted("fresh_default")) { + stop(aoi, " not persisted in `fresh` and/or `fresh_default`. Run the #175 ", + "study-area pipeline DS-first before generating vignette data — a ", + "standalone re-run here would miss cross-WSG `;DAM` tokens. The ", + "modelling invocation, for reference:\n", + " loaded <- lnk_load_overrides(cfg_bcfp)\n", + " lnk_pipeline_run(conn, aoi = '", aoi, "', cfg = cfg_bcfp, loaded = loaded,\n", + " schema = 'working_", stub, "', mapping_code = TRUE)\n", + " # and likewise for the default config into fresh_default", + call. = FALSE) +} +message("[wsg_vignette_data] authoritative ", aoi, " state present in fresh + fresh_default") + +# --- 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), ")") + +# --- spatial layers ----------------------------------------------------------- +aoi_lit <- DBI::dbQuoteLiteral(conn, aoi) + +# WSG boundary +q_boundary <- sprintf( + "SELECT watershed_group_code, watershed_group_name, geom + FROM whse_basemapping.fwa_watershed_groups_poly + WHERE watershed_group_code = %s", aoi_lit) +boundary <- sf::st_read(conn, query = q_boundary, quiet = TRUE) + +# Streams: bcfp-config BT token (`fresh`) + default-config GR token +# (`fresh_default`), keyed on the persist PK (id_segment, watershed_group_code). +# Keep only the modelled network (a BT or GR token present). +q_streams <- sprintf( + "SELECT s.id_segment, + s.blue_line_key, + mc.mapping_code_bt, + mcd.mapping_code_gr, + s.geom + FROM fresh.streams s + LEFT JOIN fresh.streams_mapping_code mc + ON mc.id_segment = s.id_segment + AND mc.watershed_group_code = s.watershed_group_code + LEFT JOIN fresh_default.streams_mapping_code mcd + ON mcd.id_segment = s.id_segment + AND mcd.watershed_group_code = s.watershed_group_code + WHERE s.watershed_group_code = %s + AND (NULLIF(mc.mapping_code_bt, '') IS NOT NULL + OR NULLIF(mcd.mapping_code_gr, '') IS NOT NULL)", aoi_lit) +streams <- sf::st_read(conn, query = q_streams, quiet = TRUE) + +# ship-small: drop Z/M, simplify centerlines (~15 m is invisible at WSG scale) +streams <- sf::st_zm(streams, drop = TRUE) +streams <- sf::st_simplify(streams, dTolerance = 15, preserveTopology = FALSE) +streams <- streams[!sf::st_is_empty(streams), ] + +# Waterbodies: lakes + rivers + manmade within the WSG, one layer +wb_one <- function(tbl) { + q <- sprintf( + "SELECT geom FROM whse_basemapping.%s WHERE watershed_group_code = %s", + tbl, aoi_lit) + x <- sf::st_read(conn, query = q, quiet = TRUE) + if (nrow(x)) x$kind <- sub("^fwa_(.*)_poly$", "\\1", tbl) + x +} +wb_tables <- c("fwa_lakes_poly", "fwa_rivers_poly", "fwa_manmade_waterbodies_poly") +wb_layers <- Filter(function(z) nrow(z) > 0, lapply(wb_tables, wb_one)) + +# Named streams (labels) — FWA, present in the local subset +q_named <- sprintf( + "SELECT gnis_name, blue_line_key, stream_order, geom + FROM whse_basemapping.fwa_named_streams + WHERE watershed_group_code = %s", aoi_lit) +named_streams <- sf::st_read(conn, query = q_named, quiet = TRUE) +named_streams <- sf::st_zm(named_streams, drop = TRUE) + +# --- context basemapping from the db_newgraph full catalog (63333) ------------ +# reserves / parks / roads / railways are absent from the FWA-only local +# subset. Pull them the way flooded + the Peace report do: frs_db_conn(). +boundary_wkt <- sf::st_as_text(sf::st_union(sf::st_geometry(boundary))) +intersect_clause <- function(geom_col = "geom") { + sprintf("ST_Intersects(%s, ST_GeomFromText('%s', 3005))", geom_col, boundary_wkt) +} + +conn_ctx <- try(fresh::frs_db_conn(), silent = TRUE) +context_layers <- list() +if (inherits(conn_ctx, "try-error") || is.null(conn_ctx)) { + message("[wsg_vignette_data] frs_db_conn() unavailable — context layers ", + "(reserves/parks/roads/railways) skipped. Bring up the 63333 ", + "db_newgraph tunnel to ship them (soul/skills/db-newgraph).") +} else { + fetch_ctx <- function(query_sql, label) { + x <- try(sf::st_read(conn_ctx, query = query_sql, quiet = TRUE), silent = TRUE) + if (inherits(x, "try-error") || nrow(x) == 0L) { + message(sprintf(" %s: 0 features (skipping)", label)) + return(NULL) + } + message(sprintf(" %s: %d features", label, nrow(x))) + sf::st_zm(x, drop = TRUE) + } + message("[wsg_vignette_data] fetching context layers from db_newgraph ...") + context_layers$reserves <- fetch_ctx( + sprintf("SELECT english_name, band_name, geom + FROM whse_admin_boundaries.adm_indian_reserves_bands_sp + WHERE %s", intersect_clause()), + "reserves") + context_layers$parks <- fetch_ctx( + sprintf("SELECT protected_lands_name, protected_lands_designation, geom + FROM whse_tantalis.ta_park_ecores_pa_svw + WHERE %s", intersect_clause()), + "parks") + # roads pre-filtered to resource roads (RR*) — the only class the map draws + context_layers$roads <- fetch_ctx( + sprintf("SELECT transport_line_id, structured_name_1, transport_line_type_code, + highway_route_1, geom + FROM whse_basemapping.transport_line + WHERE transport_line_type_code IN ('RRS', 'RRD', 'RRN') + AND %s", intersect_clause()), + "roads") + context_layers$railways <- fetch_ctx( + sprintf("SELECT track_name, geom + FROM whse_basemapping.gba_railway_tracks_sp + WHERE %s", intersect_clause()), + "railways") + DBI::dbDisconnect(conn_ctx) +} +context_layers <- Filter(Negate(is.null), context_layers) + +# --- write (fresh file; layered) ---------------------------------------------- +if (file.exists(gpkg)) unlink(gpkg) +sf::st_write(boundary, gpkg, layer = "aoi", quiet = TRUE) +sf::st_write(streams, gpkg, layer = "streams", quiet = TRUE, append = TRUE) +if (nrow(named_streams) > 0L) { + sf::st_write(named_streams, gpkg, layer = "named_streams", quiet = TRUE, append = TRUE) +} +if (length(wb_layers) > 0L) { + waterbodies <- sf::st_zm(do.call(rbind, wb_layers), drop = TRUE) + sf::st_write(waterbodies, gpkg, layer = "waterbodies", quiet = TRUE, append = TRUE) +} else { + waterbodies <- boundary[0, ] + message("[wsg_vignette_data] no waterbodies for ", aoi, " — layer skipped") +} +for (nm in names(context_layers)) { + sf::st_write(context_layers[[nm]], gpkg, layer = nm, quiet = TRUE, append = TRUE) +} + +# --- report ------------------------------------------------------------------- +sizes <- file.info(list.files(out_dir, full.names = TRUE))["size"] +message("[wsg_vignette_data] wrote artifacts:") +for (f in rownames(sizes)) { + message(sprintf(" %-40s %s", f, format(structure(sizes[f, ], class = "object_size"), + units = "auto"))) +} +message("[wsg_vignette_data] gpkg layers: ", + paste(sf::st_layers(gpkg)$name, collapse = ", ")) +message("[wsg_vignette_data] streams kept: ", nrow(streams), + "; waterbodies: ", nrow(waterbodies), + "; named_streams: ", nrow(named_streams)) + +DBI::dbDisconnect(conn) diff --git a/inst/vignette-data/pars.gpkg b/inst/vignette-data/pars.gpkg new file mode 100644 index 0000000..98b2ea0 Binary files /dev/null and b/inst/vignette-data/pars.gpkg differ diff --git a/inst/vignette-data/pars_parity.rds b/inst/vignette-data/pars_parity.rds new file mode 100644 index 0000000..8c04367 Binary files /dev/null and b/inst/vignette-data/pars_parity.rds differ diff --git a/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/.gitkeep b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/README.md b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/README.md new file mode 100644 index 0000000..3bf0456 --- /dev/null +++ b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/README.md @@ -0,0 +1,9 @@ +## Outcome + +Built `link`'s first package vignette, `vignettes/pars-mapping-code.Rmd` — a per-segment habitat `mapping_code` analysis for the Parsnip River Watershed Group (`PARS`, FWCP Peace), rehearsed end-to-end so it can transfer into the Fish Passage Peace 2025 report appendix (the same vignette→appendix path `flooded` took). The vignette does two things: shows link's `bcfishpass` config reproducing bcfishpass per-segment `mapping_code` for bull trout at 99.04% parity, then extends to Arctic grayling — a species bcfishpass does not model. Symbology reuses the bcfishpass registry bundled in `gq` (`gq_reg_main()` + `gq_tmap_classes()`, fresh's recipe) so colours match a bcfishpass QGIS project exactly. + +Five phases, atomic-committed: (1) infra scaffold (DESCRIPTION Suggests/Remotes/VignetteBuilder + `references.bib`); (2) `data-raw/pars_vignette_data.R` data-gen caching `pars.gpkg` + `pars_parity.rds` + `pars_stamp.rds` to `inst/vignette-data/`; (3) the Rmd itself (8 sections); (4) lint-clean + tunnel-free render verification; (5) v0.42.0 release. + +Key learnings: **(a)** the whole design is driven by pkgdown CI having no Postgres and no bcfishpass snapshot — model run + comparison run once locally, the vignette only *loads* cached artifacts (model-run chunks `eval=FALSE`). **(b)** Biology is the narrative: PARS sits above the W.A.C. Bennett Dam in the Arctic drainage, so no Pacific salmon — bull trout is the *only* bcfp-config species present, making the single-species parity the correct scope, not a limitation. **(c)** Model state was READ from the authoritative #175 DS-first study-area persists (`fresh` + `fresh_default`), never recomputed — a standalone single-WSG re-run would miss cross-WSG `;DAM` tokens. **(d)** `lnk_compare_rollup`/`lnk_parity_annotate` were dropped from data-gen: they need the live bcfp tunnel (`:63333`), which breaks the tunnel-free design. **(e)** pkgdown `build_article` renders against the *installed* package, so `system.file("vignette-data/...")` needs a local reinstall before building locally; CI installs first, so it resolves there. pkgdown flattens `bookdown::html_vignette2` "Figure N" numbering (articles path) — the numbering is present in the actual vignette build, and since the Rmd uses no `\@ref()` cross-refs, nothing breaks. code-check round 1 caught a factually-wrong caption ("grayling network is broader" — data shows GR 19,233 < BT 31,932 classified segments). + +Closed by: commits c4babc9 (Phase 1) → 6bf1335 (Phase 2) → b6f468f (Phase 3) → 432f9c6 (Phase 4) → 315f281 (Release v0.42.0) / PR pending (`Closes #215`). diff --git a/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/findings.md b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/findings.md new file mode 100644 index 0000000..23e299e --- /dev/null +++ b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/findings.md @@ -0,0 +1,93 @@ +# Findings — PARS Peace mapping_code vignette (#215) + +## Issue context + +`link` has no vignette. We want one for the **PARS (Parsnip) WSG** in the FWCP Peace +region that rehearses a habitat `mapping_code` analysis end-to-end, so the showcase can +transfer into the **Fish Passage Peace 2025** report appendix — the same vignette→appendix +path `flooded` took (`pars-floodplain.Rmd` → `0830-appendix-floodplain.Rmd`), templated in +fish_passage_template_reporting#178. + +Two analyses: +1. **Parity** — link's `bcfishpass` config reproduces bcfishpass per-segment `mapping_code` + for PARS (inside the 99.66% study-area median, #175). Tunnel-free vs the local + `fresh.streams_vw_bcfp` snapshot. +2. **Arctic grayling showcase** — link's `default` config models GR, which bcfishpass does + not model at all. The net-new, project-specific extension. + +**Positioning (load-bearing, per fish_passage_template_reporting#192):** complements and +extends the canonical `smnorris` stack (`fwapg`/`bcfishpass`/`bcfishobs`) — never supersedes. +Lead with what's net-new; frame upstream as foundational. Norris credited inline, lightly. + +## Critical design constraint + +pkgdown CI has **no Postgres and no bcfp snapshot**. The model run + comparison happen +**once locally** in a data-gen script that caches artifacts to `inst/vignette-data/`; the +vignette only *loads* those (model-run chunks shown `eval=FALSE`, mirroring flooded's `vca` +chunk). Do not run the model during vignette build. + +## Grounded facts (from plan-mode exploration) + +### flooded template +- `flooded/vignettes/pars-floodplain.Rmd`: YAML `output: bookdown::html_vignette2` + + `bibliography: references.bib` + `link-citations: false`; param table via + `xciter::xct_keys_to_inline_table_col(tab, col_format="citation_keys", path_bib="references.bib")` + → `knitr::kable`; loads via `system.file("vignette-data/pars.gpkg", package="flooded", mustWork=TRUE)`; + maps = `terra::shade(terra::terrain(...))` hillshade + layered `sf` overlays + text halos. +- `flooded/data-raw/wsg_vignette_data.R`: data-gen → wipes + writes a multi-layer `.gpkg` + via `sf::st_write(..., append=TRUE)` + COG tifs + meta `.rds`. **Not** the `.Rmd.orig` + pre-knit pattern (breaks bookdown figure numbering). +- flooded `_pkgdown.yml` has **no `articles:`** — vignettes auto-discover. +- GitHub raw links: `https://github.com/NewGraphEnvironment/link/raw/main/inst/vignette-data/`. + +### link function signatures (verified) +- `lnk_config(name_or_path)` → manifest; `lnk_load_overrides(cfg)` → `loaded` list. +- `lnk_pipeline_run(conn, aoi, cfg, loaded, schema=paste0("working_",tolower(aoi)), dams=TRUE, + cleanup_working=TRUE, mapping_code=FALSE)` — persists to `cfg$pipeline$schema` (`fresh` for + bcfishpass; `fresh_default` for default). +- `lnk_compare_mapping_code(conn, aoi, cfg, reference="bcfishpass", conn_ref=NULL, species=NULL, + ref_table="fresh.streams_vw_bcfp")` → tibble `wsg, species, total_segs, match_pct, n_diffs, + top_pattern, top_pattern_count`. **Tunnel-free when `conn_ref=NULL`** (reads local snapshot view). +- `lnk_compare_rollup(conn, aoi, cfg, reference, conn_ref, species)` → `wsg, species, + habitat_type, unit, link_value, ref_value, diff_pct`. +- `lnk_parity_annotate(rollup, taxonomy="research/bcfp_divergence_taxonomy.yml", to=NULL, + tolerance=2)` → adds `taxonomy_id, class, mechanism, status, refs`. +- `lnk_stamp(cfg, conn, aoi)` + `lnk_stamp_finish(stamp, result)` → provenance; + `format(stamp, "markdown")`. +- GR confirmed in `inst/extdata/configs/default/{rules.yaml,dimensions.csv}`; absent from + `bcfishpass/`. Snapshot `fresh.streams_vw_bcfp` loaded by + `data-raw/snapshot_bcfp.sh --with-bcfp-views`. + +### gq symbology registry +- `gq::gq_reg_main()` → list; `gq::gq_tmap_classes(reg$layers$streams_salmon)` → `$field`, + `$values` (named vector `mapping_code` token → hex), `$labels`. Registry bundled at + `gq/inst/registry/reg_main.json` (extracted from bcfp QGIS project). Token→colour vocabulary + is barrier-status-based (`SPAWN;NONE`→`#129bdb`, `SPAWN;MODELLED`→`#ff9f85`, + `SPAWN;ASSESSED`→`#ef4545`, `SPAWN;DAM`→`#ae7dd6`, `SPAWN;REMEDIATED`→`#33a02c`, `REAR;*`, + `ACCESS;*`) — **species-agnostic**, so one lookup colours every species' map. +- **Consume pattern (verbatim from `fresh/vignettes/fwa-network-query.Rmd`):** + ```r + reg <- gq::gq_reg_main(); cls <- gq::gq_tmap_classes(reg$layers$streams_salmon) + streams$col <- cls$values[streams$mapping_code_bt]; streams$col[is.na(streams$col)] <- "#999999" + plot(sf::st_geometry(streams), col = streams$col, lwd = 1, add = TRUE) + present <- names(cls$values) %in% unique(streams$mapping_code_bt) + legend("topright", legend = cls$labels[present], col = cls$values[present], lwd = 2, + cex = 0.7, bg = "white") + ``` +- link does not yet use `gq`; `fresh` carries it as Suggests + `NewGraphEnvironment/gq` Remote. + +### link DESCRIPTION today +- Suggests has `sf` but NOT `bookdown/knitr/rmarkdown/xciter/terra/gq`; Remotes lacks + `xciter`/`gq`; no `VignetteBuilder`. + +## Reference issues + +- fish_passage_template_reporting#192 — Arctic grayling framework integration; THE positioning + register ("complements and extends... does not supersede"); grayling is first concrete FWCP + Peace output; Phases A-D. +- fish_passage_template_reporting#178 — templated floodplain chapter; driver script + parameter + CSVs + YAML toggle. +- flooded#35 — WSG-scale vignette pattern + output-path convention. +- #175 — study-area parity baseline (99.66% median). +- #212 — KO/RB/GR rows in bcfp config; needed only for link-vs-link Comparison B, NOT the + grayling showcase (which uses `default` config). Kept un-conflated. diff --git a/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/progress.md b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/progress.md new file mode 100644 index 0000000..743be98 --- /dev/null +++ b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/progress.md @@ -0,0 +1,34 @@ +# Progress — PARS Peace mapping_code vignette (#215) + +## Session 2026-05-31 + +- Plan-mode exploration — explored flooded vignette template, link function signatures, gq + symbology registry consume pattern; phases approved by user (after one revision adding gq). +- Filed issue #215. +- Created branch `215-vignette-pars-peace-mapping-code-link-bc` off main. +- Scaffolded PWF baseline (task_plan.md / findings.md / progress.md) with approved phases. +- Phase 1 done (commit c4babc9): DESCRIPTION Suggests + Remotes + VignetteBuilder; `vignettes/` + + seeded `references.bib`; gq/xciter already installed; gq registry consume pattern verified. +- Phase 2 done: `data-raw/pars_vignette_data.R` reads the authoritative #175 DS-first persists + (model state NOT recomputed — a standalone PARS run would miss cross-WSG `;DAM`). Tunnel-free + `lnk_compare_mapping_code` → BT 99.04% (only bcfp-config species in the Peace; Pacific salmon + absent above WAC Bennett Dam). `fresh_default` adds GR (19,233 segs) for the showcase. Cached + `pars.gpkg` (9.7 MB), `pars_parity.rds`, `pars_stamp.rds`. gq registry matches 99.99% of tokens. + Dropped rollup/annotate (need live bcfp tunnel; breaks tunnel-free design). +- Phase 3 done: wrote `vignettes/pars-mapping-code.Rmd` (`bookdown::html_vignette2`, `bibliography: references.bib`). + 8 sections: orient (Peace = Arctic drainage above WAC Bennett Dam → BT-only parity is correct scope) → + Modelling parameters (`xciter` species/gradient table + `format(stamp,"markdown")` provenance) → Cached inputs + (`system.file` + GitHub raw links) → Parity (kable of `pars_parity.rds`, BT 99.04% live via `sprintf`) + BT + full-WSG map → Arctic grayling extension + GR full-WSG map → detail comparison (BT vs GR sub-reach) → vignette→report + (template#192) → References. Maps use the gq registry consume pattern (`gq_reg_main()` + `gq_tmap_classes()` + + base-R plot/legend, fresh's recipe); hillshade dropped (no PARS DEM shipped). Model-run chunks `eval=FALSE`; all + data-load chunks read cached artifacts — full local render confirmed tunnel-free (3 figures numbered, 2 tables, + citations resolved). Installed `bookdown` (was missing locally). `/code-check`: round 1 fixed a wrong GR caption + ("broader" → "smaller; 1,764 GR-only segs"), round 2 clean. +- Phase 4 done: render verified tunnel-free two ways — `rmarkdown::render` (bookdown engine, figures numbered + 1/2/3) and `pkgdown::build_article` (all figs/tables/citations render; pkgdown flattens "Figure N" numbering by + design but no `\@ref` cross-refs exist, so nothing breaks). No DB touched — chunks read `system.file` artifacts. + Needed a local `pak::local_install` so `inst/vignette-data/` ships (CI installs before pkgdown, resolves there). + Wrapped 3 long caption/sprintf strings with `paste0` → vignette is 0 lints; data-raw keeps 4 accepted SQL-indent + lints. `/code-check` clean (verified the wraps preserve exact text + format specifiers). Installed `bookdown`. +- Next: Phase 5 — NEWS.md + DESCRIPTION bump → `/planning-archive` → `/gh-pr-push`. diff --git a/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/task_plan.md b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/task_plan.md new file mode 100644 index 0000000..caa175d --- /dev/null +++ b/planning/archive/2026-06-issue-215-vignette-pars-mapping-code/task_plan.md @@ -0,0 +1,63 @@ +# Task: PARS Peace mapping_code vignette — link/bcfp parity + Arctic grayling showcase (#215) + +`link` has no vignette. We want one for the **PARS (Parsnip) WSG** in the FWCP Peace region that rehearses a habitat `mapping_code` analysis end-to-end, so the showcase can transfer into the **Fish Passage Peace 2025** report appendix — the same vignette→appendix path `flooded` took (`pars-floodplain.Rmd` → `0830-appendix-floodplain.Rmd`), templated in fish_passage_template_reporting#178. + +Two analyses: +1. **Parity** — link's `bcfishpass` config reproduces bcfishpass per-segment `mapping_code` for PARS (inside the 99.66% study-area median, #175). Tunnel-free vs the local `fresh.streams_vw_bcfp` snapshot. +2. **Arctic grayling showcase** — link's `default` config models GR, which bcfishpass does not model at all. The net-new, project-specific extension. + +**Positioning (load-bearing, per fish_passage_template_reporting#192):** complements and extends the canonical `smnorris` stack (`fwapg`/`bcfishpass`/`bcfishobs`) — never supersedes. Lead with what's net-new; frame upstream as foundational. Norris credited inline, lightly. + +**Symbology — reuse `gq`, don't hand-roll:** map colours come from the bcfishpass symbology registry bundled in `gq` (`gq::gq_reg_main()`), the same way `fresh` does it (`fresh/vignettes/fwa-network-query.Rmd`). Stream colours match bcfishpass exactly; no bespoke mapping_code colour scheme. + +## Critical design constraint (drives everything) + +pkgdown CI has **no Postgres and no bcfp snapshot**. The model run + comparison happen **once locally** in a data-gen script that caches artifacts to `inst/vignette-data/`; the vignette only *loads* those (model-run chunks shown `eval=FALSE`, mirroring flooded's `vca` chunk). Do not run the model during vignette build. + +## Phase 1 — Vignette infra scaffold +- [x] `DESCRIPTION`: add Suggests `bookdown, knitr, rmarkdown, terra, xciter, gq`; add `VignetteBuilder: knitr`; add `NewGraphEnvironment/xciter` + `NewGraphEnvironment/gq` to Remotes. +- [x] Create `vignettes/` + `vignettes/references.bib` (seed cites: bcfishpass, fwapg, bcfishobs, any GR/habitat refs). +- [x] Reinstall dev deps so `xciter` + `gq` resolve — both already installed (`gq` registry consume pattern verified: `gq_tmap_classes()` returns 30 token→hex values, salmon layer field `mapping_code_salmon`). +- [x] `/code-check` clean → commit (checkbox flip). + +## Phase 2 — Data-gen script + cached artifacts +- [x] `data-raw/pars_vignette_data.R`: model state READ (not recomputed) from the authoritative #175 DS-first study-area persists — `fresh` (bcfp cfg, BT only in the Peace) + `fresh_default` (default cfg, adds GR/RB/KO); guarded persisted-state check shows the run invocation. (c) `lnk_compare_mapping_code` (tunnel-free, BT 99.04%); (d) `lnk_stamp`/`lnk_stamp_finish`; (e) spatial layers (PARS `aoi`, `streams` with `mapping_code_bt` from `fresh` + `mapping_code_gr` from `fresh_default`, `waterbodies`) → `inst/vignette-data/pars.gpkg`; (f) cache `pars_parity.rds` + `pars_stamp.rds`. **`lnk_compare_rollup`/`lnk_parity_annotate` dropped: they need the live bcfp tunnel (`:63333`), which breaks the tunnel-free no-DB design — and PARS BT at 99% has no habitat-km divergence to annotate.** +- [x] Run locally; confirm artifacts written + sizes reasonable: `pars.gpkg` 9.7 MB (33,696 streams + 1,914 waterbodies, ZM-dropped + 15 m simplify), `pars_parity.rds` 272 B, `pars_stamp.rds` 1.9 KB. gq registry matches 99.99% of BT + GR tokens. +- [x] `/code-check` clean → commit (script + artifacts + checkbox). + +## Phase 3 — Write `vignettes/pars-mapping-code.Rmd` +- [x] 8 sections: orient → **Modelling parameters** (`xciter` species/gradient param table + `lnk_stamp` provenance) → **Cached inputs** (`system.file` + GitHub raw links) → **Reproducing bcfishpass (parity)** (kable of cached parity tibble + BT full-WSG map) → **Arctic grayling — a link extension** (GR full-WSG map) → **Maps — detail comparison** (BT vs GR sub-reach via the `gq` registry — `gq_reg_main()` + `gq_tmap_classes()` + base-R `plot`/`legend`, fresh's recipe; hillshade dropped — no PARS DEM shipped) → **From vignette to report** (Peace 2025 appendix / template#192) → **References**. +- [x] Model-run chunks `eval=FALSE`; data-load chunks read cached artifacts (`system.file` gpkg + 2 rds). No DB touched at build — confirmed by full local render. +- [x] Positioning prose reviewed against #192 ("complements and extends, never supersedes"; Norris stack framed foundational, credited inline). +- [x] `/code-check`: round 1 caught a factually-wrong GR map caption ("broader" — data shows GR 19,233 < BT 31,932 classified segs); corrected to "smaller, but 1,764 GR-only segments". Round 2 clean. Render verified: 3 figures numbered, 2 tables, citations resolved, no raw `@keys` leaked. + +## Phase 4 — Render + verify +- [x] Render verified two ways, both tunnel-free: (1) `rmarkdown::render` via the `bookdown::html_vignette2` engine — **figures numbered 1/2/3**, 2 tables, citations resolved, no raw `@keys`; (2) `pkgdown::build_article("pars-mapping-code")` — all 3 figures + captions + tables + citations render. pkgdown flattens bookdown "Figure N" numbering by design (articles path), but the vignette uses **no `\@ref()` cross-refs**, so nothing breaks; numbering is present in the shipped vignette build. No DB touched — chunks read `system.file("vignette-data/...")` only. Required a local `pak::local_install` so `inst/vignette-data/` ships; pkgdown CI installs before building, so `system.file` resolves there automatically. +- [x] `lintr::lint_package()`: **vignette now 0 lints** (wrapped 3 long caption/sprintf strings with `paste0`). data-raw script retains 4 `indentation_linter` on multi-line SQL — accepted in Phase 2, matches the shipped `wsg_compare.R` pattern. Package-wide pre-existing lints (~1,250, this is a relaxed data-pipeline repo) unchanged / out of scope. +- [x] `/code-check` clean (fresh-eyes round confirmed the `paste0` wraps preserve exact caption text + all sprintf format specifiers) → commit. + +## Phase 5 — Release +- [x] `NEWS.md` new `# link 0.42.0` section (minor bump — net-new vignette feature) + `DESCRIPTION` Version 0.41.4 → 0.42.0, Date → 2026-06-01. +- [ ] `/planning-archive` → `/gh-pr-push` (PR body: `Closes #215` + `Relates to NewGraphEnvironment/sred#24`). + +## Dependencies / relations + +- **#212** (KO/RB/GR rows in bcfp config) — needed only for the link-vs-link full-species Comparison B. The grayling **showcase** here uses the `default` config and is independent. Vignette notes this so the two aren't conflated. +- Relates: #175 (parity baseline); fish_passage_template_reporting#192, #178; flooded#35, #17; `gq` symbology registry. + +## Out of scope + +- #212's bcfp-config GR rows / link-vs-link Comparison B (separate issue). +- The report-side chapter itself (lives in the Peace report / template#178, #192). +- bcfishobs calibration loop (template#192 Phase C). +- Hillshade DEM backdrop is optional — if a PARS DEM isn't cheap to fetch, primary map is streams-by-mapping_code over the WSG boundary (no DEM dependency). + +## Validation + +- [ ] `pkgdown::build_site` renders the vignette with figures numbered and citations resolved, with **no live DB** (proves the cached-artifact design). +- [ ] Parity table reproduces PARS numbers consistent with the #175 baseline. +- [ ] GR map renders from default-config output (a species bcfp doesn't model). +- [ ] Tone consistent with #192 positioning. +- [ ] `/code-check` clean on each commit. +- [ ] PWF checkboxes match landed work. +- [ ] `/planning-archive` on completion. diff --git a/vignettes/pars-habitat-connectivity.Rmd b/vignettes/pars-habitat-connectivity.Rmd new file mode 100644 index 0000000..97d1285 --- /dev/null +++ b/vignettes/pars-habitat-connectivity.Rmd @@ -0,0 +1,423 @@ +--- +title: "Bull trout and Arctic grayling: habitat and connectivity classification for the Parsnip River Watershed Group" +output: + bookdown::html_vignette2 +vignette: > + %\VignetteIndexEntry{Bull trout and Arctic grayling: habitat and connectivity classification for the Parsnip River Watershed Group} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + fig.width = 7, + fig.height = 6, + dpi = 150, + message = FALSE, + warning = FALSE +) +library(link) +``` + +This vignette walks a per-segment habitat `mapping_code` analysis through the +Parsnip River Watershed Group (`PARS`, ~5,600 km², north-eastern BC) end to +end. It does two things. First, it shows `link`'s `bcfishpass` configuration +**reproducing** the per-segment `mapping_code` that +[bcfishpass](https://github.com/smnorris/bcfishpass) produces. Second, it shows +`link` **extending** that methodology to a species bcfishpass does not model in +the Peace: Arctic grayling (`GR`). + +The Parsnip River Watershed Group sits between Prince George and Mackenzie, BC. +The Parsnip flows north into the southern arm of Williston Reservoir, joining +the Peace River system; from there the drainage runs Peace → Slave → Mackenzie, +ultimately discharging to the Arctic Ocean via the Mackenzie Delta. Of the +species in `link`'s `bcfishpass` configuration, bull trout (`BT`) is the only +one present, so the parity check below is a single-species comparison. Both +bull trout and Arctic grayling (`GR`) are cold-water species whose +distributions the model resolves through gradient, channel-width, and access +thresholds. Bull trout — provincially blue-listed and a COSEWIC species of +special concern in the Western Arctic population — spawn as adfluvial migrants +in cold, low-gradient tributaries such as the Misinchinka and Anzac. Arctic +grayling, at the southern edge of their range in the Williston watershed, hold +to cooler, larger (fourth-order and up) clear-water reaches and spawn over fine +gravels. + +`link` is layered on the canonical +[`fwapg`](https://github.com/smnorris/fwapg) / +`bcfishpass` / [`bcfishobs`](https://github.com/smnorris/bcfishobs) stack from +Simon Norris (Hillcrest Geographics). What `link` adds is a configuration-driven +re-expression of the same modelling: we can experiment with different +configurations for species bcfishpass already models, and extend to species it +does not — here, Arctic grayling for the **Fish Passage Peace 2025** program — +while staying byte-checkable against the upstream reference. + +## Modelling parameters + +Per-segment `mapping_code` is assembled from access, spawning, and rearing +classifications. Access is gated by a per-species **maximum** gradient; spawning +and rearing are then gated by their own **maximum** gradients and a minimum +channel width. The values in force for this run are below. + +```{r params, echo = FALSE} +params <- data.frame( + species = c("BT", "GR"), + configuration = c("bcfishpass (parity)", "default (link extension)"), + access_grad_max = c(0.25, 0.15), + spawn_grad_max = c(0.0549, 0.0249), + rear_grad_max = c(0.1049, 0.0349), + spawn_cw_min_m = c(2, 4), + rear_cw_min_m = c(1.5, 1.5), + stringsAsFactors = FALSE +) +knitr::kable( + params, row.names = FALSE, + col.names = c("species", "configuration", "access grad max", + "spawn grad max", "rear grad max", "spawn CW min (m)", + "rear CW min (m)"), + caption = paste0( + "Access, spawning, and rearing gradient ceilings and minimum channel ", + "widths for the two species mapped in this vignette. Bull trout is ", + "modelled by both bcfishpass and link's bcfishpass config (the parity ", + "case); Arctic grayling is modelled only by link's default config (the ", + "extension). Grayling's lower access ceiling (0.15 vs 0.25) and narrower ", + "spawning/rearing gradient windows are what give it the smaller modelled ", + "network seen below." + ) +) +``` + +Each segment's `mapping_code` is a compact token of the form +`;`, optionally suffixed `;INTERMITTENT` for intermittent +streams. The first field is the highest-value habitat use modelled for that +segment — `SPAWN`, `REAR`, or `ACCESS` (reachable, but no modelled spawning or +rearing habitat). The second records the most significant barrier *downstream* +of the segment: `NONE` (none known), `MODELLED` (a modelled potential barrier), +`ASSESSED` (a field-assessed known barrier), `DAM`, or `REMEDIATED` (a barrier +since fixed). Stream colour is keyed on barrier status alone — a purple segment +sits below a dam, a red one below a field-assessed barrier — regardless of +habitat use, while line width encodes the habitat use itself: spawning reaches +draw thickest, rearing medium, access-only thinnest. Both the colours and the +widths are read straight from the bcfishpass symbology registry bundled in +`gq`, so the maps match a bcfishpass QGIS project exactly. + +## Cached inputs + +The model run and the bcfishpass comparison both require a populated +PostgreSQL/PostGIS database and a bcfishpass snapshot — neither of which +exists on the documentation-build CI. So they run **once, locally**, in +`data-raw/wsg_vignette_data.R` (generic — set `aoi` and re-run for any +watershed group), which caches its outputs to `inst/vignette-data/`. This +vignette only *loads* those artifacts; it never touches a database at build +time. Direct downloads from the repo (open in QGIS or any GDAL-aware tool): + +- [`pars.gpkg`](https://github.com/NewGraphEnvironment/link/raw/main/inst/vignette-data/pars.gpkg) + — vectors: `aoi` (WSG boundary), `streams` (per-segment `mapping_code_bt` + from the bcfishpass config + `mapping_code_gr` from the default config), + `waterbodies` (lakes + rivers + manmade), `named_streams`, and basemapping + 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 + +```{r load, include = FALSE} +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)) + +present <- sf::st_layers(gpkg)$name +lyr <- function(name) { + if (name %in% present) sf::st_read(gpkg, layer = name, quiet = TRUE) else NULL +} + +aoi <- lyr("aoi") +streams <- lyr("streams") +waterbodies <- lyr("waterbodies") +named_streams <- lyr("named_streams") +reserves <- lyr("reserves") +parks <- lyr("parks") +roads <- lyr("roads") +railways <- lyr("railways") + +# Context layers (reserves/parks/roads/railways) are pulled by intersection +# with the WSG boundary, so whole features can spill past it. Clip them to the +# boundary shape so nothing renders outside the basin. +clip_aoi <- function(x) { + if (is.null(x) || nrow(x) == 0L) return(x) + out <- suppressWarnings(sf::st_intersection(x, sf::st_union(sf::st_geometry(aoi)))) + if (nrow(out) == 0L) NULL else out +} +roads <- clip_aoi(roads) +railways <- clip_aoi(railways) +reserves <- clip_aoi(reserves) +parks <- clip_aoi(parks) +``` + +The model run itself — shown here for reference, not executed at build time — +is one call per configuration. The Peace study-area run that produced the +state this vignette reads modelled the drainage **most-downstream-watershed-group +first**, so a segment's downstream-dam (`;DAM`) tokens, which can live in an +adjacent watershed group, resolve correctly. A standalone single-group re-run +would diverge on exactly those cross-group segments, so the data-gen script +reads the persisted study-area state rather than recomputing it. + +```{r model-run, eval = FALSE} +conn <- lnk_db_conn() + +cfg_bcfp <- lnk_config("bcfishpass") # persists to schema `fresh` +loaded <- lnk_load_overrides(cfg_bcfp) +lnk_pipeline_run(conn, aoi = "PARS", cfg = cfg_bcfp, loaded = loaded, + mapping_code = TRUE) + +cfg_default <- lnk_config("default") # persists to `fresh_default` +cfg_default$pipeline$schema <- "fresh_default" +loaded_d <- lnk_load_overrides(cfg_default) +lnk_pipeline_run(conn, aoi = "PARS", cfg = cfg_default, loaded = loaded_d, + mapping_code = TRUE) +``` + +## Reproducing bcfishpass (parity) + +`lnk_compare_mapping_code()` compares `link`'s per-segment `mapping_code` +against the local bcfishpass snapshot, segment by segment, with no database +tunnel required. The comparison restricts itself to species that are actually +active in the watershed group — which, for the reasons above, is bull trout +alone in `PARS`. + +```{r parity-table, echo = FALSE} +ptab <- parity[, c("wsg", "species", "total_segs", "match_pct", + "n_diffs", "top_pattern", "top_pattern_count")] +names(ptab) <- c("WSG", "species", "segments", "match %", "n diffs", + "top diff pattern (link | bcfishpass)", "count") +knitr::kable( + ptab, row.names = FALSE, + caption = paste0( + "Per-segment mapping_code parity for bull trout in PARS, link's ", + "bcfishpass config vs the local bcfishpass snapshot. The top diff pattern ", + "column shows the most common (link | reference) disagreement, not a ", + "literal mapping_code value." + ) +) +``` + +```{r parity-pct, echo = FALSE, results = "asis"} +cat(sprintf(paste0("link reproduces **%.2f%%** of bcfishpass's per-segment ", + "bull-trout `mapping_code` across %s segments, with %s ", + "disagreements. That is consistent with the 99.66%% ", + "study-area median established for the Peace.\n"), + parity$match_pct[1], + format(parity$total_segs[1], big.mark = ","), + format(parity$n_diffs[1], big.mark = ","))) +``` + +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. + +```{r symbology, include = FALSE} +reg <- gq::gq_reg_main() +cls <- gq::gq_tmap_classes(reg$layers$streams_salmon) + +# bcfishpass line widths come from the same registry — habitat use drives line +# weight (SPAWN thick, REAR medium, ACCESS thin), barrier status drives colour. +# Stored raw here and scaled per map below (full-WSG views need thinner lines +# than the zoomed detail panels). Unmatched tokens fall to the thinnest weight. +width_for <- function(tokens) { + w <- cls$widths[tokens] + w[is.na(w)] <- min(cls$widths, na.rm = TRUE) + unname(w) +} + +# Context layers shared by the two full-WSG maps, drawn UNDER the +# mapping_code streams: waterbodies, resource roads, railways, then the +# land-use polygons (parks, reserves) on top of hydro/transport so they read +# where they overlap — the flooded layering. Reserves are tiny at WSG scale, +# so each gets a centroid diamond marker. +draw_context_full <- function() { + if (!is.null(waterbodies)) { + plot(sf::st_geometry(waterbodies), col = "#a3cdb966", border = "#2171B5", + lwd = 0.3, add = TRUE) + } + if (!is.null(roads)) { + plot(sf::st_geometry(roads), col = "#88888888", lwd = 0.25, add = TRUE) + } + if (!is.null(railways)) { + plot(sf::st_geometry(railways), col = "black", lwd = 0.8, lty = "dashed", + add = TRUE) + } + if (!is.null(parks)) { + plot(sf::st_geometry(parks), col = "#639b5f55", border = "#33a02c", + lwd = 0.7, add = TRUE) + } + if (!is.null(reserves)) { + plot(sf::st_geometry(reserves), col = "#b2b2b288", border = "#232323", + lwd = 0.6, add = TRUE) + pts <- suppressWarnings(sf::st_centroid(sf::st_geometry(reserves), + of_largest_polygon = TRUE)) + plot(pts, pch = 23, bg = "#222222", col = "white", cex = 0.9, add = TRUE) + } +} + +# Reserve english_name labels with a thin white halo, drawn last with +# xpd = TRUE so an edge label isn't cropped at the plot region. +label_reserves <- function(x, cex = 0.45) { + if (is.null(x) || !"english_name" %in% names(x) || nrow(x) == 0L) { + return(invisible()) + } + pts <- suppressWarnings(sf::st_centroid(sf::st_geometry(x), + of_largest_polygon = TRUE)) + coords <- sf::st_coordinates(pts) + ofs_y <- par("cxy")[2] * 0.45 * 0.9 + halo_x <- par("cxy")[1] * 0.45 * 0.05 + halo_y <- par("cxy")[2] * 0.45 * 0.05 + ly <- coords[, 2] - ofs_y + for (dx in c(-1, 1)) { + text(coords[, 1] + dx * halo_x, ly, labels = x$english_name, + cex = cex, col = "white", font = 2, xpd = TRUE) + } + for (dy in c(-1, 1)) { + text(coords[, 1], ly + dy * halo_y, labels = x$english_name, + cex = cex, col = "white", font = 2, xpd = TRUE) + } + text(coords[, 1], ly, labels = x$english_name, cex = cex, col = "#111111", + font = 2, xpd = TRUE) +} + +# mapping_code legend drawn outside the plotting region (into the reserved +# right margin) so it never overlaps the network. +legend_mapping_code <- function(tokens) { + present <- names(cls$values) %in% unique(tokens) + legend("topright", inset = c(-0.34, 0), xpd = TRUE, title = "mapping_code", + legend = cls$labels[present], col = cls$values[present], + lwd = 2, cex = 0.55, bty = "n") +} +``` + +```{r map-bt, echo = FALSE, fig.cap = "Bull-trout per-segment mapping_code across the Parsnip River Watershed Group, link's bcfishpass configuration. Stream colours come straight from the bcfishpass symbology registry bundled in gq, so they match a bcfishpass QGIS project exactly. Context: lakes/rivers/manmade waterbodies (light blue), provincial parks (green), First Nations reserves (grey polygon + black diamond + label), resource roads (grey), railways (black dashed); the heavy black line is the watershed-group boundary."} +bt <- streams[!is.na(streams$mapping_code_bt) & nzchar(streams$mapping_code_bt), ] +bt$col <- cls$values[bt$mapping_code_bt] +bt$col[is.na(bt$col)] <- "#999999" +bt$w <- width_for(bt$mapping_code_bt) + +op <- par(mar = c(2, 1, 4, 8)) +plot(sf::st_geometry(aoi), col = NA, border = "black", lwd = 1.5, axes = FALSE, + main = "PARS — bull trout mapping_code\n(link bcfishpass config)") +draw_context_full() +plot(sf::st_geometry(bt), col = bt$col, lwd = bt$w * 0.6, add = TRUE) +plot(sf::st_geometry(aoi), col = NA, border = "black", lwd = 1.5, add = TRUE) +label_reserves(reserves) +legend_mapping_code(bt$mapping_code_bt) +par(op) +``` + +## Arctic grayling — a link extension + +bcfishpass does not model Arctic grayling, so there is nothing to compare +against; this is net-new output. `link`'s `default` configuration carries `GR` +in its species dimensions, and the same six-phase pipeline that produced the +bull-trout parity above produces a per-segment `mapping_code` for grayling. +The map below is rendered with the **same** gq symbology registry — the 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 model. Same gq registry symbology 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."} +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" +gr$w <- width_for(gr$mapping_code_gr) + +op <- par(mar = c(2, 1, 4, 8)) +plot(sf::st_geometry(aoi), col = NA, border = "black", lwd = 1.5, axes = FALSE, + main = "PARS — Arctic grayling mapping_code\n(link default config)") +draw_context_full() +plot(sf::st_geometry(gr), col = gr$col, lwd = gr$w * 0.6, add = TRUE) +plot(sf::st_geometry(aoi), col = NA, border = "black", lwd = 1.5, add = TRUE) +label_reserves(reserves) +legend_mapping_code(gr$mapping_code_gr) +par(op) +``` + +## Maps — detail comparison + +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 +grayling classification with no bull-trout classification — the reaches where +the extension is doing genuinely new work. + +```{r map-detail, echo = FALSE, fig.width = 7, fig.height = 4.5, fig.cap = "South-east corner of the Parsnip River Watershed Group at full resolution — the headwaters near the continental divide: bull trout (left, link bcfishpass config) and Arctic grayling (right, link default config), same extent, same gq symbology. Grey background streams are the full modelled network, so the coloured overlay shows where each species' classification reaches. Context: waterbodies (light blue), parks (green), reserves (grey + diamond), roads (grey), railways (black dashed), named streams (italic blue labels)."} +e <- sf::st_bbox(aoi) +inset_bbox <- sf::st_bbox(c( + xmin = unname(e["xmin"] + (e["xmax"] - e["xmin"]) * 0.55), + ymin = unname(e["ymin"]), + xmax = unname(e["xmax"]), + ymax = unname(e["ymin"] + (e["ymax"] - e["ymin"]) * 0.45) +), crs = sf::st_crs(aoi)) + +crop_sf <- function(x) { + if (is.null(x) || nrow(x) == 0L) return(NULL) + out <- suppressWarnings(sf::st_crop(x, inset_bbox)) + if (nrow(out) == 0L) NULL else out +} + +frame <- sf::st_as_sfc(inset_bbox) +all_d <- crop_sf(streams) +bt_d <- crop_sf(bt) +gr_d <- crop_sf(gr) +wb_d <- crop_sf(waterbodies) +aoi_d <- crop_sf(aoi) +roads_d <- crop_sf(roads) +rail_d <- crop_sf(railways) +res_d <- crop_sf(reserves) +parks_d <- crop_sf(parks) +ns_d <- crop_sf(named_streams) + +panel <- function(seg, title) { + plot(sf::st_geometry(frame), col = NA, border = NA, axes = FALSE, + main = title) + if (!is.null(wb_d)) { + plot(sf::st_geometry(wb_d), col = "#a3cdb988", border = "#2171B5", + lwd = 0.4, add = TRUE) + } + if (!is.null(all_d)) { + plot(sf::st_geometry(all_d), col = "#cccccc", lwd = 0.4, add = TRUE) + } + if (!is.null(roads_d)) { + plot(sf::st_geometry(roads_d), col = "#666666aa", lwd = 0.4, add = TRUE) + } + if (!is.null(rail_d)) { + plot(sf::st_geometry(rail_d), col = "black", lwd = 1.0, lty = "dashed", + add = TRUE) + } + if (!is.null(parks_d)) { + plot(sf::st_geometry(parks_d), col = "#639b5f55", border = "#33a02c", + lwd = 0.8, add = TRUE) + } + if (!is.null(res_d)) { + plot(sf::st_geometry(res_d), col = "#b2b2b2aa", border = "#232323", + lwd = 0.7, add = TRUE) + rpts <- suppressWarnings(sf::st_centroid(sf::st_geometry(res_d), + of_largest_polygon = TRUE)) + plot(rpts, pch = 23, bg = "#222222", col = "white", cex = 1.0, add = TRUE) + } + if (!is.null(seg) && nrow(seg) > 0L) { + plot(sf::st_geometry(seg), col = seg$col, lwd = seg$w, add = TRUE) + } + if (!is.null(aoi_d)) { + plot(sf::st_geometry(aoi_d), border = "black", lwd = 1.5, add = TRUE) + } + if (!is.null(ns_d)) { + dedup <- ns_d[!duplicated(ns_d$gnis_name), ] + npts <- suppressWarnings(sf::st_centroid(sf::st_geometry(dedup))) + text(sf::st_coordinates(npts), labels = dedup$gnis_name, cex = 0.45, + font = 3, col = "#0d3a6c") + } +} + +op <- par(mfrow = c(1, 2), mar = c(1, 1, 2, 1)) +panel(bt_d, "Bull trout") +panel(gr_d, "Arctic grayling") +par(op) +```