Skip to content

Commit d69deae

Browse files
author
Mike Johnson
committed
Add physical-range attribute bounds check to invariant base layer
Adds a per-attribute physical-plausibility pass complementing the existing topology/geometry invariants: - hf_attr_bounds(): curated bounds table (inst/extdata/divide_attr_bounds.csv) giving the physically reasonable range for each divide/flowpath modeling parameter. A `caveat` column flags bounds that are too strict (strict-zero lowers that reject valid flat/zero values) or look like placeholders; those are excluded from checks by default. An `aliases` column (;-separated) lets a single bound match alternate column names, so one row covers schema naming differences (e.g. lengthkm matches length_km). - hf_check_attr_bounds(): standalone validator; absent columns are skipped, canonical/alias names matched in order so the populated column wins. - hf_check_invariants() gains an opt-in `attr_bounds=`/`domain=` hook. The pass is soft (warn-only) and off by default, so it never changes the returned `ok` for existing callers until explicitly enabled. Bounds are aligned to our schema: flowpath length->lengthkm, incremental area->divide_areasqkm, drainage->total_dasqkm, divide area->areasqkm, with the source names retained as aliases. Tests cover range detection, inclusive boundaries, caveat exclusion, alias matching, and soft-under-strict behavior.
1 parent 99868e1 commit d69deae

10 files changed

Lines changed: 321 additions & 3 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Collate:
6161
'duckdb.R'
6262
'geom.R'
6363
'gpkg_metadata.R'
64+
'hf_attr_bounds.R'
6465
'hf_invariants.R'
6566
'hfio.R'
6667
'hfutils-package.R'

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export(get_hydroseq)
1919
export(get_node)
2020
export(gpkg_get_version)
2121
export(gpkg_set_version)
22+
export(hf_attr_bounds)
23+
export(hf_check_attr_bounds)
2224
export(hf_check_invariants)
2325
export(layer_exists)
2426
export(lynker_spatial_auth)

