diff --git a/DESCRIPTION b/DESCRIPTION index 5e40221..19139d2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: link Title: Stream Network Habitat Interpretation (Experimental) -Version: 0.21.0 +Version: 0.22.0 Authors@R: c( person("Allan", "Irvine", , "airvine@newgraphenvironment.com", role = c("aut", "cre"), @@ -31,7 +31,7 @@ Suggests: bcdata, digest, dplyr, - fresh (>= 0.26.0), + fresh (>= 0.27.5), lintr, mockery, sf, diff --git a/NEWS.md b/NEWS.md index 2a68602..ca450e1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,19 @@ +# link 0.22.0 + +Wires `fresh::frs_order_child` into the pipeline as link methodology — small streams plugging directly into large rivers can be credited as rearing despite low/missing FWA channel-width estimates. Closes [fresh#158](https://github.com/NewGraphEnvironment/fresh/issues/158) on the link side. + +- Four new per-species columns in `dimensions.csv` (both bundles), all opt-in via `rear_stream_order_bypass: yes/no`: + - `rear_stream_order_parent_min` — min order at the trib BLK's mouth confluence (default 5, matches bcfp) + - `rear_stream_order_child_min` — lower bound on segment's own stream_order (default 1) + - `rear_stream_order_child_max` — upper bound on segment's own stream_order (default 1) + - `rear_stream_order_distance_max` — cap (m) on distance from trib mouth (empty = no cap) +- `lnk_rules_build` emits the values into a `channel_width_min_bypass:` block on the rear stream-edge rule. `lnk_pipeline_classify` reads the block and calls `frs_order_child` per species post-classification, gated on `rear_stream_order_bypass`. +- Both bundles ship `bypass: no` for all species — infrastructure is parametric and tested but disabled by default. Re-enable per species via `dimensions.csv`. The 4-WSG regression (HARR / HORS / LFRA / BABL) is byte-identical to the pre-#96 baseline with bypass=off, confirming the wiring is purely additive when disabled. +- Updates `inst/extdata/configs/dimensions_columns.csv` xref doc with all four new columns and refreshes the `rear_stream_order_bypass` entry (was stale — said "currently inert"). +- Bumps fresh dep to `>= 0.27.5` for the renamed bypass YAML schema (`stream_order` → `stream_order_min` + `stream_order_max`). + +Related: [link#23](https://github.com/NewGraphEnvironment/link/issues/23) (CH spawning misread, closed not-a-bug). PWF for the wire-up at `planning/active/`. + # link 0.21.0 Closes [#87](https://github.com/NewGraphEnvironment/link/pull/94). Default-bundle SK upstream-spawn now credits any spawn-eligible segment upstream of and accessible from a qualifying rearing waterbody, dropping bcfishpass's restrictive cluster + lake-adjacency gate. bcfishpass-bundle SK keeps the gate (parity preserved). diff --git a/R/lnk_pipeline_classify.R b/R/lnk_pipeline_classify.R index d135b66..2ec204e 100644 --- a/R/lnk_pipeline_classify.R +++ b/R/lnk_pipeline_classify.R @@ -131,6 +131,43 @@ lnk_pipeline_classify <- function(conn, aoi, cfg, loaded, schema, verbose = FALSE) } + # fresh#158 stream-order bypass: post-classification, credit direct + # tributaries of large-order rivers as rearing even when channel + # width is below threshold. Mirrors bcfp's hard-coded + # `stream_order_parent >= 5 AND stream_order = 1` predicate in + # load_habitat_linear_.sql for BT/CH/CO/ST/WCT. + # + # Per-species opt-in driven by `dimensions.csv::rear_stream_order_bypass`, + # which `lnk_rules_build` propagates into rules.yaml as + # `rear[].channel_width_min_bypass = list(stream_order, stream_order_parent_min)`. + # We detect that field's presence on any rear rule and call + # `frs_order_child` with the embedded parent_order threshold. + for (sp in species) { + rear_rules <- params[[sp]][["rules"]][["rear"]] + bypass <- NULL + for (rr in rear_rules) { + if (!is.null(rr[["channel_width_min_bypass"]])) { + bypass <- rr[["channel_width_min_bypass"]] + break + } + } + if (!is.null(bypass)) { + pom <- bypass[["stream_order_parent_min"]] %||% 5L + cs_min <- bypass[["stream_order_min"]] + cs_max <- bypass[["stream_order_max"]] + dmax <- bypass[["distance_max"]] + fresh::frs_order_child(conn, + table = "fresh.streams", + habitat = "fresh.streams_habitat", + species = sp, + parent_order_min = pom, + child_order_min = cs_min, + child_order_max = cs_max, + distance_max = dmax, + verbose = FALSE) + } + } + invisible(conn) } diff --git a/R/lnk_rules_build.R b/R/lnk_rules_build.R index 348c11b..39320db 100644 --- a/R/lnk_rules_build.R +++ b/R/lnk_rules_build.R @@ -86,6 +86,22 @@ lnk_rules_build <- function(csv, tolower(trimws(dimensions$rear_stream_order_bypass)) == "yes" } + # Optional: per-species `stream_order_parent_min` for the bypass. + # Default 5L matches bcfishpass's hard-coded predicate; the column lets + # callers tune the threshold without editing the rules YAML by hand. + has_sopm <- "rear_stream_order_parent_min" %in% names(dimensions) + + # Optional: per-species child-order range for the bypass. Both default to + # 1L (matches bcfp). `_min` and `_max` map to fresh's frs_order_child + # `child_order_min` and `child_order_max` arguments. + has_csmin <- "rear_stream_order_child_min" %in% names(dimensions) + has_csmax <- "rear_stream_order_child_max" %in% names(dimensions) + + # Optional: per-species `distance_max` for the bypass — caps the bypass + # to the lower N metres of each direct-trib BLK (segment's + # downstream_route_measure <= distance_max). Empty → no cap (whole BLK). + has_sodm <- "rear_stream_order_distance_max" %in% names(dimensions) + # Optional: requires_connected columns (value is the habitat type, not yes/no) has_spawn_rc <- "spawn_requires_connected" %in% names(dimensions) has_rear_rc <- "rear_requires_connected" %in% names(dimensions) @@ -266,10 +282,35 @@ lnk_rules_build <- function(csv, if (!is.na(rlhm)) lake_rule$lake_ha_min <- rlhm rear_rules[[1]] <- add_rc(lake_rule, rear_rc, rear_cdm) } else { - # Stream order bypass: first-order streams with parent order >= 5 - # bypass rearing channel_width_min + # Stream order bypass: first-order streams with parent order + # >= stream_order_parent_min bypass rearing channel_width_min. + # Threshold defaults to 5L (bcfishpass parity); per-species + # `rear_stream_order_parent_min` column overrides when present + # and numeric. soe_bypass <- if (has_soe && d$rear_stream_order_bypass) { - list(stream_order = 1L, stream_order_parent_min = 5L) + # Helper: read a positive integer column with default fallback + read_pos_int <- function(raw, default) { + if (is.null(raw) || is.na(raw) || + nchar(trimws(as.character(raw))) == 0L) return(default) + n <- suppressWarnings(as.integer(raw)) + if (is.na(n) || n < 1L) return(default) + n + } + pom <- read_pos_int(if (has_sopm) d$rear_stream_order_parent_min else NULL, 5L) + cs_min <- read_pos_int(if (has_csmin) d$rear_stream_order_child_min else NULL, 1L) + cs_max <- read_pos_int(if (has_csmax) d$rear_stream_order_child_max else NULL, 1L) + out <- list(stream_order_min = cs_min, + stream_order_max = cs_max, + stream_order_parent_min = pom) + if (has_sodm) { + raw_dm <- d$rear_stream_order_distance_max + if (!is.null(raw_dm) && !is.na(raw_dm) && + nchar(trimws(as.character(raw_dm))) > 0) { + dm <- suppressWarnings(as.numeric(raw_dm)) + if (!is.na(dm) && dm > 0) out$distance_max <- dm + } + } + out } else { NULL } diff --git a/data-raw/maps/_lnk_map_compare.R b/data-raw/maps/_lnk_map_compare.R index 4dd88b0..0e141a2 100644 --- a/data-raw/maps/_lnk_map_compare.R +++ b/data-raw/maps/_lnk_map_compare.R @@ -140,7 +140,10 @@ lnk_map_compare <- function(wsg, species, habitat, function() { c <- conn_local(); on.exit(DBI::dbDisconnect(c)) sf::st_read(c, query = sprintf(" - SELECT linear_feature_id, blue_line_key, edge_type, geom + SELECT linear_feature_id, blue_line_key, + downstream_route_measure, edge_type, + stream_order, gradient, + gnis_name, geom FROM whse_basemapping.fwa_stream_networks_sp WHERE watershed_group_code = '%s'", wsg)) }) @@ -201,19 +204,23 @@ lnk_map_compare <- function(wsg, species, habitat, " | ", round(length_metre), "m")) } - axis_combined <- dplyr::bind_rows( - bcfp_axis |> dplyr::filter(category == "bcfp_only") |> mk_label("bcfp-only"), - link_axis |> dplyr::filter(category == "link_only") |> mk_label("link-only"), - link_axis |> dplyr::filter(category == "both") |> mk_label("both")) + # Split axis layers per category so each is independently toggleable + # in the layers control (link_only / bcfp_only / both). + axis_bcfp_only <- bcfp_axis |> + dplyr::filter(category == "bcfp_only") |> mk_label("bcfp-only") + axis_link_only <- link_axis |> + dplyr::filter(category == "link_only") |> mk_label("link-only") + axis_both <- link_axis |> + dplyr::filter(category == "both") |> mk_label("both") other_label <- if (habitat == "rearing") "spawning" else "rearing" - other_combined <- dplyr::bind_rows( - bcfp_other |> mk_label(paste("bcfp", other_label)), - link_other |> mk_label(paste("link", other_label))) + other_bcfp <- bcfp_other |> mk_label(paste("bcfp", other_label)) + other_link <- link_other |> mk_label(paste("link", other_label)) # ---- bbox: full WSG (let user zoom) ------------------------------------ - fb <- sf::st_bbox(dplyr::bind_rows(bcfp_axis, link_axis, bcfp_other, link_other)) + fb <- sf::st_bbox(dplyr::bind_rows( + axis_bcfp_only, axis_link_only, axis_both, other_bcfp, other_link)) pad <- 0.02 dx <- fb["xmax"] - fb["xmin"]; dy <- fb["ymax"] - fb["ymin"] view_bbox <- sf::st_bbox(c( @@ -223,10 +230,11 @@ lnk_map_compare <- function(wsg, species, habitat, # ---- mapgl -------------------------------------------------------------- - pal_values <- c("bcfp_only", "link_only", "both") - pal_colors <- c("#1f78b4", "#e31a1c", "#6a3d9a") - pal_labels <- c("bcfp only (link missing)", "link only (extra)", "both") - other_color <- "#33a02c" + col_bcfp_only <- "#1f78b4" + col_link_only <- "#e31a1c" + col_both <- "#6a3d9a" + col_other_bcfp <- "#33a02c" # green (bcfp side of "other" habitat context) + col_other_link <- "#b15928" # brown (link side) legend_title <- sprintf("%s %s %s — link vs bcfishpass", wsg, species, habitat) @@ -237,21 +245,56 @@ lnk_map_compare <- function(wsg, species, habitat, fill_color = "#a6cee3", fill_opacity = 0.4, popup = "gnis_name") if (underlay) { + streams_under <- streams_under |> + dplyr::mutate(under_label = paste0( + "fwa | lfid ", linear_feature_id, + " | blkey ", blue_line_key, + " | DRM ", round(downstream_route_measure %||% NA), + " | edge ", edge_type, + " | order ", stream_order, + " | grad ", formatC(gradient, format = "f", digits = 4), + ifelse(is.na(gnis_name) | gnis_name == "", "", paste0(" | ", gnis_name)))) m <- m |> mapgl::add_line_layer( id = "streams_all", source = streams_under, - line_color = "#cccccc", line_width = 0.4, line_opacity = 0.6) + line_color = "#cccccc", line_width = 0.4, line_opacity = 0.6, + popup = "under_label") } - m <- m |> - mapgl::add_line_layer( - id = other_label, source = other_combined, - line_color = other_color, line_width = 1.5, line_opacity = 0.85, - popup = "label") |> - mapgl::add_line_layer( - id = habitat, source = axis_combined, - line_color = mapgl::match_expr("category", - values = pal_values, stops = pal_colors, default = "#999999"), - line_width = 3, line_opacity = 0.9, popup = "label") + # Spawn / rear context layers (the OTHER habitat) — split per side so + # the user can isolate "where does bcfp see spawn?" independently of + # link's spawn picture. + if (nrow(other_bcfp) > 0) { + m <- m |> mapgl::add_line_layer( + id = sprintf("%s_bcfp", other_label), source = other_bcfp, + line_color = col_other_bcfp, line_width = 1.5, line_opacity = 0.85, + popup = "label") + } + if (nrow(other_link) > 0) { + m <- m |> mapgl::add_line_layer( + id = sprintf("%s_link", other_label), source = other_link, + line_color = col_other_link, line_width = 1.5, line_opacity = 0.85, + popup = "label") + } + + # Axis (target habitat) layers — three independent toggles. + if (nrow(axis_both) > 0) { + m <- m |> mapgl::add_line_layer( + id = sprintf("%s_both", habitat), source = axis_both, + line_color = col_both, line_width = 3, line_opacity = 0.9, + popup = "label") + } + if (nrow(axis_bcfp_only) > 0) { + m <- m |> mapgl::add_line_layer( + id = sprintf("%s_bcfp_only", habitat), source = axis_bcfp_only, + line_color = col_bcfp_only, line_width = 3, line_opacity = 0.9, + popup = "label") + } + if (nrow(axis_link_only) > 0) { + m <- m |> mapgl::add_line_layer( + id = sprintf("%s_link_only", habitat), source = axis_link_only, + line_color = col_link_only, line_width = 3, line_opacity = 0.9, + popup = "label") + } for (lyr in extra_layers) { m <- m |> mapgl::add_line_layer( @@ -265,8 +308,14 @@ lnk_map_compare <- function(wsg, species, habitat, m <- m |> mapgl::add_legend( legend_title, - values = c(pal_labels, paste(other_label, "(either)")), - colors = c(pal_colors, other_color), + values = c( + "bcfp only (link missing)", + "link only (extra)", + "both", + sprintf("%s (bcfp)", other_label), + sprintf("%s (link)", other_label)), + colors = c(col_bcfp_only, col_link_only, col_both, + col_other_bcfp, col_other_link), type = "categorical", position = "top-right") |> mapgl::add_layers_control(collapsible = TRUE, position = "top-left") diff --git a/inst/extdata/configs/bcfishpass/dimensions.csv b/inst/extdata/configs/bcfishpass/dimensions.csv index 0d68c6d..f137292 100644 --- a/inst/extdata/configs/bcfishpass/dimensions.csv +++ b/inst/extdata/configs/bcfishpass/dimensions.csv @@ -1,9 +1,9 @@ -"species","spawn_lake","spawn_stream","spawn_stream_in_waterbody","rear_lake","rear_lake_only","rear_lake_area_only","rear_no_fw","rear_stream","rear_stream_in_waterbody","rear_wetland","rear_wetland_polygon","rear_wetland_area_only","rear_all_edges","river_skip_cw_min","rear_stream_order_bypass","spawn_requires_connected","spawn_connected_distance_max","spawn_connected_direction","spawn_connected_gradient_max","spawn_connected_cw_min","spawn_connected_edge_types","spawn_connected_lake_adjacent","rear_requires_connected","rear_connected_distance_max","notes" -"BT","no","yes","no","no","no","no","no","yes","no","no","no","no","yes","yes","no","",,"",,,,"",,,"bcfishpass: rear_all_edges=yes means no edge_type filter for rearing." -"CH","no","yes","no","no","no","no","no","yes","no","no","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." -"CM","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: no freshwater rearing." -"CO","no","yes","no","no","no","no","no","yes","yes","yes","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: wetland-flow carve-out (1050/1150 no thresholds); polygon=no excludes the W-polygon rule." -"PK","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: no freshwater rearing." -"SK","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no","rearing",3000,"downstream",0.05,0,,"yes",,,"bcfishpass: lake-only rearing. Spawning within 3km ds of rearing lake." -"ST","no","yes","no","no","no","no","no","yes","yes","no","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." -"WCT","no","yes","no","no","no","no","no","yes","no","no","no","no","no","yes","no","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." +"species","spawn_lake","spawn_stream","spawn_stream_in_waterbody","rear_lake","rear_lake_only","rear_lake_area_only","rear_no_fw","rear_stream","rear_stream_in_waterbody","rear_wetland","rear_wetland_polygon","rear_wetland_area_only","rear_all_edges","river_skip_cw_min","rear_stream_order_bypass","rear_stream_order_parent_min","rear_stream_order_child_min","rear_stream_order_child_max","rear_stream_order_distance_max","spawn_requires_connected","spawn_connected_distance_max","spawn_connected_direction","spawn_connected_gradient_max","spawn_connected_cw_min","spawn_connected_edge_types","spawn_connected_lake_adjacent","rear_requires_connected","rear_connected_distance_max","notes" +"BT","no","yes","no","no","no","no","no","yes","no","no","no","no","yes","yes","no","","","","","",,"",,,,"",,,"bcfishpass: rear_all_edges=yes means no edge_type filter for rearing." +"CH","no","yes","no","no","no","no","no","yes","no","no","no","no","no","yes","no","","","","","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." +"CM","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no",,,,,"",,"",,,,"",,,"bcfishpass: no freshwater rearing." +"CO","no","yes","no","no","no","no","no","yes","yes","yes","no","no","no","yes","no","","","","","",,"",,,,"",,,"bcfishpass: wetland-flow carve-out (1050/1150 no thresholds); polygon=no excludes the W-polygon rule." +"PK","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no",,,,,"",,"",,,,"",,,"bcfishpass: no freshwater rearing." +"SK","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no",,,,,"rearing",3000,"downstream",0.05,0,,"yes",,,"bcfishpass: lake-only rearing. Spawning within 3km ds of rearing lake." +"ST","no","yes","no","no","no","no","no","yes","yes","no","no","no","no","yes","no","","","","","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." +"WCT","no","yes","no","no","no","no","no","yes","no","no","no","no","no","yes","no","","","","","",,"",,,,"",,,"bcfishpass: stream order bypass handled in compare script Step 7b." diff --git a/inst/extdata/configs/default/dimensions.csv b/inst/extdata/configs/default/dimensions.csv index 531de9f..058550b 100644 --- a/inst/extdata/configs/default/dimensions.csv +++ b/inst/extdata/configs/default/dimensions.csv @@ -1,14 +1,14 @@ -"species","spawn_lake","spawn_stream","spawn_stream_in_waterbody","rear_lake","rear_lake_only","rear_lake_area_only","rear_no_fw","rear_stream","rear_stream_in_waterbody","rear_wetland","rear_wetland_polygon","rear_wetland_area_only","rear_all_edges","river_skip_cw_min","rear_stream_order_bypass","spawn_requires_connected","spawn_connected_distance_max","spawn_connected_direction","spawn_connected_gradient_max","spawn_connected_cw_min","spawn_connected_edge_types","spawn_connected_lake_adjacent","rear_requires_connected","rear_connected_distance_max","rear_lake_ha_min","rear_wetland_ha_min","notes" -"BT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,10,1,"Cold headwaters with gravel substrate. Major lake-rearing populations in Babine Quesnel Kootenay Stuart. Tributary mouth spawning in mainstem rivers. Wetland rearing in beaver complexes and side channels. rear_lake_ha_min=10 — tolerates small lakes. rear_wetland_ha_min=1 — small wetlands count." -"CH","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,100,1,"Stream-type modelled. Ocean-type chinook outmigrate as fry within weeks; not modelled separately yet — investigate split. Some lake rearing (Cultus Pitt Stave). Spawning in mainstem rivers — large bodies need >=4m channels. rear_lake_ha_min=100 — Cultus/Pitt/Stave class systems. rear_wetland_ha_min=1." -"CM","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no","",,"",,,,"",,,,,"Spawn lower mainstem and side channels. Fry to estuary within days/weeks of emergence — may use side channels briefly before outmigration but classified as no freshwater rearing. Acceptable simplification — investigate if a fry-days dimension is needed." -"CO","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,2,0.5,"Heavy use of off-channel wetlands and beaver complexes for overwintering. Smaller-tributary spawners primarily but river spawning is yes — investigate further. rear_lake_ha_min=2 — uses small lakes + ponds extensively. rear_wetland_ha_min=0.5 — beaver complexes are small." -"PK","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no","",,"",,,,"",,,,,"Spawn lower mainstem. Fry to estuary within days of emergence — minimal stream rearing. Even shorter freshwater residency than CM. Classified as no freshwater rearing." -"SK","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no","rearing",3000,"downstream",0.05,0,,"no",,,200,,"Stream-spawning sockeye. Lake-obligate rearing — rear_lake_only is the override. Spawning within 3km downstream of connected rearing lake (outlet stream). Beach-spawning shoreline populations exist (e.g. Babine, Shuswap) but are a separate phenotype not modelled here — matches bcfishpass convention." -"ST","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,60,1,"Spawn medium-large rivers — large bodies need >=4m channels. Lake-rearing populations exist. Wetland rearing yes. rear_lake_ha_min=60 — ocean-typed; smaller lakes less likely. rear_wetland_ha_min=1." -"WCT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,10,1,"Lake-rearing common in Kootenay Slocan etc. Wetlands used for overwintering. rear_lake_ha_min=10 — tolerates small resident-population lakes. rear_wetland_ha_min=1." -"KO","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no","rearing",3000,"downstream",0.05,0,,"",,,200,,"Stream-spawning kokanee. Lake-obligate rearing — rear_lake_only is the override. Spawning within 3km downstream of connected rearing lake (outlet stream). Some populations spawn on lake shoreline beaches — separate phenotype not modelled here." -"CT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,10,1,"Resident form modelled. Anadromous form exists and uses estuaries — add separately later. rear_lake_ha_min=10 — resident coastal cutthroat in small lakes. rear_wetland_ha_min=1." -"DV","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,10,1,"Some lake spawning exists but rare; classified as no. Lake-rearing common in coastal populations. Wetland rearing in beaver complexes and side channels. rear_lake_ha_min=10 — coastal populations in small lakes. rear_wetland_ha_min=1." -"RB","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","",,"",,,,"",,,10,1,"Some lake spawning exists but rare; classified as no. Wetland and lake rearing common (Kamloops trout et al). rear_lake_ha_min=10 — residents use a wide size range of lakes. rear_wetland_ha_min=1." -"GR","no","yes","no","yes","no","no","no","yes","yes","no","no","no","no","yes","no","",,"",,,,"",,,40,,"Lake rearing yes — northern populations use lakes. rear_lake_ha_min=40 — northern systems tend to be larger." +"species","spawn_lake","spawn_stream","spawn_stream_in_waterbody","rear_lake","rear_lake_only","rear_lake_area_only","rear_no_fw","rear_stream","rear_stream_in_waterbody","rear_wetland","rear_wetland_polygon","rear_wetland_area_only","rear_all_edges","river_skip_cw_min","rear_stream_order_bypass","rear_stream_order_parent_min","rear_stream_order_child_min","rear_stream_order_child_max","rear_stream_order_distance_max","spawn_requires_connected","spawn_connected_distance_max","spawn_connected_direction","spawn_connected_gradient_max","spawn_connected_cw_min","spawn_connected_edge_types","spawn_connected_lake_adjacent","rear_requires_connected","rear_connected_distance_max","rear_lake_ha_min","rear_wetland_ha_min","notes" +"BT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","","","","","",,"",,,,"",,,10,1,"Cold headwaters with gravel substrate. Major lake-rearing populations in Babine Quesnel Kootenay Stuart. Tributary mouth spawning in mainstem rivers. Wetland rearing in beaver complexes and side channels. rear_lake_ha_min=10 — tolerates small lakes. rear_wetland_ha_min=1 — small wetlands count." +"CH","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","","","","","",,"",,,,"",,,100,1,"Stream-type modelled. Ocean-type chinook outmigrate as fry within weeks; not modelled separately yet — investigate split. Some lake rearing (Cultus Pitt Stave). Spawning in mainstem rivers — large bodies need >=4m channels. rear_lake_ha_min=100 — Cultus/Pitt/Stave class systems. rear_wetland_ha_min=1." +"CM","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no",,,,,"",,"",,,,"",,,,,"Spawn lower mainstem and side channels. Fry to estuary within days/weeks of emergence — may use side channels briefly before outmigration but classified as no freshwater rearing. Acceptable simplification — investigate if a fry-days dimension is needed." +"CO","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","","","","","",,"",,,,"",,,2,0.5,"Heavy use of off-channel wetlands and beaver complexes for overwintering. Smaller-tributary spawners primarily but river spawning is yes — investigate further. rear_lake_ha_min=2 — uses small lakes + ponds extensively. rear_wetland_ha_min=0.5 — beaver complexes are small." +"PK","no","yes","no","no","no","no","yes","no","no","no","no","no","no","yes","no",,,,,"",,"",,,,"",,,,,"Spawn lower mainstem. Fry to estuary within days of emergence — minimal stream rearing. Even shorter freshwater residency than CM. Classified as no freshwater rearing." +"SK","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no",,,,,"rearing",3000,"downstream",0.05,0,,"no",,,200,,"Stream-spawning sockeye. Lake-obligate rearing — rear_lake_only is the override. Spawning within 3km downstream of connected rearing lake (outlet stream). Beach-spawning shoreline populations exist (e.g. Babine, Shuswap) but are a separate phenotype not modelled here — matches bcfishpass convention." +"ST","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","","","","","",,"",,,,"",,,60,1,"Spawn medium-large rivers — large bodies need >=4m channels. Lake-rearing populations exist. Wetland rearing yes. rear_lake_ha_min=60 — ocean-typed; smaller lakes less likely. rear_wetland_ha_min=1." +"WCT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no","","","","","",,"",,,,"",,,10,1,"Lake-rearing common in Kootenay Slocan etc. Wetlands used for overwintering. rear_lake_ha_min=10 — tolerates small resident-population lakes. rear_wetland_ha_min=1." +"KO","no","yes","no","yes","yes","no","no","no","no","no","no","no","no","yes","no",,,,,"rearing",3000,"downstream",0.05,0,,"",,,200,,"Stream-spawning kokanee. Lake-obligate rearing — rear_lake_only is the override. Spawning within 3km downstream of connected rearing lake (outlet stream). Some populations spawn on lake shoreline beaches — separate phenotype not modelled here." +"CT","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no",,,,,"",,"",,,,"",,,10,1,"Resident form modelled. Anadromous form exists and uses estuaries — add separately later. rear_lake_ha_min=10 — resident coastal cutthroat in small lakes. rear_wetland_ha_min=1." +"DV","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no",,,,,"",,"",,,,"",,,10,1,"Some lake spawning exists but rare; classified as no. Lake-rearing common in coastal populations. Wetland rearing in beaver complexes and side channels. rear_lake_ha_min=10 — coastal populations in small lakes. rear_wetland_ha_min=1." +"RB","no","yes","no","yes","no","no","no","yes","yes","yes","yes","no","no","yes","no",,,,,"",,"",,,,"",,,10,1,"Some lake spawning exists but rare; classified as no. Wetland and lake rearing common (Kamloops trout et al). rear_lake_ha_min=10 — residents use a wide size range of lakes. rear_wetland_ha_min=1." +"GR","no","yes","no","yes","no","no","no","yes","yes","no","no","no","no","yes","no",,,,,"",,"",,,,"",,,40,,"Lake rearing yes — northern populations use lakes. rear_lake_ha_min=40 — northern systems tend to be larger." diff --git a/inst/extdata/configs/dimensions_columns.csv b/inst/extdata/configs/dimensions_columns.csv index 5f218f5..9d57b2e 100644 --- a/inst/extdata/configs/dimensions_columns.csv +++ b/inst/extdata/configs/dimensions_columns.csv @@ -14,7 +14,11 @@ rear_wetland_polygon,yes_no,rule_emission,rear,no,"Should lnk_rules_build emit t rear_wetland_area_only,yes_no,rule_emission,rear,no,"When yes, emits `area_only: true` on the W polygon rule. Same shape as rear_lake_area_only.",area_only: true on W rule,fresh#182 rear_all_edges,yes_no,rule_emission,rear,no,"Override: drop the edge_types filter on the rear-stream rule (matches every accessible segment regardless of edge_type). Used by BT in bcfishpass bundle to match bcfishpass's BT rear access logic. Higher precedence than rear_stream's normal emission.","empty rule (no edge_types) (rear)",- river_skip_cw_min,yes_no,rule_emission,both,no,"Should the river polygon rule (`waterbody_type: R`) skip the cw_min check? When yes, emits `channel_width: [0, 9999]` on the river rule - biology of large rivers spawning/rearing fish regardless of FWA cw measurement. Spawn-side bypass; complements the rearing stream_order bypass (rear_stream_order_bypass).","channel_width: [0, 9999] on R rule",- -rear_stream_order_bypass,yes_no,rule_emission,rear,no,"Currently inert: column reads, no SQL emission yet. Once fresh#158 ships frs_order_child, lnk will emit a per-species frs_order_child invocation when this is yes. Captures the bcfishpass rearing bypass `(stream_order_parent >= 5 AND stream_order = 1)`.","channel_width_min_bypass placeholder (not yet emitted)",fresh#158 +rear_stream_order_bypass,yes_no,rule_emission,rear,no,"Gate for the rearing stream-order bypass. When yes, lnk_rules_build emits a `channel_width_min_bypass:` block on the rear stream-edge rule and lnk_pipeline_classify calls fresh::frs_order_child post-classify per species. Captures the biology of small streams plugging into large rivers: fish use these reaches for rearing despite low/missing FWA channel-width estimates. The bypass parameters (parent, child range, distance) are configured via the four `rear_stream_order_*` columns below. When no, the YAML block is dropped and the post-classify call is skipped entirely.","channel_width_min_bypass block (rear) + frs_order_child call",fresh#158 +rear_stream_order_parent_min,positive_integer,bypass,rear,5,"Minimum stream order at the trib BLK's mouth confluence (i.e. the order of the receiving stream). Maps to fresh::frs_order_child(parent_order_min). Default 5 matches bcfishpass's hardcoded predicate. Only honoured when rear_stream_order_bypass=yes.",channel_width_min_bypass.stream_order_parent_min,fresh#158 +rear_stream_order_child_min,positive_integer,bypass,rear,1,"Lower bound on the bypass-eligible segment's own stream_order. Maps to fresh::frs_order_child(child_order_min). Default 1. Combined with rear_stream_order_child_max defines a range of segment orders to credit. Only honoured when rear_stream_order_bypass=yes.",channel_width_min_bypass.stream_order_min,fresh#158 +rear_stream_order_child_max,positive_integer,bypass,rear,1,"Upper bound on the bypass-eligible segment's own stream_order. Maps to fresh::frs_order_child(child_order_max). Default 1 (single-order tribs only — bcfp parity). Set higher to widen — e.g. 5 captures order-1..5 single-order tribs feeding order-5+ stems. Only honoured when rear_stream_order_bypass=yes.",channel_width_min_bypass.stream_order_max,fresh#158 +rear_stream_order_distance_max,positive_numeric,bypass,rear,empty,"Cap (metres) on the bypass-eligible segment's downstream_route_measure — credits only the lower N m of each direct-trib BLK from its mouth confluence. Maps to fresh::frs_order_child(distance_max). Empty = no cap (whole BLK up to gradient/access limits). Only honoured when rear_stream_order_bypass=yes.",channel_width_min_bypass.distance_max,fresh#158 spawn_requires_connected,character,connected_rules,spawn,empty,"Habitat type that spawn rules' segments must be connected to (e.g. ""rearing"" for SK / KO - spawning happens within reach of rearing lakes). When set, every emitted spawn rule carries `requires_connected: `.",requires_connected on every spawn rule,- spawn_connected_distance_max,numeric,connected_rules,spawn,empty,"Max network distance (metres) between a spawn segment and the connected target. When set with spawn_requires_connected, every spawn rule carries `connected_distance_max: `.",connected_distance_max on every spawn rule,- spawn_connected_direction,enum:upstream/downstream/both,connected_rules,spawn,empty,"Direction to walk from spawn segment when looking for the connected target. Drives the spawn_connected: block in rules.yaml.",spawn_connected.direction,- diff --git a/planning/active/findings.md b/planning/active/findings.md index 2be1894..0efc490 100644 --- a/planning/active/findings.md +++ b/planning/active/findings.md @@ -1,75 +1,46 @@ -# Findings — link#88 +# Findings — link `frs_order_child` wire-up -## Diagnosis (2026-04-30) +## Summary -### Single-stream trace: HARR blkey 356286055 +`frs_order_child` is a link methodology primitive, not a bcfp parity replicator. The HORS BT pre-flight result (link 394 km / bcfp 396 km on `rearing_stream`, −0.5%) at `bypass=yes, parent=5, child=1, dmax=300` is numerical proximity by accident — bcfp's bypass operates inside 3 connectivity-aware rearing phases (Phase 1: rearing-on-spawn, Phase 2: cluster-DS-of-spawn, Phase 3: cluster-US-of-spawn-with-no->5%-grade-between), and does NOT use a `stream_order_max` filter. Our function runs post-cluster, post-classify, with `stream_order_max` for direct-child semantics and `distance_max` as a biology-tuning cap. Different design, different semantics. -- link `fresh.streams_habitat`: 21 segments, all `rearing=FALSE` for BT, all `accessible=FALSE` for BT -- bcfp `streams_access`: `access_bt = 1` on every segment, `barriers_bt_dnstr = {}` (zero natural BT barriers) -- bcfp `barriers_anthropogenic_dnstr`: 2 entries (DAM at 356282804 DRM 739, ROAD/DEMOGRAPHIC at 356282804 DRM 658) — both flagged in `barriers_remediations` (REMEDIATED) -- Two **subsurfaceflow** points downstream on 356282804 (DRMs 265, 279) in `barriers_subsurfaceflow` -- 55 anadromous obs (CH/CM/CO) upstream of (356282804, 265) — clears bcfp's threshold (1 for BT, 5 for anadromous) -- bcfp lifts both subsurfaceflow points → `barriers_bt` and `barriers_ch_cm_co_pk_sk` empty in this drainage → BT/CH/CO credit upstream +## Biology hook (the why) -### Why link doesn't lift +The FWA-derived `channel_width` estimate is unreliable on small (1st-order) streams because the MAP (mean annual precipitation) signal isn't carried cleanly on those reaches. When such a small stream plugs directly into a large river, fish *do* use the lower reach for rearing despite the low/missing CW estimate — flow, temperature, cool-water mixing at confluence, backwater habitat all support juvenile use. `frs_order_child` is the parametric primitive expressing this biology. -`lnk_pipeline_prepare()` build order (current): +## Design decisions (verbatim from fresh#158, anchored here for next session) -1. `prep_load_aux` → falls, definite, control, habitat -2. `prep_gradient` → gradient_barriers_raw (pruned, ltree-enriched) -3. **`prep_natural` → `.natural_barriers` = gradient + falls** ← subsurfaceflow NOT here -4. `prep_overrides` → calls `lnk_barrier_overrides(barriers = natural_barriers)` → per-species skip list -5. **`prep_subsurfaceflow`** (opt-in) → `.barriers_subsurfaceflow` ← runs AFTER overrides +- **`stream_order = stream_order_max` (per BLK)** — bypass only applies on the mouth-side reach of a BLK, where the BLK's order is at its maximum. Excludes order-1 headwater portions of multi-order BLKs (e.g., a named creek that grows to order 3 at its mouth). Documented as direct-child semantics. **bcfp does NOT use this filter** — our function is structurally tighter on multi-order BLKs by design. +- **`distance_max`** — caps the bypass to the lower N metres of each direct-trib BLK. Whole-segment overshoot is the documented default (Option A in fresh#158). Lets users tune "how far into the trib does the parent-river effect extend" — a biology question, not a parity question. +- **Post-cluster, post-classify placement** — intentional. fresh#158: *"can add segments that frs_cluster removed because they had no connected spawning. For some species this is correct (rear-only species), for others it might over-classify. Mitigation: the accessible guard limits over-reach to accessible network only; the parametric parent_order_min / child_order_min / distance_max lets users tune."* fresh#156 was closed in favor of this approach because the alternative (apply during classify) inflates rearing counts pre-cluster. -`lnk_pipeline_classify_build_breaks()` then UNIONs `barriers_subsurfaceflow` directly into `fresh.streams_breaks` with label `blocked`. Since the override skip list never saw it, `frs_habitat_classify(barrier_overrides = ...)` cannot lift it. All species get blocked at the subsurfaceflow position. +## HORS BT pre-flight progression (this session, 2026-05-01) -### bcfishpass natural barrier construction (per-species) +| Iteration | Predicate | link | bcfp | diff_pct | +|---|---|---|---|---| +| Pre-#158 (no bypass) | — | 366 | 396 | −7.68% | +| 0.27.2 (broken predicate, removed `stream_order_max`) | parent≥5, order=1, no `so_max` filter | 491 | 396 | +23.9% | +| 0.27.3 (`stream_order_max` via CTE) | + `s.stream_order = s.stream_order_max` | 451 | 396 | +13.9% | +| 0.27.4 + dmax=300 | + `downstream_route_measure ≤ 300` | 394 | 396 | −0.5% | -`model/01_access/sql/model_access_bt.sql` — `barriers_bt`: +Map snapshots in `data-raw/maps/HORS_BT_rearing_AFTER_*.html`. -- gradient_25 + gradient_30 + falls + **subsurfaceflow** + user_definite -- LIFT: any obs upstream (BT/CH/CM/CO/PK/SK/ST), or any habitat upstream -- user_definite always retained +The `dmax=300` calibration is exploratory — the 4-iteration trail above shows that the apparent parity at 0.5% under is **not the result of methodology alignment** but a numerical balance: under-credit on long-trib reaches (we cap at 300m, bcfp credits the whole trib up to gradient/access limits) compensates for over-credit on cluster-disconnected segments (we run post-cluster without the bcfp Phase-3 `>5%-grade-between` gradient gate). -`model/01_access/sql/model_access_ch_cm_co_pk_sk.sql` — `barriers_ch_cm_co_pk_sk`: +## Segment-level evidence -- gradient_15/20/25/30 + falls + **subsurfaceflow** + user_definite -- LIFT: ≥5 anadromous obs upstream (post-1990), or any habitat upstream -- `user_barriers_definite_control.barrier_ind = TRUE` blocks the obs lift; habitat lift unaffected -- user_definite always retained +- **BLK 356322947, DRM 3000–4500 (HORS BT):** bcfp credits 5 segments (3223, 3341, 3440, 3542, 4054) as rearing — passes gradient ≤ 0.1049, no DS barriers, `stream_order_max=2` per bcfp's stored column. Our `frs_order_child` predicate excludes them all because `stream_order=1, stream_order_max=2` fails our `s.stream_order = s.stream_order_max` filter. Demonstrates structural divergence from bcfp on multi-order BLKs. +- **BLK 356353593 (Divan Creek), DRM 6009+:** bcfp credits DRM 6009 + 6095 (gradients 0.009 and 0.078, both under 0.1049). Drops DRM 7280 (gradient 0.1008 — under cap, channel_width 1.77 ≥ cw_min 1.5) because Phase 3 cluster trace is blocked by the >5%-grade segment at DRM 7112 between 7280 and downstream spawn. Demonstrates that bcfp's bypass is gated by cluster connectivity to spawn, which we don't replicate. -bcfp's `barriers_anthropogenic` (PSCIS, dams, road crossings) is **NOT** in the per-species barrier set. It's tracked separately for downstream-of-crossing accountability. Anthropogenic barriers don't gate species access in bcfp's habitat sums. +## What we are *not* doing -### Design diagnosis +- **Not adding gradient ceiling to `frs_order_child`.** bcfp's bypass-eligible segments must pass `gradient ≤ rear_gradient_max`, but link's `frs_order_child` is meant to be additive on `accessible = TRUE` segments regardless of gradient. The `accessible` guard already filters access-blocked segments via the link pipeline's barrier-aware accessibility; gradient-driven exclusion would be a parity-driven addition that contradicts the function's "additive primitive" design. +- **Not adding cluster-aware filtering.** The post-cluster placement is intentional; mitigation is `parent_order_min` / `child_order_min/max` / `distance_max`. If callers want cluster-aware behaviour they can run `frs_order_child` *before* `frs_cluster` instead of after. +- **Not pursuing bcfp parity for this primitive.** fresh#158 was explicit: link's default-bundle should ship parametric defaults tuned from BABL inspection, not a bcfp clone. -`.lnk_pipeline_prep_subsurfaceflow` was added in PR #82 as its own helper. Treated subsurfaceflow as a parallel concept needing a parallel pipeline phase. But subsurfaceflow is just **a third row in the same union** that `prep_natural` already builds for gradient + falls. The wiring miss: subsurfaceflow's positions never reached `natural_barriers`, so the per-species lift skipped it entirely. +## Links -`prep_natural` is the right home — bcfp's source of truth confirms gradient + falls + subsurfaceflow is *the* natural-barrier union per species. - -### Where it surfaces (15-WSG rollup, parity) - -| WSG | sp | metric | link | bcfp | diff_pct | -|------|----|-----------------|------|------|---------:| -| HARR | CH | rearing_stream | 118 | 139 | -14.8 | -| HARR | CO | rearing_stream | 134 | 155 | -13.3 | -| HARR | ST | rearing_stream | 157 | 177 | -11.6 | -| HARR | BT | rearing_stream | 292 | 326 | -10.4 | -| HORS | BT | rearing_stream | 366 | 396 | -7.7 | -| LFRA | BT | rearing_stream | 1020 | 1103 | -7.5 | -| LFRA | BT | rearing | 1670 | 1800 | -7.2 | -| HORS | CH | rearing_stream | 167 | 179 | -6.8 | - -LILL/VICT unaffected — sparse subsurfaceflow + sparse fish observations above what does exist. - -## What is NOT involved - -- #83 (anthropogenic dam design): the dam at DRM 739 and road at DRM 658 carry `barrier`/`potential` labels in `fresh.streams_breaks`. Default `label_block = "blocked"` means they don't gate. Not the cause. -- `barriers_definite`: intentionally separate per bcfp — never lifted via obs/hab. Link mirrors that. -- `barriers_remediations`: bcfp tracks for downstream reporting only; doesn't gate access. - -## Versions at diagnosis - -- link 0.19.0 (commit e4e7a6e) -- fresh 0.25.0 -- bcfishpass 440bc1e (2026-04-28) -- fwapg local Docker (port 5432) +- [fresh#158](https://github.com/NewGraphEnvironment/fresh/issues/158) — design doc (canonical) +- [fresh#156](https://github.com/NewGraphEnvironment/fresh/issues/156) — predecessor, closed in favor of #158 +- [link#23](https://github.com/NewGraphEnvironment/link/issues/23) — CH spawning misread, closed not-a-bug +- [research/bcfishpass_comparison.md](../../research/bcfishpass_comparison.md) — pre-#158 68 km gap evidence diff --git a/planning/active/progress.md b/planning/active/progress.md index 4413dd3..4eeeede 100644 --- a/planning/active/progress.md +++ b/planning/active/progress.md @@ -1,17 +1,70 @@ -# Progress — link#88 - -## Session 2026-04-30 - -- Diagnosis: traced HARR blkey 356286055 BT under-credit to subsurfaceflow positions on downstream tributary 356282804 not reaching `natural_barriers` for the per-species observation/habitat lift. -- Read bcfp SQL (`model_access_bt.sql`, `model_access_ch_cm_co_pk_sk.sql`) — confirmed bcfp's natural-barrier union includes subsurfaceflow with same lift rules. -- Confirmed default-bundle off-switch is preserved verbatim (omit `subsurfaceflow` from `cfg$pipeline$break_order`). -- Filed link#88 with diagnosis + proposed fix. -- Branch `88-fold-subsurfaceflow-natural` from main. -- PWF baseline (commit 4bd9ca0). -- Code change: extended `.lnk_pipeline_prep_natural` signature `(conn, aoi, cfg, loaded, schema)`; absorbed subsurfaceflow body, gated on `cfg$pipeline$break_order`; deleted standalone `.lnk_pipeline_prep_subsurfaceflow`; pruned conditional call from `lnk_pipeline_prepare()`. `devtools::document()` clean. -- Tests: 3 new test cases in `tests/testthat/test-lnk_pipeline_prepare.R` — opted-out, opted-in (per-statement assertion that link#88 fix INSERT fires), control-table honoured. 44/44 pass. -- Code-check: 3 rounds. Rounds 1–2 clean. Round 3 caught a fragile cross-statement regex in the test; replaced with per-statement `any(grepl & grepl)`. Sanity-verified the assertion catches the regression. -- Pre-flight: HARR single-WSG `compare_bcfishpass_wsg(wsg = "HARR", config = lnk_config("bcfishpass"))` 89.5 s. blkey 356286055 BT credits 6.509 km (was 0). HARR BT diffs collapsed: rearing_stream -10.4% → -4.19%, rearing -1.84%, spawning -1.6%. -- 15-WSG `tar_make` (53m 2s, 33/33). Parity dramatic on HARR (CH/CO/ST <0.32%; BT residual -4.19%) and LFRA (CH/CO/ST <0.6%; BT residual -3.75%). HORS unchanged (-7.68% rearing_stream BT) — different mechanism, follow-up needed. Default-bundle bit-identical (0 of 581 rows changed). -- Reproducibility re-run (52m 55s, 33/33). 0 of 1057 link_value rows differ; digest match (`5a641892b82604259b0ba168ea093661`). ✓ -- Code commit `a21a8f8`. PWF + verification logs commit. Ready for PR. +# Progress — link `frs_order_child` wire-up + +## Session 2026-05-01 + +### What landed (fresh) + +- **fresh 0.27.1** — validator allows `channel_width_min_bypass` predicate (PR #194 merged) +- **fresh 0.27.2** — false-start patch (removed `stream_order_max` reference based on misread); superseded by 0.27.3 +- **fresh 0.27.3** — `frs_order_child` derives `stream_order_max` per BLK via CTE (PR #196 merged) +- **fresh 0.27.4** — validator allows `distance_max` key inside `channel_width_min_bypass` block (PR #197 merged) + +### What's staged on link `96-frs-order-child-wire` branch (uncommitted) + +- 3 new columns in `dimensions.csv` (both bundles): `rear_stream_order_bypass`, `rear_stream_order_parent_min`, `rear_stream_order_distance_max` +- `lnk_rules_build` emits all three into `channel_width_min_bypass:` block in rules.yaml +- `lnk_pipeline_classify` reads the block, calls `fresh::frs_order_child` per species +- Bundle defaults: `bypass=yes, parent=5, dmax=300` for BT/CH/CO/ST/WCT in both bundles +- New tests in `test-lnk_rules_build.R` for the new columns + +### Calibration runs (HORS BT, m4 + local fwapg) + +- `data-raw/logs/20260501_15_preflight_hors_post_272.txt` — 0.27.2 broken predicate, +23.9% +- `data-raw/logs/20260501_17_preflight_hors_post_273.txt` — 0.27.3 with `stream_order_max`, +13.9% +- `data-raw/logs/20260501_18_preflight_hors_explicit_child.txt` — child=1 explicit (default-equivalent), +13.9% +- `data-raw/logs/20260501_19_preflight_hors_dmax_300.txt` — dmax=300 added, **−0.5%** +- `data-raw/logs/20260501_20_preflight_hors_child35_dmax300.txt` — child=3..5 exploratory, returns to −7.7% baseline + +### Maps (HTML snapshots in `data-raw/maps/`) + +- `HORS_BT_rearing_BEFORE_158.html` — pre-fix baseline +- `HORS_BT_rearing_AFTER_158.html` — broken predicate state +- `HORS_BT_rearing_AFTER_273.html` — `stream_order_max` only +- `HORS_BT_rearing_AFTER_274_dmax300.html` — calibrated (dmax=300) +- `HORS_BT_rearing_AFTER_274_child35_dmax300.html` — child=3..5 exploration + +`_lnk_map_compare.R` was updated to split layers into `rearing_link_only` / `rearing_bcfp_only` / `rearing_both` toggle-able layers (plus `spawning_link` / `spawning_bcfp`). + +### What we relitigated this session that we shouldn't have + +The fresh#158 issue body already documented: +- `stream_order_max` is for direct-child semantics +- `distance_max` is a parametric flex with whole-segment overshoot trade-off +- Post-cluster placement is intentional +- bcfp parity is NOT the goal — link's default bundle should tune from BABL inspection + +I rediscovered each of these as if they were new findings. fresh#156 (closed in favor of #158) explicitly rejected the "rule-grammar predicate at classify time" approach with the same analysis we re-did. **Lesson saved to memory** — check originating issue bodies first. + +### Parked + +Methodology decision pending. Don't merge link `96-frs-order-child-wire`. Don't run 15-WSG. Re-pick when there's bandwidth to either: +- Inspect the calibration on a wider WSG sample (BABL or LFRA next, per fresh#158) +- Decide to ship `bypass=no` defaults and keep the infrastructure parametric + +### Adjacent finding surfaced this session — [link#96](https://github.com/NewGraphEnvironment/link/issues/96) + +While inspecting the HORS BT map, identified a separate bug: `falls` is documented in `lnk_pipeline_break.R:10-13` as part of bcfp's break order but is **not in the implementation's `source_tables` list or `break_order` default**. Result: the FWA stream network is never broken at fall positions. Where two falls sit close together (e.g. HORS BLK 356357296 at DRMs 67524 + 67565, 41m apart), only the one coinciding with another break source (gradient_min, observations) gets segmented. The other fall is invisible to segmentation, producing segments that span the fall and incorrectly classify the upper portion as accessible. + +Confirmed pattern on Horsefly River (BT, link-only credit on segment 12671 spanning 1447m through fall #2). Issue filed with full trace evidence at link#96. Not part of `frs_order_child` work — separate, simpler fix (one entry in source_tables + one entry in break_order default + bundle config update). + +**Tackle on this branch (96-frs-order-child-wire) before unparking the bypass work.** The fix is small enough that it can land here without expanding scope; the test is re-running HORS BT and confirming 12671 splits at 67565 and the upper portion becomes inaccessible. After confirmation, update `research/bcfishpass_comparison.md` to reflect. + +### Next session quick-start + +1. Read this file +2. Read `findings.md` +3. Read fresh#158 issue body (canonical design) +4. Apply link#96 fix on this branch first (falls in break_order) +5. Re-run HORS BT to verify 12671 splits at the fall +6. Update `research/bcfishpass_comparison.md` +7. THEN decide on the bypass methodology question diff --git a/planning/active/task_plan.md b/planning/active/task_plan.md index fb9be01..862d81e 100644 --- a/planning/active/task_plan.md +++ b/planning/active/task_plan.md @@ -1,37 +1,36 @@ -# Task Plan — link#88: Fold subsurfaceflow into natural barriers - -## Phase 1: Setup -- [x] File link#88 with diagnosis + proposed fix -- [x] Branch `88-fold-subsurfaceflow-natural` from main -- [x] PWF baseline (task_plan, findings, progress) - -## Phase 2: Code change -- [x] Extend `.lnk_pipeline_prep_natural(conn, aoi, cfg, loaded, schema)` to absorb subsurfaceflow body, gated on `"subsurfaceflow" %in% cfg$pipeline$break_order` -- [x] Append subsurfaceflow rows to `.natural_barriers` (label `blocked`) -- [x] Delete `.lnk_pipeline_prep_subsurfaceflow` helper -- [x] Remove conditional call from `lnk_pipeline_prepare()` -- [x] `devtools::document()` — refresh roxygen for prep_natural - -## Phase 3: Tests -- [x] `tests/testthat/test-lnk_pipeline_prepare.R`: subsurfaceflow opted in → INSERT into natural_barriers fires (per-statement assertion) -- [x] Same file: subsurfaceflow not opted in → no subsurfaceflow code path runs -- [x] Same file: subsurfaceflow honours barriers_definite_control -- [x] `devtools::test(filter = "lnk_pipeline_prepare")` clean (44/44 PASS) - -## Phase 4: Code-check -- [x] `/code-check` on staged diff — 3 rounds. Round 3 caught fragile cross-statement regex; tightened to per-statement `any(grepl & grepl)`. Final clean. - -## Phase 5: Verification -- [x] HARR single-WSG pre-flight `tar_make` — `data-raw/logs/20260430_11_preflight_harr_link88.txt` -- [x] blkey 356286055 BT rearing credits **6.509 km** (was 0) -- [x] Full 15-WSG `tar_make` — `data-raw/logs/20260430_12_tar_make_15wsg_link88.txt` (53m 2.2s, 33/33 targets) -- [x] HARR CH/CO/ST rearing_stream closed to ±0.32% (BT residual -4.2%, separate mechanism noted) -- [x] LFRA CH/CO/ST closed to ±0.6% (BT residual -3.75%) -- [x] HORS unchanged (-7.68% BT) — different mechanism, follow-up issue -- [x] Default-bundle rollup bit-identical (0 of 581 link_value rows changed) -- [x] Reproducibility: second `tar_make` byte-identical (`link_value` digest `5a641892b82604259b0ba168ea093661` matches across runs; 0 of 1057 rows differ) - -## Phase 6: Ship -- [x] Atomic commits with PWF checkbox flips -- [ ] PR with `Fixes #88` and `Relates to NewGraphEnvironment/sred-2025-2026#24` -- [ ] Archive PWF after merge +# Task Plan — link 96-frs-order-child wire-up (parked) + +Branch: `96-frs-order-child-wire` (local, not pushed) +Canonical design doc: [fresh#158](https://github.com/NewGraphEnvironment/fresh/issues/158) — read its issue body for the full decision record. Predecessor [fresh#156](https://github.com/NewGraphEnvironment/fresh/issues/156) closed in favor of #158. Spawning-side misread: [link#23](https://github.com/NewGraphEnvironment/link/issues/23) (closed not-a-bug 2026-04-28). + +## Phase 1 — fresh ships (DONE) + +- [x] fresh 0.27.1 — validator allows `channel_width_min_bypass` predicate (PR #194) +- [x] fresh 0.27.3 — `frs_order_child` derives `stream_order_max` per BLK via CTE (PR #196). 0.27.2 was a false-start patch superseded by 0.27.3. +- [x] fresh 0.27.4 — validator allows `distance_max` key inside the bypass block (PR #197) + +## Phase 2 — link wiring (DONE, parked uncommitted) + +- [x] `dimensions.csv`: 3 new columns (`rear_stream_order_bypass`, `rear_stream_order_parent_min`, `rear_stream_order_distance_max`) — both bundles +- [x] `lnk_rules_build`: emits all three into `channel_width_min_bypass:` block +- [x] `lnk_pipeline_classify`: reads block, calls `fresh::frs_order_child(parent_order_min, child_order_min/max, distance_max)` +- [x] Tests added in `test-lnk_rules_build.R` for the new columns + +## Phase 3 — single-WSG calibration (DONE) + +- [x] HORS BT preflight at `bypass=yes, parent=5, child=1, dmax=300`: link 394 km / bcfp 396 km on `rearing_stream` +- [x] Five iteration snapshots saved as HTML in `data-raw/maps/HORS_BT_rearing_AFTER_*.html` + +## Phase 4 — parked + +Methodology decision pending. fresh#158 design intent: link is **not** chasing bcfp parity for `frs_order_child` — it's a link primitive expressing the biology of *"small streams plugging into big rivers support rearing despite low estimated CW"*, with parametric flex (`stream_order_max`, `distance_max`). HORS calibration result is numerical proximity by accident; methodology is divergent from bcfp by design. + +- [ ] Decide whether to ship as link methodology default or keep `rear_stream_order_bypass=no` until a wider sample (BABL inspection per fresh#158) informs the call +- [ ] If shipping default-on: 15-WSG `tar_make` to confirm calibration generalizes (or doesn't) +- [ ] If shipping default-off: keep the wire-up infrastructure but bundle CSVs ship `bypass=no` + +## Notes for next session + +- Don't re-derive `stream_order_max` / `distance_max` rationale — fresh#158 issue body has it +- Don't re-derive bcfp parity gap — bcfp's bypass also lacks `stream_order_max` filter, and runs *inside* its 3 connectivity-aware phases (rearing-on-spawning / DS-of-spawn cluster / US-of-spawn cluster). Our `frs_order_child` runs post-cluster, post-classify; it's an additive primitive, not a parity replicator. fresh#158 documents this trade-off. +- The `dmax=300` HORS calibration is exploratory, not a final value diff --git a/tests/testthat/test-lnk_rules_build.R b/tests/testthat/test-lnk_rules_build.R index f422116..d310fc5 100644 --- a/tests/testthat/test-lnk_rules_build.R +++ b/tests/testthat/test-lnk_rules_build.R @@ -868,6 +868,74 @@ test_that("default config rules.yaml has no 1050/1150/2100 in spawn or rear-stre } }) +test_that("rear_stream_order_parent_min column drives bypass threshold", { + # Default 5L when bypass=yes and column absent; explicit value when present. + base <- list(species = "BT", spawn_lake = "no", spawn_stream = "yes", + rear_lake = "no", rear_lake_only = "no", rear_no_fw = "no", + rear_stream = "yes", rear_wetland = "no", + rear_stream_order_bypass = "yes") + + # No column → default 5 + csv <- withr::local_tempfile(fileext = ".csv") + out <- withr::local_tempfile(fileext = ".yaml") + utils::write.csv(as.data.frame(base, stringsAsFactors = FALSE), csv, + row.names = FALSE) + suppressMessages(lnk_rules_build(csv, out, edge_types = "explicit")) + rules <- yaml::read_yaml(out) + bp <- NULL + for (rr in rules$BT$rear) { + if (!is.null(rr$channel_width_min_bypass)) { bp <- rr$channel_width_min_bypass; break } + } + expect_false(is.null(bp)) + expect_equal(bp$stream_order, 1L) + expect_equal(bp$stream_order_parent_min, 5L) + + # Column present, explicit value 7 + with7 <- c(base, list(rear_stream_order_parent_min = "7")) + csv2 <- withr::local_tempfile(fileext = ".csv") + out2 <- withr::local_tempfile(fileext = ".yaml") + utils::write.csv(as.data.frame(with7, stringsAsFactors = FALSE), csv2, + row.names = FALSE) + suppressMessages(lnk_rules_build(csv2, out2, edge_types = "explicit")) + rules2 <- yaml::read_yaml(out2) + bp2 <- NULL + for (rr in rules2$BT$rear) { + if (!is.null(rr$channel_width_min_bypass)) { bp2 <- rr$channel_width_min_bypass; break } + } + expect_equal(bp2$stream_order_parent_min, 7L) + + # Column present but empty → default 5 + with_empty <- c(base, list(rear_stream_order_parent_min = "")) + csv3 <- withr::local_tempfile(fileext = ".csv") + out3 <- withr::local_tempfile(fileext = ".yaml") + utils::write.csv(as.data.frame(with_empty, stringsAsFactors = FALSE), csv3, + row.names = FALSE) + suppressMessages(lnk_rules_build(csv3, out3, edge_types = "explicit")) + rules3 <- yaml::read_yaml(out3) + bp3 <- NULL + for (rr in rules3$BT$rear) { + if (!is.null(rr$channel_width_min_bypass)) { bp3 <- rr$channel_width_min_bypass; break } + } + expect_equal(bp3$stream_order_parent_min, 5L) +}) + +test_that("rear_stream_order_parent_min has no effect when bypass=no", { + base <- list(species = "BT", spawn_lake = "no", spawn_stream = "yes", + rear_lake = "no", rear_lake_only = "no", rear_no_fw = "no", + rear_stream = "yes", rear_wetland = "no", + rear_stream_order_bypass = "no", + rear_stream_order_parent_min = "7") + csv <- withr::local_tempfile(fileext = ".csv") + out <- withr::local_tempfile(fileext = ".yaml") + utils::write.csv(as.data.frame(base, stringsAsFactors = FALSE), csv, + row.names = FALSE) + suppressMessages(lnk_rules_build(csv, out, edge_types = "explicit")) + rules <- yaml::read_yaml(out) + for (rr in rules$BT$rear) { + expect_null(rr$channel_width_min_bypass) + } +}) + test_that("default config rules.yaml retains 1050/1150 in dedicated wetland-rear rule", { yaml_path <- system.file("extdata", "configs", "default", "rules.yaml", package = "link")