Skip to content

Commit fe1b4d0

Browse files
author
Mike Johnson
committed
feat(invariants): add ngen slope/So/lake-consistency checks
Port three routing-attribute and lake-crosswalk checks into the ngen stage of hf_check_invariants(): * slope_valid -- channel `slope` must be strictly positive on every flowpath (routing requirement); an all-NA column reports as info (step not run). * So_valid -- channel-bottom slope `So` must be > 0 wherever it lands (flowpaths / flowlines / network attribute table). * lake_spatial_consistent -- a flowpath's stamped `lake_id` must lie within ~one lake-extent (1 km floor) of that lake, catching wbareacomi VAA mis-indexing without requiring exact polygon/flowline overlap. Document the ngen stage's optional inputs (nexus, flowlines, network, lakes) and the new checks in the @details roxygen.
1 parent d0a35ae commit fe1b4d0

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

R/hf_invariants.R

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,17 @@
3535
#' - `stage = "aggregated"`: `flowpaths` (sf), `divides` (sf), `network`
3636
#' (data.frame, optional).
3737
#' - `stage = "ngen"`: `flowpaths` (sf with `fp-` IDs), `divides` (sf with
38-
#' `cat-` IDs).
38+
#' `cat-` IDs), and optionally `nexus` (data.frame with `nexus_id` /
39+
#' `nexus_toid`, enabling the `fp -> nexus -> fp` DAG check), `flowlines`
40+
#' and/or `network` (a data.frame carrying the `So` channel-slope routing
41+
#' attribute), and `lakes` (sf with a `lake_id` column and waterbody
42+
#' polygons). When the relevant columns are present, the ngen stage adds:
43+
#' `slope_valid` and `So_valid` (channel `slope` / `So` must be strictly
44+
#' positive everywhere they appear, since routing requires it; an all-`NA`
45+
#' column reports as info rather than failing), and `lake_spatial_consistent`
46+
#' (every flowpath stamped with a `lake_id` must lie within ~one lake-extent,
47+
#' 1 km floor, of that lake -- catching `wbareacomi` VAA mis-indexing without
48+
#' demanding exact geometry overlap).
3949
#'
4050
#' @return Invisibly a list with `ok` (logical), `stage`, and `checks`
4151
#' (named list of per-check results).
@@ -309,6 +319,75 @@ hf_check_invariants <- function(stage, ..., strict = TRUE,
309319
flowpaths, divides, coverage_min)
310320
}
311321

322+
# -- slope_valid (ported from hfrefactor 2026-06) -------------------------
323+
# Channel routing needs a strictly positive slope on every flowpath. All-NA =
324+
# slope step not run for this build (info); once present, every value must > 0.
325+
if (!is.null(flowpaths) && "slope" %in% names(flowpaths)) {
326+
sl <- suppressWarnings(as.numeric(flowpaths$slope))
327+
if (all(is.na(sl))) {
328+
checks$slope_valid <- .hf_info("slope not computed (all NA) for this build")
329+
} else {
330+
checks$slope_valid <- .hf_ok(
331+
sum(is.na(sl)) == 0L && sum(sl < 0, na.rm = TRUE) == 0L &&
332+
sum(sl == 0, na.rm = TRUE) == 0L,
333+
sprintf("%d null + %d negative + %d zero slope(s) (routing needs slope > 0)",
334+
sum(is.na(sl)), sum(sl < 0, na.rm = TRUE), sum(sl == 0, na.rm = TRUE)))
335+
}
336+
}
337+
338+
# -- So_valid -- channel-bottom slope (routing attr) must be > 0 wherever it
339+
# lands (flowpaths / flowlines / network attribute table). .calculate_flow_atts
340+
# floors it; any non-positive So in output is a regression.
341+
if (!is.null(flowpaths)) {
342+
So_src <- c(
343+
if ("So" %in% names(flowpaths)) suppressWarnings(as.numeric(flowpaths$So)),
344+
if (!is.null(args$flowlines) && "So" %in% names(args$flowlines))
345+
suppressWarnings(as.numeric(args$flowlines$So)),
346+
if (!is.null(args$network) && "So" %in% names(args$network))
347+
suppressWarnings(as.numeric(args$network$So)))
348+
if (length(So_src) > 0L) {
349+
checks$So_valid <- .hf_ok(
350+
sum(is.na(So_src)) == 0L && sum(So_src < 0, na.rm = TRUE) == 0L &&
351+
sum(So_src == 0, na.rm = TRUE) == 0L,
352+
sprintf("%d null + %d negative + %d zero So value(s) (routing needs So > 0)",
353+
sum(is.na(So_src)), sum(So_src < 0, na.rm = TRUE),
354+
sum(So_src == 0, na.rm = TRUE)))
355+
}
356+
}
357+
358+
# -- lake_spatial_consistent -- the lake_id crosswalk (member_comid ->
359+
# wbareacomi) is authoritative, but NWM waterbody polygons and reference
360+
# flowlines are DIFFERENT geometries that rarely overlap, so exact intersection
361+
# is the wrong test. A stamp is invalid only when PHYSICALLY IMPOSSIBLE: the
362+
# reach lies more than ~one lake-extent (1 km floor) from its lake (genuine
363+
# wbareacomi VAA mis-index).
364+
lakes <- args$lakes
365+
if (!is.null(flowpaths) && !is.null(lakes) &&
366+
"lake_id" %in% names(flowpaths) && inherits(flowpaths, "sf") &&
367+
inherits(lakes, "sf") && nrow(lakes) > 0L) {
368+
has_lake <- !is.na(flowpaths$lake_id) & as.character(flowpaths$lake_id) != ""
369+
if (any(has_lake)) {
370+
n_bad <- tryCatch({
371+
fp_l <- flowpaths[has_lake, ]
372+
lk_geom <- sf::st_geometry(lakes)
373+
lk_chr <- as.character(lakes$lake_id)
374+
ext <- vapply(lk_geom, function(g) {
375+
b <- sf::st_bbox(g)
376+
sqrt((b[["xmax"]] - b[["xmin"]])^2 + (b[["ymax"]] - b[["ymin"]])^2)
377+
}, numeric(1L))
378+
li <- match(as.character(fp_l$lake_id), lk_chr)
379+
d <- as.numeric(sf::st_distance(
380+
sf::st_geometry(fp_l), lk_geom[li], by_element = TRUE))
381+
sum(is.finite(d) & d > pmax(1000, ext[li]))
382+
}, error = function(e) NA_integer_)
383+
checks$lake_spatial_consistent <- if (is.na(n_bad))
384+
.hf_info("lake_spatial_consistent: not computable (geometry error)")
385+
else .hf_ok(n_bad == 0L, sprintf(
386+
"%d flowpath(s) stamped with a lake_id physically too far from it (>1 lake-extent; wbareacomi VAA mis-index)",
387+
n_bad))
388+
}
389+
}
390+
312391
checks
313392
}
314393

man/hf_check_invariants.Rd

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)