R/hf_attr_bounds.R

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#' Physical-range bounds for divide and flowpath attributes
2+
#'
3+
#' Returns the attribute plausibility-bounds table shipped with the package
4+
#' (`inst/extdata/divide_attr_bounds.csv`). Each row gives the physically
5+
#' reasonable range for a divide or flowpath modeling parameter. The `caveat`
6+
#' column flags bounds that are demonstrably too strict or look like
7+
#' placeholders; those are excluded from checks by default. The `aliases`
8+
#' column (`;`-separated) lists alternate column names a bound also matches, so
9+
#' a single bound covers schema naming differences (e.g. `lengthkm` matches
10+
#' `length_km`).
11+
#'
12+
#' @return A data.frame of bounds (one row per attribute, plus per-domain rows
13+
#' for `lat`/`lon`).
14+
#' @export
15+
hf_attr_bounds <- function() {
16+
path <- system.file("extdata", "divide_attr_bounds.csv", package = "hfutils")
17+
if (!nzchar(path)) stop("divide_attr_bounds.csv not found in installed package", call. = FALSE)
18+
utils::read.csv(path, stringsAsFactors = FALSE, na.strings = c("", "NA"))
19+
}
20+
21+
# Build a named list of bound checks for a single layer. Returns results shaped
22+
# like .hf_ok() but marked soft = TRUE so they warn rather than abort even under
23+
# strict reporting. `prefix` disambiguates names when divides and flowpaths
24+
# checks are concatenated (both carry e.g. `area_sqkm`).
25+
.hf_attr_bounds_checks <- function(layer, which = c("divides", "flowpaths"),
26+
domain = NULL, trust_caveated = FALSE,
27+
prefix = NULL, max_report = 5L) {
28+
which <- match.arg(which)
29+
if (is.null(layer)) return(list())
30+
df <- as.data.frame(layer)
31+
b <- hf_attr_bounds()
32+
b <- b[b$layer == which, , drop = FALSE]
33+
34+
is_domain_row <- b$domain != "all"
35+
if (is.null(domain)) {
36+
b <- b[!is_domain_row, , drop = FALSE]
37+
} else {
38+
b <- b[!is_domain_row | b$domain == domain, , drop = FALSE]
39+
}
40+
if (!trust_caveated) b <- b[is.na(b$caveat), , drop = FALSE]
41+
42+
has_aliases <- "aliases" %in% names(b)
43+
checks <- list()
44+
for (i in seq_len(nrow(b))) {
45+
a <- b$attribute[i]
46+
# Match our canonical name first, then any alias (handles schema naming
47+
# differences, e.g. length_km vs lengthkm, and flowline_* variants).
48+
cand <- a
49+
if (has_aliases && !is.na(b$aliases[i]) && nzchar(b$aliases[i]))
50+
cand <- c(cand, trimws(strsplit(b$aliases[i], ";", fixed = TRUE)[[1]]))
51+
col <- cand[cand %in% names(df)]
52+
if (!length(col)) next
53+
col <- col[1]
54+
v <- suppressWarnings(as.numeric(df[[col]]))
55+
v <- v[!is.na(v)]
56+
if (!length(v)) next
57+
58+
lo <- b$lower[i]; hi <- b$upper[i]
59+
bad <- logical(length(v))
60+
if (!is.na(lo)) bad <- bad | (if (isTRUE(b$lower_inclusive[i])) v < lo else v <= lo)
61+
if (!is.na(hi)) bad <- bad | (if (isTRUE(b$upper_inclusive[i])) v > hi else v >= hi)
62+
63+
nm <- if (is.null(prefix)) a else paste0(prefix, ".", a)
64+
rng <- sprintf("[%s, %s]",
65+
if (is.na(lo)) "-Inf" else lo,
66+
if (is.na(hi)) "Inf" else hi)
67+
if (sum(bad) == 0L) {
68+
checks[[nm]] <- list(ok = TRUE, msg = sprintf("in range %s", rng),
69+
kind = "check", soft = TRUE)
70+
} else {
71+
ex <- utils::head(sort(unique(v[bad])), max_report)
72+
checks[[nm]] <- list(
73+
ok = FALSE, kind = "check", soft = TRUE,
74+
msg = sprintf("%d/%d out of range %s (e.g. %s)",
75+
sum(bad), length(v), rng, paste(signif(ex, 4), collapse = ", ")))
76+
}
77+
}
78+
checks
79+
}
80+
81+
#' Check divide/flowpath attribute values against physical-range bounds
82+
#'
83+
#' Complements the topology/geometry checks in [hf_check_invariants()] with a
84+
#' per-attribute physical-plausibility pass: every modeling parameter present
85+
#' in the layer is checked against its expected range. Attributes not present
86+
#' in the layer are skipped; bounds carrying a `caveat` are skipped unless
87+
#' `trust_caveated = TRUE`. Failures are reported but never abort, even when
88+
#' `strict = TRUE`.
89+
#'
90+
#' @param layer A data.frame or sf object (the Divides or Flowpaths layer).
91+
#' @param which One of `"divides"` or `"flowpaths"`.
92+
#' @param domain Domain code for `lat`/`lon` bounds: one of `"CONUS"`, `"AK"`,
93+
#' `"HI"`, `"PRVI"`, or `NULL` to skip lat/lon.
94+
#' @param strict Logical. Passed through to the reporter; attribute-bound
95+
#' failures are soft and warn regardless.
96+
#' @param trust_caveated Logical. Include bounds flagged in the `caveat` column
97+
#' (default `FALSE`).
98+
#' @param max_report Integer. Max example offending values to show per attribute.
99+
#'
100+
#' @return Invisibly a list with `ok` and `checks` (same shape as
101+
#' [hf_check_invariants()]).
102+
#' @export
103+
hf_check_attr_bounds <- function(layer, which = c("divides", "flowpaths"),
104+
domain = NULL, strict = FALSE,
105+
trust_caveated = FALSE, max_report = 5L) {
106+
which <- match.arg(which)
107+
checks <- .hf_attr_bounds_checks(layer, which, domain = domain,
108+
trust_caveated = trust_caveated,
109+
max_report = max_report)
110+
.hf_report_checks(checks, stage = paste0("attr_bounds:", which), strict = strict)
111+
}

