Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: link
Title: Stream Network Habitat Interpretation (Experimental)
Version: 0.40.5
Date: 2026-05-26
Version: 0.41.0
Date: 2026-05-27
Authors@R: c(
person("Allan", "Irvine", , "airvine@newgraphenvironment.com",
role = c("aut", "cre"),
Expand Down Expand Up @@ -30,12 +30,12 @@ Imports:
yaml
Remotes:
NewGraphEnvironment/crate,
NewGraphEnvironment/fresh@v0.31.0
NewGraphEnvironment/fresh@v0.32.0
Suggests:
bcdata,
digest,
dplyr,
fresh (>= 0.31.0),
fresh (>= 0.32.0),
lintr,
mockery,
sf,
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export(lnk_source)
export(lnk_stamp)
export(lnk_stamp_finish)
export(lnk_thresholds)
export(lnk_wsg_resolve)
import(DBI)
importFrom(RPostgres,Postgres)
importFrom(utils,read.csv)
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# link 0.41.0

New exported function `lnk_wsg_resolve()` — the bundle-aware "what WSGs should we model?" resolver ([#207](https://github.com/NewGraphEnvironment/link/issues/207)). Composes the FWA drainage closure (now a fresh primitive: `fresh::frs_wsg_drainage()`, [NewGraphEnvironment/fresh#211](https://github.com/NewGraphEnvironment/fresh/pull/212) / fresh v0.32.0) with the bundle's `wsg_species_presence` filter (link#157). Three call patterns dispatched by `(wsgs, expand)`: province mode (`wsgs = NULL` → all bundle-species WSGs, sorted alphabetically), closure mode (`wsgs = c(...), expand = TRUE` → focal + drainage closure, DS-first preserved), strict mode (`wsgs = c(...), expand = FALSE` → species-filter input verbatim). Validation mirrors `lnk_pipeline_species`; closure mode opens its own DB conn via `lnk_db_conn()` with `on.exit` cleanup; closure + strict modes emit `message()` listing any species-less WSGs dropped from the result (parity with the previous inline diagnostic). New `@family wsg` — pre-stages a `lnk_wsg_*` family for follow-on topology helpers (e.g. cross-host DS-first bucketing).

`data-raw/study_area_wsgs.R` shrinks 76 → 33 lines — pure CLI shim now, delegating to `lnk_wsg_resolve()`. Stdout is **byte-identical** for the regression baseline (`PARS,BULK` → the exact 15-WSG closure `KISP, KLUM, LKEL, LSKE, MSKE, USKE, BULK, FINA, LBTN, LPCE, MORR, PARA, PCEA, UPCE, PARS`), so `data-raw/study_area_run.sh` and downstream consumers are unchanged. fresh dependency pin: `Remotes: NewGraphEnvironment/fresh@v0.31.0 → @v0.32.0`. 22 tests added (`tests/testthat/test-lnk_wsg_resolve.R`): arg validation, stub-based province/strict (stub deliberately non-alphabetical so `sort()` is load-bearing), live-DB closure + province (gated on `skip_if_no_db()`).

# link 0.40.5

Tunnel-free per-segment `mapping_code` parity for the 3 FWCP study areas ([#175](https://github.com/NewGraphEnvironment/link/issues/175)) — 50 drainage-closed WSGs across Peace / Fraser / Skeena, authoritative median match 99.66% / mean 99.11% / 130 of 148 rows ≥99%. Built around a new `lnk_access()` export ([#205](https://github.com/NewGraphEnvironment/link/issues/205)) — the portable access builder that's the missing twin of `lnk_mapping_code`. Its `merge = TRUE` mode is the cheap post-consolidate recompute: rebuild only access + mapping_code from persisted streams/habitat/barriers (no streams segmentation or habitat classification re-derived), ~8× faster than the full-pipeline path (FINA 11.9 s wall vs ~90 s, identical bcfp parity). Methodology is now correctness-regardless-of-bucketing — distribute (any bucketing) → consolidate → recompute → compare — with the recompute as the correctness guarantee, bucketing as a speed knob.
Expand Down
126 changes: 126 additions & 0 deletions R/lnk_wsg_resolve.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#' Resolve the Set of Watershed Groups to Model
#'
#' Bundle-aware WSG resolver. Given a config + loaded overrides and an
#' optional focal set, returns the character vector of WSG codes that
#' should be modelled — composing FWA drainage closure (via
#' [fresh::frs_wsg_drainage()]) with the bundle's species-presence
#' filter (link#157).
#'
#' Three call patterns dispatched by `wsgs` + `expand`:
#'
#' - `wsgs = NULL` — *province mode*: every WSG in
#' `loaded$wsg_species_presence` that has at least one of
#' `cfg$species` flagged present.
#' - `wsgs = c(...)` + `expand = TRUE` (default) — *closure mode*:
#' expand the focal set to its drainage closure (focal + every WSG
#' they flow through, ordered downstream-first), then species-filter.
#' Opens a connection via [lnk_db_conn()] and closes it on exit.
#' - `wsgs = c(...)` + `expand = FALSE` — *strict mode*: species-filter
#' the input verbatim, no closure expansion, no DB.
#'
#' Species filter: a WSG is kept if *any* of `tolower(cfg$species)`
#' columns in `loaded$wsg_species_presence` carries `"t"` (or `"TRUE"` /
#' `TRUE`, defensively). DS-first ordering from the closure is preserved.
#'
#' @param cfg An `lnk_config` object from [lnk_config()].
#' @param loaded Named list of tibbles from [lnk_load_overrides()].
#' Must carry `wsg_species_presence`.
#' @param wsgs Character vector of focal WSG codes, or `NULL` (default)
#' for province mode. Codes are upper-cased internally before use.
#' @param expand Logical. When `wsgs` is non-`NULL`, `TRUE` (default)
#' closure-expands via [fresh::frs_wsg_drainage()]; `FALSE` uses the
#' input as-is (species-filter only).
#'
#' @return Character vector of WSG codes. Province mode returns the
#' species-filtered set sorted alphabetically; closure mode preserves the
#' downstream-first order from [fresh::frs_wsg_drainage()]; strict mode
#' preserves the caller-provided focal order. WSGs dropped by the
#' species filter (closure / strict modes) are reported via `message()`.
#'
#' @family wsg
#'
#' @export
#'
#' @examples
#' \dontrun{
#' cfg <- lnk_config("bcfishpass")
#' loaded <- lnk_load_overrides(cfg)
#'
#' # Province mode — all bundle-species WSGs
#' lnk_wsg_resolve(cfg, loaded)
#'
#' # Study-area mode — focal + drainage closure (default)
#' lnk_wsg_resolve(cfg, loaded, wsgs = c("PARS", "BULK"))
#' #> [1] "KISP" "KLUM" "LKEL" "LSKE" "MSKE" "USKE" "BULK" "FINA"
#' #> "LBTN" "LPCE" "MORR" "PARA" "PCEA" "UPCE" "PARS"
#'
#' # Strict mode — exactly these, species-filtered, no closure
#' lnk_wsg_resolve(cfg, loaded, wsgs = c("BBAR", "BULK"), expand = FALSE)
#' }
lnk_wsg_resolve <- function(cfg, loaded, wsgs = NULL, expand = TRUE) {
if (!inherits(cfg, "lnk_config")) {
stop("cfg must be an lnk_config object (from lnk_config())",
call. = FALSE)
}
if (!is.list(loaded)) {
stop("loaded must be a named list (from lnk_load_overrides())",
call. = FALSE)
}
if (!is.null(wsgs)) {
bad <- !is.character(wsgs) || length(wsgs) == 0L ||
anyNA(wsgs) || !all(nzchar(wsgs))
if (bad) {
stop("wsgs must be NULL or a non-empty character vector free of NA",
call. = FALSE)
}
}
if (!is.logical(expand) || length(expand) != 1L || is.na(expand)) {
stop("expand must be a single logical (TRUE or FALSE)", call. = FALSE)
}

wp <- loaded$wsg_species_presence
if (is.null(wp) || !nrow(wp)) {
stop("loaded$wsg_species_presence is missing or empty — ",
"did `lnk_load_overrides(cfg)` populate it?", call. = FALSE)
}
spp_cols <- tolower(cfg$species %||%
unique(loaded$parameters_fresh$species_code))
missing_cols <- setdiff(spp_cols, names(wp))
if (length(missing_cols)) {
stop("loaded$wsg_species_presence missing species columns: ",
paste(missing_cols, collapse = ", "), call. = FALSE)
}
has_spp <- apply(wp[, spp_cols, drop = FALSE], 1,
function(r) any(r %in% c("t", "TRUE", TRUE)))
modelable <- wp$watershed_group_code[has_spp]

# Province mode --------------------------------------------------------
if (is.null(wsgs)) return(sort(modelable))

focal <- toupper(wsgs)

# Strict mode ----------------------------------------------------------
if (!expand) {
kept <- focal[focal %in% modelable]
dropped <- setdiff(focal, kept)
if (length(dropped)) {
message("lnk_wsg_resolve: dropped ", length(dropped),
" species-less WSG(s): ", paste(dropped, collapse = ", "))
}
return(kept)
}

# Closure mode ---------------------------------------------------------
conn <- lnk_db_conn()
on.exit(try(DBI::dbDisconnect(conn), silent = TRUE), add = TRUE)
closure <- fresh::frs_wsg_drainage(conn, focal)
# Preserve DS-first order from frs_wsg_drainage by indexing closure,
# not the modelable set
kept <- closure[closure %in% modelable]
dropped <- setdiff(closure, kept)
if (length(dropped)) {
message("lnk_wsg_resolve: dropped ", length(dropped),
" species-less closure WSG(s): ", paste(dropped, collapse = ", "))
}
kept
}
56 changes: 8 additions & 48 deletions data-raw/study_area_wsgs.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,12 @@
# study_area_wsgs.R — given a set of FOCAL watershed groups, print the
# drainage-CLOSED, MODELABLE set in DOWNSTREAM-FIRST order (one comma line).
#
# Closure: every WSG whose outlet wscode_ltree is an ancestor of (== at or
# downstream of) any focal WSG's outlet — i.e. the WSGs a focal WSG's water
# drains through. DS-first: ordered by outlet ltree depth ascending, so the
# most-downstream WSGs come first. Running a host's bucket in this order
# persists downstream dam barriers before upstream WSGs compute access, which
# is what makes cross-WSG `;DAM` correct from the per-host run (no recompute).
# Thin CLI shim around [link::lnk_wsg_resolve()] — see `?lnk_wsg_resolve`
# for the methodology (FWA drainage closure via fresh::frs_wsg_drainage()
# composed with the bundle's wsg_species_presence filter, link#157).
#
# MODELABLE filter (link#157, mirrors data-raw/wsgs_run_host.R): drop closure
# WSGs with no bundle-species presence. lnk_pipeline_run errors hard ("No
# species resolved for AOI") on a species-less WSG (e.g. lower-mainstem groups
# pulled in by closure), which would abort the whole host run. bcfp doesn't
# model those WSGs either, so excluding them matches the proven methodology.
#
# Sources of truth: public.wsg_outlet (closure) + loaded$wsg_species_presence
# (modelable), both in fwapg / the bundle.
# Stdout: one line — comma-separated WSG codes (DS-first). Used by
# `data-raw/study_area_run.sh` to seed per-host buckets.
#
# Usage: [LNK_LOAD=loadall] Rscript study_area_wsgs.R <FOCAL1,FOCAL2,...> [config]

Expand All @@ -33,42 +24,11 @@ if (identical(Sys.getenv("LNK_LOAD"), "loadall")) {
} else {
suppressPackageStartupMessages(library(link))
}
suppressPackageStartupMessages({
library(DBI); library(RPostgres)
})
conn <- DBI::dbConnect(RPostgres::Postgres(), host = "localhost", port = 5432,
dbname = "fwapg", user = "postgres", password = "postgres")
on.exit(try(DBI::dbDisconnect(conn), silent = TRUE), add = TRUE)

# 1. Drainage closure, DS-first.
focal_lit <- paste(DBI::dbQuoteLiteral(conn, focal), collapse = ", ")
q <- sprintf("
SELECT DISTINCT w.wsg, nlevel(w.outlet) AS depth
FROM public.wsg_outlet w
JOIN public.wsg_outlet f ON f.wsg IN (%s)
WHERE f.outlet <@ w.outlet
ORDER BY depth ASC, w.wsg ASC", focal_lit)
res <- DBI::dbGetQuery(conn, q)
if (nrow(res) == 0L) {
stop("no closure found — are the focal WSGs present in public.wsg_outlet?",
call. = FALSE)
}

# 2. Modelable filter (link#157): keep only WSGs with bundle-species presence.
cfg <- lnk_config(config)
loaded <- lnk_load_overrides(cfg)
spp_cols <- tolower(cfg$species)
wp <- loaded$wsg_species_presence
has_spp <- apply(wp[, spp_cols, drop = FALSE], 1,
function(r) any(r %in% c("t", "TRUE", TRUE)))
modelable <- wp$watershed_group_code[has_spp]
cfg <- lnk_config(config)
loaded <- lnk_load_overrides(cfg)
keep <- lnk_wsg_resolve(cfg, loaded, wsgs = focal)

keep <- res$wsg[res$wsg %in% modelable] # preserves DS-first order
dropped <- setdiff(res$wsg, keep)
if (length(dropped) > 0L) {
message(sprintf("[study_area_wsgs] dropped %d species-less closure WSG(s): %s",
length(dropped), paste(dropped, collapse = ",")))
}
if (length(keep) == 0L) {
stop("no modelable WSGs after species-presence filter", call. = FALSE)
}
Expand Down
71 changes: 71 additions & 0 deletions man/lnk_wsg_resolve.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
11 changes: 11 additions & 0 deletions planning/archive/2026-05-issue-207-lnk-wsg-resolve/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Issue #207 — lnk_wsg_resolve + study_area_wsgs.R → CLI shim

## Outcome

Added `lnk_wsg_resolve(cfg, loaded, wsgs = NULL, expand = TRUE)` — the bundle-aware WSG resolver that composes `fresh::frs_wsg_drainage()` (the FWA drainage-closure primitive from NewGraphEnvironment/fresh#211 / v0.32.0) with the bundle's `wsg_species_presence` filter (link#157). Three call patterns: province (`wsgs = NULL`, sorted alphabetically), closure (`wsgs + expand = TRUE`, focal + drainage DS-first), strict (`wsgs + expand = FALSE`, species-filter input verbatim). Closure + strict modes emit `message()` listing any species-less WSGs dropped — preserving the diagnostic the old script had inline. New `@family wsg` pre-stages the family for future `lnk_wsg_*` helpers.

`data-raw/study_area_wsgs.R` shrank from 76 → 33 lines — pure CLI shim now, delegating to `lnk_wsg_resolve()`. **Byte-identical stdout** for the regression baseline (`PARS,BULK` → the 15-WSG `KISP, KLUM, LKEL, LSKE, MSKE, USKE, BULK, FINA, LBTN, LPCE, MORR, PARA, PCEA, UPCE, PARS`), so `data-raw/study_area_run.sh` is unaffected. fresh dependency pin bumped `Remotes: fresh@v0.31.0 → @v0.32.0`. 22 tests added (`tests/testthat/test-lnk_wsg_resolve.R`); /code-check ran 2 rounds on the function (3 findings → all fixed: undocumented province ordering, silent strict-mode drops, silent closure-mode drops) and 2 rounds on the tests (1 finding → stub deliberately reordered so `sort()` is load-bearing).

Released as **v0.41.0**.

Closed by: commits `196fd63` (Phase 1: fresh dep bump), `c7ae248` (Phase 2: function), `9a95081` (Phase 3: tests), `bb1a6ab` (Phase 4: shim), `c0735f3` (Release v0.41.0). PR forthcoming via `/gh-pr-push`.
Loading