Skip to content
Open
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
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
^index\.md$
^README\.md$
^vignettes/index\.Rmd$
^vignettes/monotonicity_analysis$
^\.positai$
^\.claude$
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,17 @@
docs
inst/doc
.positai

# Von den Vignetten erzeugte Ergebnisdateien (Workflows: simulation_results_*;
# monotonicity_analysis.Rmd: mono_*). Reproduzierbar durch erneutes Rendern
# -> nicht einchecken. HTML- und R-Dateien unter vignettes/ ignoriert
# bereits vignettes/.gitignore (*.html, *.R).
vignettes/*.csv
vignettes/*.pdf
vignettes/figure/
Rplots.pdf

# Deploy-Ordner der Monotonie-Analyse: komplett unversioniert. Achtung:
# Das betrifft auch den handgeschriebenen Ergebnisbericht index.html
# (liegt lokal, als claude.ai-Artefakt und deployt auf dem Server).
vignettes/monotonicity_analysis/
4 changes: 3 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ Suggests:
plotly,
readr,
rmarkdown,
testthat (>= 3.0.0),
writexl
VignetteBuilder:
VignetteBuilder:
knitr
Config/testthat/edition: 3
Remotes:
github::kwb-r/kwb.event,
github::kwb-r/kwb.utils
Expand Down
8 changes: 8 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ export(cost_rates_caption)
export(default_canonical_wb_variables)
export(default_cost_rates)
export(default_param_labels)
export(default_storage_spec)
export(default_storage_types)
export(download_engine)
export(find_min_feasible)
export(find_single_param_variations)
export(get_simulation_results_all)
export(get_simulation_results_optim)
Expand All @@ -18,6 +21,8 @@ export(h5_read_values)
export(h5_validate_write)
export(h5_write_values)
export(list_h5_datasets)
export(make_swale_runner)
export(optimise_swale_design)
export(plot_cost_overflow_boxplot)
export(plot_cost_vs_evaporation)
export(plot_cost_vs_overflow_volume)
Expand All @@ -31,8 +36,11 @@ export(read_hdf5_connections)
export(read_hdf5_scalars)
export(read_hdf5_timeseries)
export(read_raindrop_errors)
export(read_site_timeseries)
export(run_model)
export(run_scenarios)
export(sickerbox_level_presets)
export(stack_levels)
importFrom(dplyr,"%>%")
importFrom(dplyr,across)
importFrom(dplyr,all_of)
Expand Down
64 changes: 64 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,70 @@

## New features

* New **swale-design optimiser** — finds the cost-minimal design per
overflow target (`n_overflows <= x`) with roughly ten engine runs per
(storage type, target) cell instead of a full factorial sweep, at finer
resolution (2 m² / 10 mm instead of 25 m² / 100 mm grid steps):
- `find_min_feasible()` — the single primitive everything is built
from: bisection for the smallest feasible value of one design
parameter, over continuous bounds (`lower`/`upper`/`tol`) or discrete
stack `levels` (binary search). Evaluations are memoised and two
safety rules from the monotonicity analysis are built in: an **edge
guard** for the +1 event-counting wobble of the 4-h event separation
(a descending ladder below an "infeasible by exactly one event" upper
edge, so the Bad Aussee 175-m²-feasible/200-m²-infeasible pattern
cannot eat a solution) and a **volume referee** that warns — and
flags the result — iff the overflow count *and* the overflow volume
increase together (real non-monotonicity; never observed in the
5 112 validation comparisons).
- `optimise_swale_design()` — coordinate descent in cost order: shrink
`mulde_area` (the expensive lever) first, then `mulde_height` (the
cheap one); the storage layer starts at its smallest level and is
escalated only when the area is stuck at its upper bound. One shared
evaluation cache spans all `x_targets` and both storage types (a run
classifies itself for every target at once), warm-start brackets are
derived from prior brute-force results (CSV schema of the
workflows), `max_total_depth` adds an analytic depth constraint
(DWA-A 138 groundwater clearance / cover requirements) that costs no
simulation runs, and "infeasible within bounds" is a regular result
status, not an error. Costs are attached via `compute_costs()`; all
evaluated designs ship as attribute `"evaluations"`.
- `make_swale_runner()` — package-level refactoring of the `run_one()`
function previously duplicated across the three case-study
vignettes: one closure factory covering both variants (Eisenstadt:
`base.h5` rain curve scaled by `rain_factor`; Wien / Bad Aussee: own
rain + ET0 series in mm/h incl. the Growth/Shading end-time fix).
Returns the thinned one-row optimisation result augmented with
`overflow_volume_m3` (= `sum_overflows` [mm] × `mulde_area` / 1000).
- `stack_levels()`, `sickerbox_level_presets()`,
`default_storage_spec()`, `default_storage_types()` — storage-layer
search spaces: achievable stack heights from module heights (incl.
mixed combinations such as Rigofill full + half block), manufacturer
presets (GRAF, Fränkische, ACO, Wavin; verify against data sheets
before productive runs) with the brute-force grid levels
300/600/900/1200 mm as the default, and the gravel-trench range
coupled at 3 × the box range (usable-porosity ratio 0.95 / 0.3).

* New conditional vignette `monotonicity_analysis` — validates the
optimiser's core assumption on the three brute-force result sets
(5 112 neighbour comparisons): `n_overflows` is quasi-monotone in
every design parameter (13 violations, all +1 counting artefacts of
the 4-h event separation), the overflow volume (in m³) is monotone
without exception, ET depends on `mulde_area` only, and the filter
conductivity is a cost-free dominant lever (fix at maximum). Renders
after the three workflow vignettes into
`vignettes/monotonicity_analysis/` (deploy unit with the plain-language
report `index.html` and the exported `mono_*` detail tables as CSV +
interactive HTML).

* **testthat suite added** (edition 3; `tests/testthat/`): unit tests for
the bisection primitive (threshold accuracy, run counts, wobble guard,
volume referee, discrete levels) and end-to-end optimiser tests
against a synthetic monotone hydraulic model, verified against a fine
brute-force reference (cost within 5 %, monotone cost-effectiveness
curve, storage escalation, infeasibility handling, warm-start
savings, `max_total_depth`).

* New exported plot `plot_cost_vs_evaporation()` — third cost view:
scatters `cost_total` (EUR, x) against the element evapotranspiration
share (`element.WB_Evapotranspiration_`, %, y). Points share the
Expand Down
4 changes: 2 additions & 2 deletions R/cost_tooltip.R
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ storage_type_shapes <- function(storage_type, lang = c("de", "en")) {
)
}

#' Usable storage volume of the storage layer [m3] per row
#' Usable storage volume of the storage layer (m3) per row
#'
#' Area x height x usable porosity (thetaS - thetaFC) of the storage type.
#' Taken from a precomputed `storage_volume_m3` column when available,
Expand Down Expand Up @@ -282,7 +282,7 @@ storage_volume_from_df <- function(df) {
#' @param df Data frame with the columns listed above.
#' @param tt Label list from `cost_tooltip_labels()`.
#' @param digits Integer. Rounding for the numeric tooltip values.
#' @param evap_min Numeric. Minimum element evapotranspiration share [%] of
#' @param evap_min Numeric. Minimum element evapotranspiration share (%) of
#' the complete model run — the reference for the cost-per-percent line.
#' `NULL` falls back to the minimum within `df` (identical as long as `df`
#' is unfiltered).
Expand Down
196 changes: 196 additions & 0 deletions R/find_min_feasible.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#' Smallest feasible parameter value via bisection (monotone threshold search)
#'
#' Core building block of the swale-design optimiser: finds the smallest
#' value of one design parameter for which the overflow target is met
#' (`n_overflows <= x_max`), assuming quasi-monotone feasibility (larger
#' value = never more overflows; verified for the RAINDROP model in the
#' `monotonicity_analysis` vignette). Each evaluation halves the search
#' interval, so `ceiling(log2(range / tol))` evaluations suffice.
#'
#' Two safety rules from the monotonicity analysis are built in:
#' \itemize{
#' \item \strong{Edge guard}: if the upper bound is infeasible by no more
#' than `wobble` events (the +1 counting artefact of the 4-h event
#' separation), a descending ladder below the edge searches for a
#' feasible anchor before the branch is declared infeasible.
#' \item \strong{Volume referee}: whenever `n_overflows` increases with
#' the parameter (a counting flip), the overflow volume must have
#' decreased; if the volume increased as well, a warning is emitted and
#' `monotonicity_violation` is set (real non-monotonicity -- never
#' observed at the three validation sites).
#' }
#'
#' @param evaluate `function(value)` returning a list / one-row data.frame
#' with at least `n_overflows`; if it also contains `volume_column`, the
#' volume referee is active. Evaluations are memoised per value.
#' @param x_max Feasibility target: feasible iff `n_overflows <= x_max`.
#' @param lower,upper Numeric search bounds (continuous mode).
#' @param tol Resolution of the continuous search (same unit as the value).
#' @param levels Sorted numeric vector of discrete candidate values
#' (discrete mode, e.g. Sickerbox stack heights). If given, `lower`,
#' `upper` and `tol` are ignored and a binary search over the levels is
#' performed.
#' @param wobble Maximum counting-artefact size tolerated by the edge guard
#' (default 1, matching the observed +1 flips).
#' @param volume_column Name of the volume element in the `evaluate` result
#' used by the volume referee (default `"overflow_volume_m3"`).
#' @param verbose Print one line per evaluation.
#'
#' @return List with
#' \describe{
#' \item{value}{smallest feasible value, or `NA` if infeasible}
#' \item{n_overflows}{overflow count at `value`}
#' \item{status}{`"ok"`, `"at_lower_bound"` (already feasible at the lower
#' end -- caller may widen the bracket) or `"infeasible"`}
#' \item{evaluations}{tibble of all evaluated values (value, n_overflows,
#' volume), sorted by value}
#' \item{n_evaluations}{number of distinct evaluations}
#' \item{monotonicity_violation}{`TRUE` if the volume referee fired}
#' }
#'
#' @examples
#' # synthetic monotone step function: feasible from 137.4 m2 on
#' f <- function(v) list(n_overflows = if (v >= 137.4) 0L else 10L)
#' find_min_feasible(f, x_max = 0, lower = 25, upper = 200, tol = 2)$value
#'
#' @export
find_min_feasible <- function(evaluate,
x_max,
lower = NULL,
upper = NULL,
tol = 1,
levels = NULL,
wobble = 1L,
volume_column = "overflow_volume_m3",
verbose = FALSE) {

discrete <- !is.null(levels)
if (discrete) {
grid <- sort(unique(levels))
axis_lo <- 0 # virtual: below the smallest level
axis_hi <- length(grid)
axis_tol <- 1
to_value <- function(i) grid[[i]]
} else {
stopifnot(is.numeric(lower), is.numeric(upper), upper > lower, tol > 0)
axis_lo <- lower
axis_hi <- upper
axis_tol <- tol
to_value <- identity
}

evals <- new.env(parent = emptyenv())
eval_at <- function(axis_pos) {
v <- to_value(axis_pos)
key <- format(v, digits = 15)
if (!is.null(evals[[key]])) return(evals[[key]])
res <- as.list(evaluate(v))
if (!"n_overflows" %in% names(res)) {
stop("find_min_feasible(): evaluate() must return an element 'n_overflows'")
}
row <- list(
value = v,
n_overflows = as.numeric(res$n_overflows),
volume = if (volume_column %in% names(res)) {
as.numeric(res[[volume_column]])
} else {
NA_real_
}
)
assign(key, row, envir = evals)
if (isTRUE(verbose)) {
message(sprintf(" eval %s -> n_overflows = %s",
format(v), format(row$n_overflows)))
}
row
}
feasible <- function(axis_pos) {
n <- eval_at(axis_pos)$n_overflows
!is.na(n) && n <= x_max
}

status <- "ok"

# 1) Upper edge: with monotone feasibility the whole range is infeasible
# if the upper edge is -- unless the edge is a +1 counting wobble, in
# which case a descending ladder looks for a feasible anchor below.
if (!feasible(axis_hi)) {
n_hi <- eval_at(axis_hi)$n_overflows
anchor <- NA_real_
if (!is.na(n_hi) && n_hi <= x_max + wobble) {
offset <- axis_tol
repeat {
p <- axis_hi - offset
if (discrete) p <- ceiling(p)
if (p <= axis_lo) break
if (feasible(p)) {
anchor <- p
break
}
offset <- offset * 2
}
}
if (is.na(anchor)) {
status <- "infeasible"
} else {
axis_hi <- anchor
}
}
Comment on lines +115 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge-guard ladder never evaluates axis_lo itself. In continuous mode, if (p <= axis_lo) break exits without probing axis_lo. Under strict monotonicity this is safe (if feasible(axis_lo) were true, the very first step axis_hi - tol would already succeed), and the follow-up if (!discrete && feasible(lo)) guard at line 145 catches the corner case after an anchor is found. But if the upper edge is truly infeasible-by-wobble AND no descending step lands on a feasible value (only axis_lo itself is feasible), the search returns "infeasible" when a lower-bound solution existed.

This is an unlikely combination given the +1 wobble assumption, and I don't have a concrete failure scenario for the RAINDROP model — but the invariant is worth stating in the roxygen block, so future callers understand the assumption the guard rests on.


# 2) Bisection: invariant lo infeasible (or virtual/edge), hi feasible.
best <- NA_real_
if (!identical(status, "infeasible")) {
lo <- axis_lo
hi <- axis_hi
if (!discrete && feasible(lo)) {
hi <- lo # optimum at (or below) the lower end
}
while ((hi - lo) > axis_tol) {
mid <- (lo + hi) / 2
if (discrete) mid <- floor(mid)
if (mid <= lo || mid >= hi) break
if (feasible(mid)) hi <- mid else lo <- mid
}
best <- hi
at_lower <- if (discrete) best <= 1 else best <= axis_lo
if (at_lower) status <- "at_lower_bound"
}

# 3) Volume referee over all evaluations of this search
ev <- do.call(rbind, lapply(ls(evals), function(k) {
e <- get(k, envir = evals)
data.frame(value = e$value, n_overflows = e$n_overflows,
volume = e$volume)
}))
ev <- ev[order(ev$value), , drop = FALSE]
rownames(ev) <- NULL
monotonicity_violation <- FALSE
if (nrow(ev) >= 2) {
dn <- diff(ev$n_overflows)
dv <- diff(ev$volume)
flips <- which(!is.na(dn) & dn > 0)
real <- flips[!is.na(dv[flips]) & dv[flips] > 1e-9]
if (length(real) > 0) {
monotonicity_violation <- TRUE
warning(sprintf(
paste0("find_min_feasible(): n_overflows AND overflow volume ",
"increase between value %s and %s -- real non-monotonicity, ",
"bisection result unreliable for this branch."),
format(ev$value[real[1]]), format(ev$value[real[1] + 1])
), call. = FALSE)
}
}
Comment on lines +168 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Volume-referee blind spot when either endpoint's volume is NA. dv <- diff(ev$volume) yields NA whenever the "from" or "to" evaluation has no overflow_volume_m3 (e.g., engine returned NA sum_overflows, or volume_column was absent for one call). The subsequent filter !is.na(dv[flips]) & dv[flips] > 1e-9 silently drops NA-volume flips from real, so a real monotonicity violation across such a step goes unwarned. Failure scenario: two consecutive area evaluations produce n_overflows 5→6 where the larger area has NA volume — the referee misses it, monotonicity_violation stays FALSE, and the bisection result is silently unreliable.

Consider distinguishing "clean" from "unknown" — e.g., warn "volume unavailable at value X → referee disabled for this flip" and set monotonicity_violation = NA (or TRUE) so the caller doesn't confuse "no violation" with "not verified".


list(
value = if (identical(status, "infeasible")) NA_real_ else to_value(best),
n_overflows = if (identical(status, "infeasible")) {
NA_real_
} else {
eval_at(best)$n_overflows
},
status = status,
evaluations = tibble::as_tibble(ev),
n_evaluations = nrow(ev),
monotonicity_violation = monotonicity_violation
)
}
Loading