R/hf_invariants.R

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
#' coverage check (aggregated and ngen stages only). Default `FALSE`.
1616
#' @param coverage_min Minimum fraction of a flowpath that must lie inside
1717
#' its assigned catchment. Default `0.90`.
18+
#' @param attr_bounds Logical. If `TRUE`, append a per-attribute physical-range
19+
#' plausibility pass over the `divides` and `flowpaths` layers (see
20+
#' [hf_check_attr_bounds()]). Soft/warn-only and off by default, so it never
21+
#' changes the returned `ok` for existing callers until enabled. Absent
22+
#' attribute columns are skipped.
23+
#' @param domain Domain code (`"CONUS"`, `"AK"`, `"HI"`, `"PRVI"`) selecting the
24+
#' `lat`/`lon` bounds for the attribute pass; `NULL` skips lat/lon.
25+
#' @param attr_trust_caveated Logical. Include attribute bounds flagged in the
26+
#' `caveat` column of the bounds table (default `FALSE`).
1827
#'
1928
#' @details
2029
#' Expected arguments per stage:
@@ -39,7 +48,9 @@
3948
#' @importFrom stats setNames
4049
#' @export
4150
hf_check_invariants <- function(stage, ..., strict = TRUE,
42-
coverage = FALSE, coverage_min = 0.90) {
51+
coverage = FALSE, coverage_min = 0.90,
52+
attr_bounds = FALSE, domain = NULL,
53+
attr_trust_caveated = FALSE) {
4354
stage <- match.arg(stage, c("refactored", "reconciled", "aggregated", "ngen"))
4455
args <- list(...)
4556
checks <- switch(stage,
@@ -50,6 +61,18 @@ hf_check_invariants <- function(stage, ..., strict = TRUE,
5061
coverage_min = coverage_min),
5162
ngen = .hf_check_ngen(args, coverage = coverage,
5263
coverage_min = coverage_min))
64+
65+
# Optional per-attribute physical-plausibility pass. Soft (warn-only) and
66+
# off by default, so it never disturbs callers gating on the returned `ok`
67+
# until explicitly enabled. Absent attribute columns are skipped.
68+
if (isTRUE(attr_bounds) && stage %in% c("reconciled", "aggregated", "ngen")) {
69+
checks <- c(checks,
70+
.hf_attr_bounds_checks(args$divides, "divides", domain = domain,
71+
trust_caveated = attr_trust_caveated, prefix = "div"),
72+
.hf_attr_bounds_checks(args$flowpaths, "flowpaths", domain = domain,
73+
trust_caveated = attr_trust_caveated, prefix = "fp"))
74+
}
75+
5376
.hf_report_checks(checks, stage, strict)
5477
}
5578

@@ -355,7 +378,7 @@ hf_check_invariants <- function(stage, ..., strict = TRUE,
355378
tag <- sprintf("[invariants:%s]", stage)
356379
line <- sprintf("%s %s %-32s %s", tag, icon, nm, res$msg)
357380
if (isTRUE(res$ok) || identical(res$kind, "info")) message(line)
358-
else if (strict) stop(line, call. = FALSE)
381+
else if (strict && !isTRUE(res$soft)) stop(line, call. = FALSE)
359382
else message(line)
360383
}
361384
invisible(list(ok = ok_all, stage = stage, checks = checks))

_pkgdown.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ reference:
126126
- gpkg_set_version
127127
- gpkg_get_version
128128
- hf_check_invariants
129+
- hf_check_attr_bounds
130+
- hf_attr_bounds
129131

130132
- title: DBI / OGR-SQL backend
131133
desc: >
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
layer,domain,attribute,lower,upper,lower_inclusive,upper_inclusive,description,caveat,aliases
2+
divides,all,areasqkm,0,,FALSE,FALSE,catchment area (sqkm),,area_sqkm;divide_areasqkm
3+
divides,all,bexp_mode,2,15,FALSE,FALSE,Clapp-Hornberger beta exponent,,
4+
divides,all,isltyp_mode,1,16,TRUE,TRUE,dominant soil type category,,
5+
divides,all,ivgtyp_mode,1,16,TRUE,TRUE,dominant vegetation type category,,
6+
divides,all,dksat_geomean,1.95e-07,1.41e-03,FALSE,FALSE,saturated hydraulic conductivity,,
7+
divides,all,psisat_geomean,0.036,0.955,FALSE,FALSE,saturated capillary head,,
8+
divides,all,cwpvt_mean,0.09,0.36,FALSE,FALSE,empirical wind canopy parameter,,
9+
divides,all,mp_mean,3.6,12.6,FALSE,FALSE,slope of conductance-to-photosynthesis,,
10+
divides,all,mfsno_mean,0.5,4.0,FALSE,FALSE,melt factor for snow depletion curve,,
11+
divides,all,quartz_mean,0,1.0,FALSE,FALSE,mean soil quartz content,LOWER_TOO_STRICT: a legitimately zero-quartz divide fails gt 0; use ge 0,
12+
divides,all,refkdt_mean,0.1,4.0,FALSE,FALSE,reference soil infiltration parameter,,
13+
divides,all,slope1km_mean,0,1.0,FALSE,FALSE,hydraulic-head gradient at soil bottom,LOWER_TOO_STRICT: flat divide fails gt 0; use ge 0,
14+
divides,all,smcmax_mean,0.16,0.9,FALSE,FALSE,saturated soil moisture content,,
15+
divides,all,smcwlt_mean,0.05,0.3,FALSE,FALSE,wilting-point soil moisture content,,
16+
divides,all,vcmx_mean,24.0,112.0,FALSE,FALSE,max carboxylation rate,,
17+
divides,all,imperv_mean,0,1.0,FALSE,FALSE,impervious fraction,LOWER_TOO_STRICT: 0% impervious is valid and fails gt 0; use ge 0,
18+
divides,all,elevation_mean,-86.0,4422.0,FALSE,FALSE,mean terrain elevation (m),,
19+
divides,all,slope250m_mean,0,90.0,FALSE,FALSE,terrain slope (deg),LOWER_TOO_STRICT: flat terrain fails gt 0; use ge 0,
20+
divides,all,aspect_circmean,0,360.0,FALSE,FALSE,circular-mean terrain aspect (deg),LOWER_TOO_STRICT: due-north aspect=0 fails gt 0; use ge 0 / wrap,
21+
divides,all,lzfpm_mean,40.0,600.0,FALSE,FALSE,SAC max lower-zone free water primary,,
22+
divides,all,lzpk_mean,0.001,0.015,FALSE,FALSE,SAC lower-zone recession primary,,
23+
divides,all,lztwm_mean,75.0,300.0,FALSE,FALSE,SAC max lower-zone tension water,,
24+
divides,all,rexp_mean,1.4,3.5,FALSE,FALSE,SAC percolation exponent,,
25+
divides,all,uzk_mean,0.2,0.5,FALSE,FALSE,SAC upper-zone recession,,
26+
divides,all,zperc_mean,0,360.0,FALSE,FALSE,SAC min percolation rate coefficient,UPPER_SUSPECT: 360 ceiling looks like a placeholder, not a real limit,
27+
divides,all,lzfsm_mean,0,360.0,FALSE,FALSE,SAC max lower-zone free water secondary,UPPER_SUSPECT: 360 ceiling looks like a placeholder, not a real limit,
28+
divides,all,lzsk_mean,0.03,0.2,FALSE,FALSE,SAC lower-zone recession secondary,,
29+
divides,all,pfree_mean,0,0.5,FALSE,FALSE,SAC fraction percolating direct to LZ free water,,
30+
divides,all,uzfwm_mean,10.0,100.0,FALSE,FALSE,SAC max upper-zone free water,,
31+
divides,all,uztwm_mean,25.0,125.0,FALSE,FALSE,SAC max upper-zone tension water,,
32+
divides,all,mfmin_mean,0.01,0.6,FALSE,FALSE,Snow17 min non-rain melt factor,,
33+
divides,all,mfmax_mean,0,360.0,FALSE,FALSE,Snow17 max non-rain melt factor,UPPER_SUSPECT: 360 ceiling implausible; realistic mfmax is single digits,
34+
divides,all,uadj_mean,0.01,0.2,FALSE,FALSE,Snow17 avg wind function rain-on-snow,,
35+
divides,all,a_xinanjiang_inflection_point_parameter,-0.5,0.5,FALSE,FALSE,Xinanjiang inflection point,,
36+
divides,all,b_xinanjiang_shape_parameter,0.01,10.0,FALSE,FALSE,Xinanjiang shape b,,
37+
divides,all,x_xinanjiang_shape_parameter,0.01,10.0,FALSE,FALSE,Xinanjiang shape x,,
38+
divides,all,temp_delta_jan_mean,0,,FALSE,FALSE,mean temp difference January,,
39+
divides,all,temp_delta_feb_mean,0,,FALSE,FALSE,mean temp difference February,,
40+
divides,all,temp_delta_mar_mean,0,,FALSE,FALSE,mean temp difference March,,
41+
divides,all,temp_delta_apr_mean,0,,FALSE,FALSE,mean temp difference April,,
42+
divides,all,temp_delta_may_mean,0,,FALSE,FALSE,mean temp difference May,,
43+
divides,all,temp_delta_jun_mean,0,,FALSE,FALSE,mean temp difference June,,
44+
divides,all,temp_delta_jul_mean,0,,FALSE,FALSE,mean temp difference July,,
45+
divides,all,temp_delta_aug_mean,0,,FALSE,FALSE,mean temp difference August,,
46+
divides,all,temp_delta_sep_mean,0,,FALSE,FALSE,mean temp difference September,,
47+
divides,all,temp_delta_oct_mean,0,,FALSE,FALSE,mean temp difference October,,
48+
divides,all,temp_delta_nov_mean,0,,FALSE,FALSE,mean temp difference November,,
49+
divides,all,temp_delta_dec_mean,0,,FALSE,FALSE,mean temp difference December,,
50+
divides,all,glacier_percent,0,1,TRUE,TRUE,glacier cover fraction,,
51+
divides,all,cgw,1.80e-06,0.0018,FALSE,FALSE,groundwater coefficient,,
52+
divides,all,expon,1.0,8.0,FALSE,FALSE,groundwater exponent,,
53+
divides,all,max_gw_storage,0.01,0.25,FALSE,FALSE,baseflow bucket height,,
54+
divides,CONUS,lat,24,55,TRUE,TRUE,divide centroid latitude (CONUS),,
55+
divides,CONUS,lon,-125,-66,TRUE,TRUE,divide centroid longitude (CONUS),,
56+
divides,AK,lat,51,72,TRUE,TRUE,divide centroid latitude (AK),,
57+
divides,AK,lon,-172,-125,TRUE,TRUE,divide centroid longitude (AK),,
58+
divides,HI,lat,18,23,TRUE,TRUE,divide centroid latitude (HI),,
59+
divides,HI,lon,-161,-155,TRUE,TRUE,divide centroid longitude (HI),,
60+
divides,PRVI,lat,17,19,TRUE,TRUE,divide centroid latitude (PRVI),,
61+
divides,PRVI,lon,-68,-64,TRUE,TRUE,divide centroid longitude (PRVI),,
62+
flowpaths,all,lengthkm,0,,FALSE,FALSE,flowpath length (km),,length_km;flowline_lengthkm
63+
flowpaths,all,divide_areasqkm,0,,FALSE,FALSE,incremental divide area (sqkm),,areasqkm;area_sqkm;flowline_areasqkm
64+
flowpaths,all,total_dasqkm,0,,FALSE,FALSE,total upstream drainage area (sqkm),,total_da_sqkm;flowline_total_dasqkm
65+
flowpaths,all,path_length,0,,FALSE,FALSE,downstream path length,,
66+
flowpaths,all,mean_elevation,-86.0,4422.0,FALSE,FALSE,mean terrain elevation (m),,
67+
flowpaths,all,slope,0,90.0,FALSE,FALSE,terrain slope (deg),LOWER_TOO_STRICT: flat reach fails gt 0; use ge 0,

man/hf_attr_bounds.Rd

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/hf_check_attr_bounds.Rd

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/hf_check_invariants.Rd

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

tests/testthat/test-invariants.R

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,35 @@ test_that("ngen stage detects a cycle in the fp->nexus->fp graph", {
169169
"cycle"
170170
)
171171
})
172+
173+
test_that("hf_check_attr_bounds flags out-of-range and skips absent/zero-valid", {
174+
df <- data.frame(
175+
smcmax_mean = c(0.4, 0.95, 0.2), # 0.95 > 0.9 -> 1 bad
176+
bexp_mode = c(5, 5, 20), # 20 > 15 -> 1 bad
177+
glacier_percent = c(0, 0.5, 1), # inclusive 0..1 -> all ok
178+
area_sqkm = c(1, 2, 3), # all ok
179+
not_a_bound = c(9, 9, 9) # no bound -> skipped
180+
)
181+
res <- suppressMessages(hf_check_attr_bounds(df, "divides", strict = FALSE))
182+
expect_false(res$ok)
183+
expect_false(res$checks[["smcmax_mean"]]$ok)
184+
expect_false(res$checks[["bexp_mode"]]$ok)
185+
expect_true(res$checks[["glacier_percent"]]$ok)
186+
expect_true(res$checks[["areasqkm"]]$ok) # data col `area_sqkm` matched via alias, reported canonically
187+
expect_null(res$checks[["not_a_bound"]])
188+
})
189+
190+
test_that("caveated bounds excluded by default; attr failures are soft under strict", {
191+
df <- data.frame(imperv_mean = c(0, 0.2, 0.5), smcmax_mean = c(0.5, 0.95, 0.5))
192+
res <- suppressMessages(hf_check_attr_bounds(df, "divides", strict = TRUE))
193+
expect_null(res$checks[["imperv_mean"]]) # caveated -> skipped
194+
expect_false(res$checks[["smcmax_mean"]]$ok) # soft fail did not abort
195+
})
196+
197+
test_that("hf_check_attr_bounds matches via aliases (schema name differences)", {
198+
# bound canonical name is `lengthkm`; data uses the source alias `length_km`
199+
df <- data.frame(length_km = c(0.5, -1, 2)) # -1 <= 0 -> 1 bad
200+
res <- suppressMessages(hf_check_attr_bounds(df, "flowpaths", strict = FALSE))
201+
expect_false(res$ok)
202+
expect_false(res$checks[["lengthkm"]]$ok) # matched via alias, reported canonically
203+
})

0 commit comments

Comments
 (0)