diff --git a/.Rbuildignore b/.Rbuildignore index 906e453..16df298 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -8,3 +8,9 @@ ^codecov\.yml$ ^index\.md$ ^README\.md$ +^vignettes/monotonicity_analysis$ +^vignettes/optimiser_vs_bruteforce$ +^\.positai$ +^\.claude$ +^CITATION\.cff$ +^\.zenodo\.json$ diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml index 2da55d5..2813c65 100644 --- a/.github/workflows/claude.yaml +++ b/.github/workflows/claude.yaml @@ -35,4 +35,4 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--model claude-opus-4-7' + claude_args: '--model claude-opus-4-8' diff --git a/.gitignore b/.gitignore index 0d7f03b..1567446 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,23 @@ .Ruserdata docs inst/doc +.positai + +# Von den Vignetten erzeugte Ergebnisdateien (simulation_results_*). +# 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/ + +# Deploy-Ordner der Vergleichs-Praesentation Optimierer vs. Brute-Force +# (handgeschriebenes index.html, liegt auch als claude.ai-Artefakt vor) +vignettes/optimiser_vs_bruteforce/ +tests/testthat/testthat-problems.rds diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..cd307a7 --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,31 @@ +{ + "title": "kwb.raindrop: R Package for Optimisation Simulations for Rainwater Management", + "description": "

R interface and control unit for the RAINDROP hydrodynamic planning tool: long-term simulation of blue-green infrastructure (swale-trench elements) driven by real climate series, plus two cost optimisers that find the cost-minimal design per allowed overflow frequency — a deterministic bisection-based coordinate descent whose search order is derived from the construction cost rates, and an assumption-free simultaneous search (penalised Nelder-Mead, differential evolution and Halton sampling) as cross-check.

The proprietary calculation engine ("Regenwasserbewirtschaftung", © Tandler.com GmbH) is not part of this archive; it is downloaded on demand from the companion repository kwb.raindrop.binaries.

", + "upload_type": "software", + "access_right": "open", + "license": "MIT", + "creators": [ + { + "name": "Rustler, Michael", + "orcid": "0000-0003-0647-7726", + "affiliation": "Kompetenzzentrum Wasser Berlin gGmbH (KWB)" + } + ], + "keywords": [ + "blue-green infrastructure", + "sponge city", + "stormwater management", + "long-term simulation", + "cost optimisation", + "infiltration swale", + "R package" + ], + "related_identifiers": [ + { + "relation": "isSupplementTo", + "identifier": "https://github.com/KWB-R/kwb.raindrop", + "scheme": "url" + } + ], + "notes": "Developed within the project RAINDROP (Rainwater Drainage Optimization, GZ C300428), funded by the Austrian Federal Ministry of Agriculture, Forestry, Regions and Water Management." +} diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..f906950 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,37 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it using the metadata below." +type: software +title: "kwb.raindrop: R Package for Optimisation Simulations for Rainwater Management" +version: 0.1.0 +date-released: "2026-08-07" +license: MIT +repository-code: "https://github.com/KWB-R/kwb.raindrop" +url: "https://kwb-r.github.io/kwb.raindrop" +authors: + - family-names: Rustler + given-names: Michael + orcid: "https://orcid.org/0000-0003-0647-7726" + affiliation: "Kompetenzzentrum Wasser Berlin gGmbH (KWB)" +keywords: + - blue-green infrastructure + - sponge city + - stormwater management + - long-term simulation + - cost optimisation + - infiltration swale + - R package +abstract: >- + R interface and control unit for the RAINDROP hydrodynamic planning + tool: long-term simulation of blue-green infrastructure (swale-trench + elements) driven by real climate series, plus two cost optimisers + that find the cost-minimal design per allowed overflow frequency - a + deterministic bisection-based coordinate descent whose search order + is derived from the construction cost rates, and an + assumption-free simultaneous search (penalised Nelder-Mead, + differential evolution and Halton sampling) as cross-check. The + proprietary calculation engine ("Regenwasserbewirtschaftung", + (c) Tandler.com GmbH) is not part of this package; it is downloaded + on demand from the companion repository kwb.raindrop.binaries. + Developed within the project RAINDROP (Rainwater Drainage + Optimization, GZ C300428), funded by the Austrian Federal Ministry + of Agriculture, Forestry, Regions and Water Management. diff --git a/DESCRIPTION b/DESCRIPTION index 9482d3b..e3ce958 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: kwb.raindrop Title: R Package for Optimisation Simulations for Rainwater Management Simulations Performed with Calculation Engine Provided by Tandler -Version: 0.0.0.9000 +Version: 0.1.0 Authors@R: c( person("Michael", "Rustler", , "michael.rustler@kompetenz-wasser.de", role = c("aut", "cre"), comment = c(ORCID = "0000-0003-0647-7726")), @@ -41,12 +41,14 @@ 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 Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index 6df2600..d76aeb1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,9 +3,14 @@ export("%>%") export(add_overflow_events_and_waterbalance) export(compute_costs) +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) @@ -16,91 +21,133 @@ 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(optimise_swale_design_simultaneous) +export(plot_cost_overflow_boxplot) +export(plot_cost_vs_evaporation) +export(plot_cost_vs_overflow_volume) export(plot_hpond_vs_ref) export(plot_main_effects) export(plot_valid_design_space) export(plot_wb_tradeoff_overflows) +export(plotly_add_caption) +export(plotly_split_legend) 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) -importFrom(dplyr,"%>%") -importFrom(dplyr,across) -importFrom(dplyr,all_of) -importFrom(dplyr,arrange) -importFrom(dplyr,bind_cols) -importFrom(dplyr,bind_rows) -importFrom(dplyr,case_when) -importFrom(dplyr,everything) -importFrom(dplyr,filter) -importFrom(dplyr,group_by) -importFrom(dplyr,if_else) -importFrom(dplyr,left_join) -importFrom(dplyr,mutate) -importFrom(dplyr,n) -importFrom(dplyr,n_distinct) -importFrom(dplyr,pull) -importFrom(dplyr,relocate) -importFrom(dplyr,select) -importFrom(dplyr,summarise) -importFrom(dplyr,transmute) -importFrom(dplyr,ungroup) +export(sickerbox_level_presets) +export(stack_levels) +importFrom(dplyr, + "%>%", + across, + all_of, + arrange, + bind_cols, + bind_rows, + case_when, + coalesce, + desc, + everything, + filter, + group_by, + if_else, + left_join, + mutate, + n, + n_distinct, + pull, + relocate, + select, + slice, + summarise, + transmute, + ungroup +) importFrom(forcats,fct_reorder) -importFrom(fs,dir_create) -importFrom(fs,file_copy) -importFrom(fs,file_exists) -importFrom(fs,path_abs) -importFrom(future,multisession) -importFrom(future,plan) -importFrom(future,sequential) +importFrom(fs, + dir_create, + file_copy, + file_exists, + path_abs +) +importFrom(future, + multisession, + plan, + sequential +) importFrom(future.apply,future_lapply) -importFrom(ggplot2,aes) -importFrom(ggplot2,coord_cartesian) -importFrom(ggplot2,element_text) -importFrom(ggplot2,facet_wrap) -importFrom(ggplot2,geom_boxplot) -importFrom(ggplot2,geom_jitter) -importFrom(ggplot2,geom_point) -importFrom(ggplot2,geom_violin) -importFrom(ggplot2,ggplot) -importFrom(ggplot2,guide_legend) -importFrom(ggplot2,guides) -importFrom(ggplot2,labs) -importFrom(ggplot2,position_identity) -importFrom(ggplot2,position_jitter) -importFrom(ggplot2,scale_alpha_identity) -importFrom(ggplot2,scale_color_manual) -importFrom(ggplot2,scale_colour_manual) -importFrom(ggplot2,scale_x_continuous) -importFrom(ggplot2,scale_x_discrete) -importFrom(ggplot2,scale_y_continuous) -importFrom(ggplot2,scale_y_discrete) -importFrom(ggplot2,theme) -importFrom(ggplot2,theme_bw) -importFrom(ggplot2,theme_minimal) +importFrom(ggplot2, + aes, + coord_cartesian, + element_text, + facet_grid, + facet_wrap, + geom_boxplot, + geom_jitter, + geom_line, + geom_point, + geom_text, + geom_violin, + ggplot, + guide_legend, + guides, + labs, + position_identity, + position_jitter, + position_nudge, + scale_alpha_identity, + scale_color_manual, + scale_colour_manual, + scale_fill_manual, + scale_shape_manual, + scale_size, + scale_x_continuous, + scale_x_discrete, + scale_y_continuous, + scale_y_discrete, + theme, + theme_bw, + vars +) importFrom(grDevices,colorRampPalette) importFrom(hdf5r,H5File) importFrom(kwb.event,hsEvents) -importFrom(kwb.utils,catAndRun) -importFrom(kwb.utils,resolve) +importFrom(kwb.utils, + catAndRun, + resolve +) importFrom(lubridate,as_datetime) importFrom(magrittr,"%>%") importFrom(parallel,detectCores) -importFrom(progressr,handler_cli) -importFrom(progressr,handler_rstudio) -importFrom(progressr,handler_txtprogressbar) -importFrom(progressr,handlers) -importFrom(progressr,progressor) -importFrom(progressr,with_progress) -importFrom(purrr,map_chr) -importFrom(purrr,map_dfr) +importFrom(progressr, + handler_cli, + handler_rstudio, + handler_txtprogressbar, + handlers, + progressor, + with_progress +) +importFrom(purrr, + map_chr, + map_dfr +) importFrom(rlang,.data) -importFrom(stats,median) -importFrom(stats,setNames) +importFrom(stats, + median, + setNames +) importFrom(stringr,str_c) -importFrom(tibble,as_tibble) -importFrom(tibble,tibble) -importFrom(tidyr,pivot_longer) -importFrom(tidyr,pivot_wider) +importFrom(tibble, + as_tibble, + tibble +) +importFrom(tidyr, + pivot_longer, + pivot_wider +) +importFrom(utils,modifyList) diff --git a/NEWS.md b/NEWS.md index 3038ce8..f71f353 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,16 +1,439 @@ -# kwb.raindrop (development version) +# kwb.raindrop 0.1.0 (2026-08-07) + +## 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). An optional `split_jitter` randomises + the bisection split point — a Monte-Carlo of the search path + (repeated runs with different seeds must agree within `tol`). + - `optimise_swale_design()` — coordinate descent whose **search order + is derived from `cost_rates`** via a specific-cost proxy (EUR per + mm of storage capacity, capacity model V ≈ area × (mulde_height + + porosity × storage_height); porosity from `default_storage_spec()`): + maximising `mulde_height` first is provably optimal for any rates + under this cost model, and the starting storage level is the + cheapest level per mm of capacity — the smallest under the default + rates, a high level when e.g. the storage material is cheap (with + box material at 5 EUR/m³ this finds the 63 m²/1200 mm corner for + 12.7k EUR in 15 runs, where the fixed legacy order returned 155 m²/ + 300 mm for 20.8k EUR; regression-tested against the simultaneous + optimiser). Specs without a `porosity` entry keep the legacy order; + the proxy assumes capacity-additive levers, so parameters with + nonlinear hydraulic effects remain the domain of + `optimise_swale_design_simultaneous()`. Within a cell the descent + then runs: minimal feasible `mulde_area` at maximal `mulde_height` + on the chosen storage level, storage escalated only when the area + is stuck at its upper bound, `mulde_height` shrunk last. 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"`. + - `optimise_swale_design_simultaneous()` — alternative optimiser that + searches **all design parameters at once** (`mulde_area`, + `mulde_height`, `storage_height`) instead of per-parameter + bisection: infeasible designs are not excluded but penalised (any + infeasible design is worse than any feasible one; excess overflow + events grade the penalty and steer the search back towards the + feasibility boundary, where the optimum lives), so the search can + trade the parameters against each other in a single step and does + not rely on the per-parameter monotonicity the bisection exploits. + Three search `method`s share this penalised objective, the + tolerance snapping (the shared cache absorbs repeats across all + `x_targets`) and a final **multi-valley lattice polish** + (accelerated 8/4/2/1-tolerance pattern descent from the cheapest + feasible design of every storage level visited, capped at the 6 + cheapest levels for the continuous gravel trench — the storage axis + separates cost valleys that single coordinate steps cannot cross; + each round also proposes a *boundary slide* — area down with + `mulde_height` at its maximum, the two-coordinate trade towards the + cheap end of the feasibility boundary — and a `mulde_height` *floor + probe* that jumps over +1 counting-wobble bands, both plain + evaluated candidates without any monotonicity assumption): + `"nelder_mead"` (default; deterministic multistart via + `stats::optim()` — prior warm start, previous-target optimum, one + anchor start per storage level, space-filling points; every start + gets an equal slice of the `max_evals` run budget), + `"diff_evolution"` (compact DE/rand/1/bin for comparison; + deterministic via an internal Park-Miller generator seeded with + `seed` — R's global RNG stays untouched) and `"halton_search"` + (quasi-random space-filling baseline). Same interface and result + schema as `optimise_swale_design()` (incl. `max_total_depth`, warm + start and the `"evaluations"` attribute) plus a `method` column; a + pairwise dominance check per cell (a strictly larger design with + more overflows *and* more overflow volume) replaces the bisection's + volume referee. Needs considerably more engine runs per cell + (typically 60–120 instead of ~15) but serves as an independent + cross-check that coordinate descent did not miss a cheaper corner + of the design space. + - `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). + **Behaviour note — files are deleted by default:** with + `cleanup = TRUE` (the default) each scenario's input copy *and its + output HDF5s* (`Mulde_Rigole.h5`, `Dach.h5`, `Fehlerprotokoll.h5`, + …) are removed right after the one-row result has been read — only + the returned tibble survives a run. Pass `cleanup = FALSE` if you + need the raw scenario files (failed runs always keep theirs for + debugging). Rationale: without the cleanup, long searches (hundreds + of engine runs per task, each with its own `base.h5` copy plus + output HDF5s) fill the temp drive and the engine dies with HDF5 + `errno = 28` ("No space left on device"). Prepares a **site master file** once + (base.h5 + calculation settings + ET/rain series) and writes only + the ~15 small parameter datasets per run instead of reading and + rewriting *every* dataset each time — that full HDF5 round trip + (plus process spawn and virus-scanner latency on new files) was the + dominant per-run cost of the optimisation searches, several times + the ~2 s engine time of the Eisenstadt model. + - `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). + +* New conditional vignette `workflow_optimisation` — runs the optimiser + for all three sites (Eisenstadt 2005, Wien, Bad Aussee), **parallelised + over site × storage type** (6 independent tasks via + `future`/`future.apply`; wall time = longest single task, ~15–20 min + instead of ~65 min sequential — bisection within a search is inherently + sequential and the x-targets of one storage type share the evaluation + cache, but box and trench never share a single engine run). Exposes the + search space and the cost rates as explicit, adjustable code (defaults + used), warm-starts from the grid CSVs, renders the combined optimum + table, per-site cost-effectiveness curves, total-runtime reporting + (per task, per section and for the whole document) and a + **Monte-Carlo section** (`n_mc = 10`) evaluating the robustness of the + *search itself*: every bisection split point is randomly displaced + (`split_jitter = 0.3`) while rain, cost rates and all other inputs + stay fixed; the repetitions with different seeds must agree on the + storage level, keep the area within 2 × `area_tol` and the cost within + a few percent — the swale depth may scatter somewhat more because it + is hydraulically coupled to the found area (x = 1; the full pool of + 3 sites × 2 storage types × 10 repetitions runs as 60 parallel tasks, + one full re-optimisation each). + +* New conditional vignette `workflow_optimisation_simultaneous` — the + simultaneous counterpart of `workflow_optimisation` (which stays + bisection-only and now points here): runs the Nelder-Mead sweep for + all three sites in parallel (site × storage type), compares the + optima cell by cell against the bisection CSV export when present + (`delta_pct` table), and benchmarks the three search methods + (Nelder-Mead / differential evolution / Halton baseline) on the same + x = 1 cell across all sites and storage types — 12 parallel tasks — + to show what the structured searches contribute over naive sampling. + Both compute chunks report live progress across the worker boundary + (one \pkg{progressr} tick per engine run — a multi-hour sweep no + longer looks frozen), the site list has a quick-test switch + (Eisenstadt only) and `max_evals` is exposed as the runtime lever. + +* New exported helper `read_site_timeseries()` — the rain/ET0 time-series + preparation previously duplicated in the Wien and Bad Aussee vignettes + (hours since start, series-end alignment, engine mm/h convention); + selects strictly `time` + `value`, tolerating extra raw-data columns + (Bad Aussee ships a `substation` column that Wien does not have). + +* **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 + overflow-count palette of the sibling plots and are **shaped by the + storage type** (filled square = infiltration box / Sickerbox, filled + triangle = gravel trench / Schotterrigol); identical tooltip. Rendered + as `*_cost-vs-evaporation.html` in the three case-study vignettes and + linked from `vignettes/index.Rmd` under "Kosten vs. Evapotranspiration". + +* `plot_cost_overflow_boxplot()` gains `y_var = "cost_per_evap_pct"` + (y-axis = total cost per percentage point of evapotranspiration + **above the reference minimum** — the lowest evapotranspiration among + the scenarios satisfying the validity criterion (`n_overflows <= x`; + fallback: complete run) —, EUR/%; the reference (minimum share, + criterion and scenario id) is named on a second title line, and + `label_best = TRUE` annotates the evapotranspiration gain + `"(+NN % Evapotranspiration)"` after the price; titles, y-label + and the `min_cost` objective/label follow) and + `facet_storage_type = TRUE` (two stacked storage-type panels — + infiltration box on top, gravel trench below — each with its own + best-per-box markers and frontier line; `plotly::ggplotly()` keeps + the split as stacked subplots). When both storage types share one + panel (no faceting) the overlaid points are shaped by the storage + type like the scatter siblings; faceted panels keep plain circles + for readability. The vignettes render + the three existing boxplot variants with storage-type panels plus the + new `*_cost-per-evap-boxplot.html` (cheapest EUR/% per class, + point size = evapotranspiration), linked from `vignettes/index.Rmd` + under "Boxplot – Kosten je Prozent Evapotranspiration". + +* `plot_cost_vs_overflow_volume()` points are now also **shaped by the + storage type** (square/triangle, own legend under the colour legend). + +* The shared cost tooltip gains a derived **"Kosten je % Evapotranspiration + (über Min. von X %) [€/%]"** line right below the total cost: the + total cost per percentage point of element evapotranspiration + **above the reference minimum** (the lowest evapotranspiration among + the scenarios satisfying the validity criterion `n_overflows <= x`; + fallback: complete run) — the baseline comes "for free", only the + gain is paid for. The reference value is named in the line; "-" at or + below the minimum. Shown consistently in both cost scatters and all + cost boxplot variants. German labels consistently say + **"Evapotranspiration"** instead of "Verdunstung" throughout. + +* New **usable storage volume** of the storage layer: + `storage_volume_m3 = mulde_area * storage_height/1000 * + (thetaS - thetaFC)` (usable porosity 0.95 infiltration box / 0.3 + gravel trench). The vignettes add the column to the parameter grid + (grid datatable + results CSV) and the tooltips of **all** scenario + plots show it as "Nutzbares Speichervolumen [m³]" — the cost scatters + and boxplots (right below the storage type), the water-balance + trade-off plot and the design-space plots (there sourced from + `sim_results`, since the plotting grid drops the helper columns) — + computed on the fly from the `storage_theta*` columns for existing + result sets without the column. In the "Variierende Parameter" block the raw + storage_type values are now translated too + (`Speichertyp=Schotterrigol` instead of `=gravel_trench`; shared + value labels with the `plot_main_effects()` storage-type panel). + +* The boxplots' point-size legend keys match the plotted markers: with + storage-type shapes in use (no faceting) they are drawn with the grey + square/triangle instead of the default circle; the faceted variants + use circular points and matching circular keys. + +* Storage-type names in legends and facet strips are now the **short, + language-specific** ones ("Sickerbox" / "Schotterrigol" for `lang = + "de"`, "Infiltration box" / "Gravel trench" for `"en"`); only the + bold tooltip line keeps the long bilingual form. + +* All cost plots now carry a **caption naming the unit-cost rates** + they were computed with (new exported `cost_rates_caption()`, built + from [`default_cost_rates()`]: Aushub 70 €/m³ · Profilierung + + Begrünung 10 €/m² · Bodenfilter 200 €/m³ · Sickerbox 350 €/m³ · + Schotterrigol 50 €/m³, incl. installation). ggplot renders it at the + bottom of the PDFs (`caption` argument, `""` to drop); since + `plotly::ggplotly()` drops captions, the new exported + `plotly_add_caption()` re-adds it as a bottom annotation in the + interactive HTMLs (wired up in all three vignettes). + +* New exported helper `plotly_split_legend()` — cleans up the + interactive legends: `plotly::ggplotly()` flattens colour + shape + into unreadable `"(0,Sickerbox / Infiltration box)"` tuple entries. + The helper rebuilds the legend from legend-only keys with + unambiguous glyphs: one **neutral circle per overflow class in the + class colour** (clicking toggles both storage types of the class) + plus two **neutral grey** square/triangle keys under a + "Speichertyp" / "Storage type" group title (skippable via + `add_shape_legend = FALSE` for faceted plots) — a coloured + square/triangle key would wrongly suggest one specific + (colour, type) combination. The storage-type keys are **individually + clickable** (a JavaScript handler toggles all traces with that marker + symbol, since the traces' only legend group is taken by the overflow + class); the overlapping combined legend title is + removed and the legend moves to a vertical layout on the right. + Applied in all three vignettes to the cost-vs-overflow, + cost-vs-evaporation, water-balance and design-space HTMLs. + +* `plot_wb_tradeoff_overflows()` no longer crashes with "Can't combine + `mulde_area` and `storage_type` " on two-type + grids: its inline copy of the varying-parameter tooltip block was + replaced by the shared `build_varying_param_html()` helper (the + tooltip parameter names are now translated via + `default_param_labels()`, as in the cost plots). When the results + carry a `storage_type` column, its points are shaped by the storage + type (square/triangle) and the tooltip names the type; single-type + result sets plot as before. + +* `plot_valid_design_space()` gains `facet_storage_type` — two stacked + storage-type panels with **free y-scales**, so disjoint per-type + levels (storage_height: 300–1200 mm boxes vs. 900–3600 mm trenches) + fill their own panel; duplicate-based alpha is then counted per + panel and the points stay plain circles (the strips name the type). + Without faceting, points are shaped by the storage type whenever + `storage_type` varies. The vignettes facet both design-space blocks. + +* `plot_main_effects()` now supports character parameters (the pivot + previously failed on mixed types), keeps numeric level ordering + ("500" no longer sorts after "1000"), and renders `storage_type` as + its own panel with display names (Sickerbox/Schotterrigol or + Infiltration box/Gravel trench). The vignettes add `storage_type` to + the main-effects parameter set. + +* Fixed `build_varying_param_html()` (the shared tooltip helper): it + errored with "Can't combine `storage_height` and + `storage_type` " as soon as the character column + `storage_type` varied across scenarios — i.e. for every grid sweeping + both storage types (`values_transform = as.character` in the pivot). + Numbers in the varying-parameters tooltip block are now formatted + element-wise, so one decimal-valued parameter no longer forces + trailing ".00" onto every other value. The vignettes additionally + drop the storage_theta* helper columns (fully determined by + `storage_type`) from the plotting `param_grid`, keeping tooltips + lean. + +* New exported plot `plot_cost_vs_overflow_volume()` — companion to + `plot_wb_tradeoff_overflows()` for cost-aware optimisation. + Scatters `cost_total` (EUR) against overflow volume (m³, computed + from `sum_overflows` [mm] and `mulde_area` [m²]), points coloured + discretely by `n_overflows` with the same `0..x / ">x"` palette + and top legend as the water-balance plot. Both cost plots report the + **share of scenarios meeting the validity criterion** (`n_overflows` + ≤ `x`) in the plot title (e.g. `(39 % mit ≤ 5 Überläufen)`), since + ggplotly drops ggplot subtitles. The plotly tooltip + carries the element water balance (evapotranspiration, infiltration, + overflow — all in %), the chosen storage type on its own bold line + (bilingual, `Sickerbox / Infiltration box` or + `Schotterrigol / Gravel trench`), the full cost breakdown (excavation, + profiling, filter, storage, total) plus the varying `param_grid` + entries (translated via `default_param_labels()`). Rendered + as HTML (`*_cost-vs-overflow-volume.html`) in the three case-study + vignettes and linked from `vignettes/index.Rmd` under a new + "Kosten vs. Überlaufvolumen" section. + +* New exported plot `plot_cost_overflow_boxplot()` — boxplot of the + total construction cost (EUR, y) per number of overflow events (x), + with the individual scenarios overlaid as jittered points whose + **size scales with the overflow volume** (m³; the size scale is + calibrated to the valid region so the many-overflow outliers do not + shrink the valid-region points away, and a minimum size keeps every + point visible). Counts up to the + threshold `x` (= `max_n_overflows`, as in the sibling plots) each get + their own box; higher counts collapse into a single `">x"` catch-all + box (furthest right, red), keeping the axis readable for the long-tailed + 15-year runs (Wien / Bad Aussee reach several hundred overflow events); + the `">x"` box highlights the scenario with the fewest overflow events + above `x`. One best scenario per box is highlighted with a black-outlined + diamond + in that box's group colour (so its tooltip inherits the group colour), + and the best scenarios of **all** boxes are joined by a frontier line + (`mark_best` / `connect_best`). The point tooltip is identical to + `plot_cost_vs_overflow_volume()`. `best_by` picks the objective (cost + as tie-breaker) — `"min_cost"` (cheapest), `"min_overflow"` (smallest + overflow volume) or `"max_evapotranspiration"` (highest + evapotranspiration) — so the three variants trace three different + frontier lines; `label_best` annotates the marker (overflow volume + + share `"NN m³ / NN %"`, or evapotranspiration `"NN %"`); + `size_by` scales the points by overflow volume (default) or + evapotranspiration. The three case-study vignettes render all three + variants (`*_cost-by-overflows-boxplot-cheapest.html`, + `*-min-overflow.html`, `*-max-evap.html`), each linked from + `vignettes/index.Rmd` under the grouped "Kosten" section. + +* New exported helper `default_param_labels()` — German / English, + unit-carrying labels for the parameter-grid columns. The + "varying parameters" block of both cost-plot tooltips now shows e.g. + `Muldenfläche [m²]=125` instead of the raw `mulde_area=125`; pass + `param_labels =` to the plot functions to override. + +## Documentation website + +* The two deploy-only articles `vignettes/index.Rmd` (brute-force link + hub) and `vignettes/monotonicity_analysis.Rmd` are removed from the + repository (they remain in the git history). Their relative links only + worked in the deploy structure on + and their result files never + exist on GitHub Actions, so the pkgdown-built copies were link-dead + shells. The complete rendered pages live on that server; the pkgdown + navbar instead gains a "More results" menu linking them (brute force, + monotonicity analysis, optimiser vs. brute force), and the two + optimisation vignettes link the monotonicity analysis there too. + +## Consistency + +* Non-ASCII characters in R code are now unicode-escaped: all string literals + in `plot_cost_vs_overflow_volume()` and in the vignette code chunks use + `\uxxxx` escapes (rendered labels unchanged), and the few non-ASCII code + comments were rewritten in plain ASCII. Markdown prose keeps UTF-8 (escapes + are not interpreted there). + +* Eisenstadt 2005 (`workflow_eisenstadt-2005.Rmd` and + `workflow_eisenstadt-2005_neu.Rmd`) now pins + `//Massnahmenelemente/Mulde_Rigole/Parameter_Evapotranspiration/LAI_LeafAreaIndex = 3.9` + (Hörnschemeyer grass value) so all four case-study vignettes + operate on the same LAI baseline. Wien uses it as one of the + sweep levels (`c(3.9, 8.5)`), Bad Aussee identical to Wien. ## Bug fixes -* `vignettes/example_wien_minimal.Rmd` now converts ET0 from mm/day to mm/h +* `vignettes/workflow_eisenstadt-2005.Rmd` now pipes the joined optimisation + results through `kwb.raindrop::compute_costs()` like the Wien and Bad Aussee + workflows already did. Without it the vignette's + `plot_cost_vs_overflow_volume()` call aborted with "missing column(s): + cost_excavation, ..." — the cost-vs-overflow-volume PDF/HTML was never + produced and the exported CSV lacked the cost columns. + +* `get_simulation_results_optim()` now treats a result HDF5 that exists but + cannot be opened/read (e.g. the engine crashed mid-write for a scenario, or a + file briefly locked just after the run) like a missing file: it `warning()`s, + names the scenario, and returns `NULL` instead of throwing. Because the Wien / + Bad Aussee / Eisenstadt workflows now read results per run inside `run_one()`, + a single unreadable file used to abort the entire `future_lapply` batch (seen + as an `H5File.open()` "unable to open file" error mid-render); the run now + completes with NA rows for the affected scenarios. + +* `vignettes/example_wien_minimal.Rmd`, `vignettes/workflow_wien.Rmd` and + `vignettes/workflow_badaussee.Rmd` now convert ET0 from mm/day to mm/h (`value / period_et`) before writing `//Kurven/ET0`, mirroring the existing rain conversion. The engine reads the ET0 curve as a mm/h rate, so the unconverted daily values were integrated 24× too high — the cause of the - implausibly large modelled ET share. The timeseries-info summary now labels - ET0 as mm/h and recovers its total via `value * period_h`. + implausibly large modelled ET share. The minimal vignette's timeseries-info + summary now labels ET0 as mm/h and recovers its total via `value * period_h`. ## New features +* The Wien and Bad Aussee workflows now thin each run to its optimisation row + **inside** `run_one()` (via `get_simulation_results_optim(..., lean = TRUE)` + + `add_overflow_events_and_waterbalance()`) and `run_scenarios()` returns + those one-row tibbles for a final `dplyr::bind_rows()`. This replaces the + previous "run everything, then read every run's full results into memory at + once" pass (`get_simulation_results_optim_parallel()`), drastically cutting + peak RAM for large parameter grids. + +* `get_simulation_results_optim()` gains a `lean` argument. When `TRUE` it + reads only the fields consumed downstream (`element$rates`, + `element$water_balance`, `connected_area$water_balance`) and leaves the + unused `meta`/`states` and `connected_area$rates` as `NULL`, minimising + per-run memory and I/O. Its intro message is now gated behind `debug`. + * `inst/scripts/prepare_eisenstadt_swmm_timeseries.R` extracts the rain (`/Kurven/Regen`) and ET0 (`/Kurven/ET0`) curves from an engine HDF5 and writes SWMM-5 external time-series files. It converts **out** of the @@ -46,7 +469,7 @@ * New vignette `example_wien_minimal`: a self-contained smoke test of the full input → engine → results loop on Wien. Now extended into an ET-diagnostics grid that sweeps three engine switches — - `keineVerdunstungBeiRegen`, `Hoernschemeyer_aktiv` and the + `keineEvapotranspirationBeiRegen`, `Hoernschemeyer_aktiv` and the `ET0ref_GrasReferenzverdunstung` factor (`0`, `1`, `100`) — at Daniel's reference geometry (12 scenarios total). Daniel's three XLSX-review corrections (`Dach/Evapotranspiration_aktiv = 0`, diff --git a/R/compute_costs.R b/R/compute_costs.R index 75d810b..6651673 100644 --- a/R/compute_costs.R +++ b/R/compute_costs.R @@ -27,6 +27,58 @@ default_cost_rates <- function() { ) } +#' Caption line naming the unit-cost rates behind the cost plots +#' +#' Formats the unit-cost rates (EUR per m2 / m3, see [default_cost_rates()]) +#' as a single-line caption for the cost plots, so every figure names the +#' rates its EUR values were computed with. Used as the default `caption` of +#' [plot_cost_vs_overflow_volume()], [plot_cost_vs_evaporation()] and +#' [plot_cost_overflow_boxplot()] (rendered by ggplot at the bottom of the +#' PDFs) and passed to [plotly_add_caption()] for the interactive HTMLs +#' (`plotly::ggplotly()` drops ggplot captions). +#' +#' If the costs were computed with non-default rates, pass the same +#' `cost_rates` list here so the caption matches the numbers. +#' +#' @param lang Character. `"de"` or `"en"`. +#' @param cost_rates `list` of unit costs as returned by +#' [default_cost_rates()]. +#' +#' @return `character(1)`, a single line. +#' +#' @examples +#' cost_rates_caption("de") +#' +#' @export +cost_rates_caption <- function(lang = c("de", "en"), + cost_rates = default_cost_rates()) { + lang <- match.arg(lang) + f <- function(v) format(v, trim = TRUE, big.mark = " ", scientific = FALSE) + switch( + lang, + de = paste0( + "Kostens\u00e4tze (inkl. Einbau): Aushub ", + f(cost_rates$excavation_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "Profilierung + Begr\u00fcnung ", + f(cost_rates$profiling_eur_per_m2), " \u20ac/m\u00b2 \u00b7 ", + "Bodenfilter ", f(cost_rates$filter_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "Sickerbox ", + f(cost_rates$infiltration_box_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "Schotterrigol ", f(cost_rates$gravel_trench_eur_per_m3), " \u20ac/m\u00b3" + ), + en = paste0( + "Cost rates (incl. installation): excavation ", + f(cost_rates$excavation_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "profiling + greening ", + f(cost_rates$profiling_eur_per_m2), " \u20ac/m\u00b2 \u00b7 ", + "soil filter ", f(cost_rates$filter_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "infiltration box ", + f(cost_rates$infiltration_box_eur_per_m3), " \u20ac/m\u00b3 \u00b7 ", + "gravel trench ", f(cost_rates$gravel_trench_eur_per_m3), " \u20ac/m\u00b3" + ) + ) +} + #' Compute construction costs for an infiltration-swale parameter grid #' #' Given a parameter grid that drives the simulation, attach a per-scenario diff --git a/R/cost_tooltip.R b/R/cost_tooltip.R new file mode 100644 index 0000000..00de3da --- /dev/null +++ b/R/cost_tooltip.R @@ -0,0 +1,352 @@ +#' German / English labels for optimisation parameter-grid columns +#' +#' Maps the raw `param_grid` column names produced by the case-study workflows +#' to human-readable, unit-carrying labels. Used to translate the +#' "varying parameters" block in the interactive tooltips of +#' [plot_cost_vs_overflow_volume()] and [plot_cost_overflow_boxplot()], so a +#' hovered point shows e.g. `Muldenflaeche [m2]=125` instead of the raw +#' `mulde_area=125`. +#' +#' Unknown columns fall back to their raw name, so a grid gaining a new column +#' still renders (just untranslated). Override individual entries or pass your +#' own named vector via the `param_labels` argument of the plot functions. +#' +#' @param lang Character. `"de"` or `"en"`. +#' +#' @return A named `character` vector: names are `param_grid` column names, +#' values are the display labels. +#' +#' @export +#' +#' @examples +#' default_param_labels("de")[["mulde_area"]] +#' default_param_labels("en")[["storage_height"]] +default_param_labels <- function(lang = c("de", "en")) { + lang <- match.arg(lang) + switch( + lang, + de = c( + connected_area = "Angeschlossene Fl\u00e4che [m\u00b2]", + mulde_area = "Muldenfl\u00e4che [m\u00b2]", + mulde_height = "Muldenh\u00f6he [mm]", + filter_hydraulicconductivity = "Filter-Leitf\u00e4higkeit kf [mm/h]", + filter_height = "Filterh\u00f6he [mm]", + storage_height = "Speicherh\u00f6he [mm]", + bottom_hydraulicconductivity = "Sohl-Leitf\u00e4higkeit kf [mm/h]", + rain_factor = "Regenfaktor [-]", + lai = "Blattfl\u00e4chenindex LAI [-]", + storage_type = "Speichertyp", + storage_volume_m3 = "Nutzbares Speichervolumen [m\u00b3]" + ), + en = c( + connected_area = "Connected area [m\u00b2]", + mulde_area = "Swale area [m\u00b2]", + mulde_height = "Swale depth [mm]", + filter_hydraulicconductivity = "Filter conductivity kf [mm/h]", + filter_height = "Filter thickness [mm]", + storage_height = "Storage thickness [mm]", + bottom_hydraulicconductivity = "Subsoil conductivity kf [mm/h]", + rain_factor = "Rain factor [-]", + lai = "Leaf area index LAI [-]", + storage_type = "Storage type", + storage_volume_m3 = "Usable storage volume [m\u00b3]" + ) + ) +} + +#' Short, language-specific display names for the storage_type values +#' +#' Used wherever the raw `storage_type` values appear as compact text: the +#' "varying parameters" tooltip block (`Speichertyp=Schotterrigol` instead of +#' `Speichertyp=gravel_trench`) and the x-axis of the `plot_main_effects()` +#' storage-type panel. The bold storage-type tooltip line keeps the longer +#' bilingual names from `cost_tooltip_labels()`. +#' +#' @param lang Character. `"de"` or `"en"`. +#' @return Named character vector (names = raw values). +#' @noRd +storage_type_value_labels <- function(lang = c("de", "en")) { + lang <- match.arg(lang) + switch( + lang, + de = c(infiltration_box = "Sickerbox", + gravel_trench = "Schotterrigol"), + en = c(infiltration_box = "Infiltration box", + gravel_trench = "Gravel trench") + ) +} + +#' Per-scenario HTML of the varying parameter-grid entries (translated) +#' +#' Detects the `param_grid` columns that vary across scenarios (excluding +#' `scenario_name`), formats their values, translates the parameter names via +#' `param_labels`, and collapses them into one `
`-separated HTML string per +#' scenario for use in a plotly tooltip. +#' +#' @param param_grid Data frame with a `scenario_name` column. +#' @param lang Character. `"de"` or `"en"`. +#' @param param_labels Named character vector mapping columns to labels, or +#' `NULL` to use [default_param_labels()]. +#' @param digits_params Integer. Rounding for numeric parameter values. +#' +#' @return A tibble with columns `scenario_name` and `params_html`. +#' +#' @importFrom dplyr %>% select summarise across everything n_distinct filter +#' @importFrom dplyr pull mutate group_by all_of coalesce +#' @importFrom tidyr pivot_longer +#' @importFrom purrr map_chr +#' @importFrom rlang .data +#' @noRd +build_varying_param_html <- function(param_grid, lang = c("de", "en"), + param_labels = NULL, digits_params = 4L) { + lang <- match.arg(lang) + if (is.null(param_labels)) param_labels <- default_param_labels(lang) + + varying_params <- param_grid %>% + dplyr::select(-"scenario_name") %>% + dplyr::summarise(dplyr::across(dplyr::everything(), + ~ dplyr::n_distinct(.) > 1)) %>% + tidyr::pivot_longer(dplyr::everything(), + names_to = "param", + values_to = "vary") %>% + dplyr::filter(.data$vary) %>% + dplyr::pull("param") + + if (length(varying_params) == 0) { + return( + param_grid %>% + dplyr::select("scenario_name") %>% + dplyr::mutate(params_html = "") + ) + } + + param_grid %>% + dplyr::select("scenario_name", dplyr::all_of(varying_params)) %>% + # values_transform: numeric and character parameters (e.g. storage_type) + # cannot share one `val` column otherwise. + tidyr::pivot_longer(-"scenario_name", + names_to = "param", + values_to = "val", + values_transform = list(val = as.character)) %>% + dplyr::mutate( + val_chr = purrr::map_chr(.data$val, ~ paste(.x, collapse = ",")), + val_num = suppressWarnings(as.numeric(.data$val_chr)), + # format element-wise: a vectorised format() would pad every parameter + # to the maximum number of decimals in the column (e.g. "100.00" + # because another parameter has value 0.95). + val_fmt = ifelse( + is.na(.data$val_num), + .data$val_chr, + vapply(.data$val_num, + function(v) format(round(v, digits_params), trim = TRUE, + scientific = FALSE), + character(1)) + ), + # storage_type values get their short display names + # (Speichertyp=Schotterrigol instead of =gravel_trench) + val_fmt = ifelse( + .data$param == "storage_type" & + .data$val_chr %in% names(storage_type_value_labels(lang)), + unname(storage_type_value_labels(lang)[.data$val_chr]), + .data$val_fmt + ), + param_label = dplyr::coalesce(unname(param_labels[.data$param]), + .data$param), + kv = paste0(.data$param_label, "=", .data$val_fmt) + ) %>% + dplyr::group_by(.data$scenario_name) %>% + dplyr::summarise(params_html = paste(.data$kv, collapse = "
"), + .groups = "drop") +} + +#' Shared tooltip labels for the cost plots +#' +#' The `tt_*` label set used to assemble the (identical) plotly tooltip of +#' [plot_cost_vs_overflow_volume()] and [plot_cost_overflow_boxplot()]. +#' +#' @param lang Character. `"de"` or `"en"`. +#' @return Named list of label strings. +#' @noRd +cost_tooltip_labels <- function(lang = c("de", "en")) { + lang <- match.arg(lang) + switch( + lang, + de = list( + tt_scenario = "Szenario", + tt_n_overflows = "Anzahl \u00dcberlaufereignisse", + tt_sum_overflows_mm = "Summe \u00dcberl\u00e4ufe [mm]", + tt_overflow_volume = "\u00dcberlaufvolumen [m\u00b3]", + tt_wb_header = "Wasserhaushalt [%]", + tt_wb_evap = "Evapotranspiration", + tt_wb_infil = "Versickerung", + tt_wb_overflow = "\u00dcberlauf", + tt_cost_total = "Gesamtkosten", + tt_cost_per_evap = "Kosten je % Evapotranspiration", + tt_above_min = "\u00fcber Min. von", + tt_cost_excavation = "Aushub", + tt_cost_profiling = "Profilierung + Begr\u00fcnung", + tt_cost_filter = "Bodenfilter", + tt_cost_storage = "Speicherschicht", + tt_storage_type = "Speichertyp", + tt_storage_volume = "Nutzbares Speichervolumen [m\u00b3]", + st_infiltration_box = "Sickerbox / Infiltration box", + st_gravel_trench = "Schotterrigol / Gravel trench", + tt_costs_header = "Kostenaufteilung [\u20ac]", + tt_params = "Variierende Parameter" + ), + en = list( + tt_scenario = "Scenario", + tt_n_overflows = "Number of overflow events", + tt_sum_overflows_mm = "Sum of overflows [mm]", + tt_overflow_volume = "Overflow volume [m\u00b3]", + tt_wb_header = "Water balance [%]", + tt_wb_evap = "Evapotranspiration", + tt_wb_infil = "Infiltration", + tt_wb_overflow = "Overflow", + tt_cost_total = "Total cost", + tt_cost_per_evap = "Cost per % evapotranspiration", + tt_above_min = "above min. of", + tt_cost_excavation = "Excavation", + tt_cost_profiling = "Profiling + greening", + tt_cost_filter = "Soil filter", + tt_cost_storage = "Storage layer", + tt_storage_type = "Storage type", + tt_storage_volume = "Usable storage volume [m\u00b3]", + st_infiltration_box = "Infiltration box / Sickerbox", + st_gravel_trench = "Gravel trench / Schotterrigol", + tt_costs_header = "Cost breakdown [\u20ac]", + tt_params = "Varying parameters" + ) + ) +} + +#' Storage-type display factor and marker shapes for the cost plots +#' +#' Maps the raw `storage_type` values to their **short, language-specific** +#' display names (from `storage_type_value_labels()`; e.g. "Sickerbox" / +#' "Schotterrigol" for `lang = "de"`) and to the fixed marker shapes shared +#' by all cost plots: **filled square (15) = infiltration box**, **filled +#' triangle (17) = gravel trench**. Used for legends and facet strips; the +#' tooltip's bold storage-type line keeps the longer bilingual names from +#' `cost_tooltip_labels()`. Values that are `NA` or unknown fall back to the +#' infiltration box, mirroring `cost_tooltip_text()`. +#' +#' @param storage_type Character vector of raw values +#' (`"infiltration_box"` / `"gravel_trench"`). +#' @param lang Character. `"de"` or `"en"`. +#' @return List with `display` (factor, infiltration box level first) and +#' `shape_values` (named vector for `ggplot2::scale_shape_manual()`). +#' @noRd +storage_type_shapes <- function(storage_type, lang = c("de", "en")) { + lang <- match.arg(lang) + labels <- storage_type_value_labels(lang) + raw <- as.character(storage_type) + disp <- ifelse(!is.na(raw) & raw == "gravel_trench", + labels[["gravel_trench"]], labels[["infiltration_box"]]) + lvls <- unname(labels[c("infiltration_box", "gravel_trench")]) + list( + display = factor(disp, levels = lvls), + shape_values = stats::setNames(c(15, 17), lvls) + ) +} + +#' 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, +#' otherwise derived from the `storage_theta*` columns; `NULL` when neither +#' is present (old result sets), so callers can omit their tooltip line. +#' +#' @param df Data frame (results or parameter grid). +#' @return Numeric vector or `NULL`. +#' @noRd +storage_volume_from_df <- function(df) { + if ("storage_volume_m3" %in% names(df)) { + df$storage_volume_m3 + } else if (all(c("mulde_area", "storage_height", "storage_thetaS", + "storage_thetaFC") %in% names(df))) { + df$mulde_area * df$storage_height / 1000 * + (df$storage_thetaS - df$storage_thetaFC) + } else { + NULL + } +} + +#' Assemble the shared cost-plot tooltip HTML for each row of `df` +#' +#' `df` must carry `scenario_name`, `n_overflows`, `sum_overflows`, +#' `overflow_volume_m3`, the three `element.WB_*` shares, the five `cost_*` +#' columns, `storage_type` and `params_html`. Returns one HTML string per row. +#' Both cost plots call this so their tooltips are byte-identical. +#' +#' @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 +#' 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). +#' @return Character vector, length `nrow(df)`. +#' @noRd +cost_tooltip_text <- function(df, tt, digits = 2L, evap_min = NULL) { + st_raw <- if ("storage_type" %in% names(df)) { + as.character(df$storage_type) + } else { + rep(NA_character_, nrow(df)) + } + st_disp <- ifelse(!is.na(st_raw) & st_raw == "gravel_trench", + tt$st_gravel_trench, tt$st_infiltration_box) + # Derived cost efficiency: total cost per percentage point of element + # evapotranspiration ABOVE the run minimum [EUR/%] -- the baseline + # evapotranspiration comes "for free", only the gain beyond the worst + # scenario is paid for. Undefined ("-") at or below the minimum (the + # minimum scenario itself has no defined marginal cost). + evap <- df[["element.WB_Evapotranspiration_"]] + if (is.null(evap_min)) { + evap_min <- suppressWarnings(min(evap, na.rm = TRUE)) + } + evap_delta <- evap - evap_min + cpe <- ifelse(!is.na(df$cost_total) & !is.na(evap_delta) & evap_delta > 0, + df$cost_total / evap_delta, NA_real_) + cpe_fmt <- vapply(cpe, function(v) { + if (is.na(v)) "-" else format(round(v, 0), big.mark = " ", trim = TRUE) + }, character(1)) + cpe_label <- paste0(tt$tt_cost_per_evap, " (", tt$tt_above_min, " ", + round(evap_min, digits), " %) [\u20ac/%]") + # Usable storage volume line; omitted for result sets where it is not + # derivable (see storage_volume_from_df()). + storage_volume <- storage_volume_from_df(df) + storage_volume_line <- if (is.null(storage_volume)) { + "" + } else { + paste0("
", tt$tt_storage_volume, ": ", round(storage_volume, digits)) + } + paste0( + tt$tt_scenario, ": ", df$scenario_name, + "
", tt$tt_n_overflows, ": ", df$n_overflows, + "
", tt$tt_sum_overflows_mm, ": ", round(df$sum_overflows, digits), + "
", tt$tt_overflow_volume, ": ", round(df$overflow_volume_m3, digits), + "

", tt$tt_wb_header, "", + "
", tt$tt_wb_evap, ": ", + round(df[["element.WB_Evapotranspiration_"]], digits), + "
", tt$tt_wb_infil, ": ", + round(df[["element.WB_InfiltrationNetto_"]], digits), + "
", tt$tt_wb_overflow, ": ", + round(df[["element.WB_Oberflaechenablauf_Ueberlauf_"]], digits), + "

", tt$tt_storage_type, ": ", st_disp, "", + storage_volume_line, + "

", tt$tt_costs_header, "", + "
", tt$tt_cost_excavation, ": ", + format(round(df$cost_excavation, 0), big.mark = " ", trim = TRUE), + "
", tt$tt_cost_profiling, ": ", + format(round(df$cost_profiling, 0), big.mark = " ", trim = TRUE), + "
", tt$tt_cost_filter, ": ", + format(round(df$cost_filter, 0), big.mark = " ", trim = TRUE), + "
", tt$tt_cost_storage, ": ", + format(round(df$cost_storage, 0), big.mark = " ", trim = TRUE), + "
", tt$tt_cost_total, ": ", + format(round(df$cost_total, 0), big.mark = " ", trim = TRUE), "", + "
", cpe_label, ": ", cpe_fmt, + "

", tt$tt_params, "
", df$params_html + ) +} diff --git a/R/find_min_feasible.R b/R/find_min_feasible.R new file mode 100644 index 0000000..0347431 --- /dev/null +++ b/R/find_min_feasible.R @@ -0,0 +1,216 @@ +#' 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, +#' ). +#' 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 split_jitter Numeric in `[0, 0.45]`, default 0. With 0 the +#' interval is split exactly in half (deterministic). A positive value +#' draws the split fraction uniformly from `0.5 +- split_jitter` -- +#' a Monte-Carlo of the *search path*: repeated runs with different +#' seeds take different routes to the threshold and must agree within +#' `tol` if the result is a property of the problem, not of the path. +#' @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, + split_jitter = 0, + volume_column = "overflow_volume_m3", + verbose = FALSE) { + + stopifnot(split_jitter >= 0, split_jitter <= 0.45) + + 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 + } + } + + # 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) { + frac <- if (split_jitter > 0) { + stats::runif(1, 0.5 - split_jitter, 0.5 + split_jitter) + } else { + 0.5 + } + mid <- lo + frac * (hi - lo) + if (discrete) { + mid <- floor(mid) + if (mid <= lo) mid <- lo + 1 + if (mid >= hi) mid <- hi - 1 + } + 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) + } + } + + 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 + ) +} diff --git a/R/get_simulation_results_optim.R b/R/get_simulation_results_optim.R index 0540e13..311315e 100644 --- a/R/get_simulation_results_optim.R +++ b/R/get_simulation_results_optim.R @@ -26,6 +26,13 @@ #' @param simulation_names Character vector of simulation run identifiers #' (e.g. \code{c("s00001", "s00002")}). #' @param debug print debug messages (default: TRUE) +#' @param lean Logical. If \code{TRUE}, read only the fields consumed by +#' \code{\link{add_overflow_events_and_waterbalance}} -- \code{element$rates}, +#' \code{element$water_balance} and \code{connected_area$water_balance} -- and +#' leave \code{meta}/\code{states} (both sides) and \code{connected_area$rates} +#' as \code{NULL}. This keeps per-run memory and I/O minimal when each run is +#' thinned to its optimisation row immediately instead of collecting every +#' run's full results first. Defaults to \code{FALSE} (read everything). #' @return A named list with one entry per \code{simulation_names}. Each entry is #' either \code{NULL} (element HDF5 missing) or a nested list: #' \describe{ @@ -53,13 +60,16 @@ #' @importFrom stats setNames #' @importFrom hdf5r H5File get_simulation_results_optim <- function(paths, - path_list, + path_list, simulation_names, - debug = TRUE) { - - message(sprintf("Reading results files ('%s') for %d model runs", - paste0(c(paths$file_results_hdf5_element, paths$file_results_hdf5_flaeche), collapse = "|"), - length(simulation_names))) + debug = TRUE, + lean = FALSE) { + + if (isTRUE(debug)) { + message(sprintf("Reading results files ('%s') for %d model runs", + paste0(c(paths$file_results_hdf5_element, paths$file_results_hdf5_flaeche), collapse = "|"), + length(simulation_names))) + } stats::setNames(lapply(simulation_names, function(s_name) { paths <- kwb.utils::resolve(path_list, dir_target = s_name) @@ -82,56 +92,70 @@ get_simulation_results_optim <- function(paths, return(NULL) } - # Open H5 handles outside catAndRun so on.exit binds to *this* lambda's - # frame, not catAndRun's internal frame. Handles are guaranteed to close - # whichever way the iteration unwinds. - res_hdf5_element <- hdf5r::H5File$new(paths$path_results_hdf5_element, mode = "r") - on.exit(try(res_hdf5_element$close_all(), silent = TRUE), add = TRUE) + # Open + read in an inner function so its on.exit() handle-closing binds to + # *its own* frame and always fires (even on error) before we decide what to + # return. The surrounding tryCatch makes a result file that exists but is + # unreadable -- e.g. the engine crashed mid-write for that scenario -- + # behave like a missing file (NULL + warning) instead of aborting the whole + # (possibly parallel) batch. Downstream add_overflow_events_and_waterbalance() + # then emits an NA row for the scenario. + read_result <- function() { + res_hdf5_element <- hdf5r::H5File$new(paths$path_results_hdf5_element, mode = "r") + on.exit(try(res_hdf5_element$close_all(), silent = TRUE), add = TRUE) - res_hdf5_flaeche <- if (has_flaeche) { - h <- hdf5r::H5File$new(paths$path_results_hdf5_flaeche, mode = "r") - on.exit(try(h$close_all(), silent = TRUE), add = TRUE) - h - } else { - if (isTRUE(debug)) { - message(sprintf( - "No connected_area H5 for %s ('%s') -> connected_area = NULL", - s_name, paths$path_results_hdf5_flaeche - )) + res_hdf5_flaeche <- if (has_flaeche) { + h <- hdf5r::H5File$new(paths$path_results_hdf5_flaeche, mode = "r") + on.exit(try(h$close_all(), silent = TRUE), add = TRUE) + h + } else { + if (isTRUE(debug)) { + message(sprintf( + "No connected_area H5 for %s ('%s') -> connected_area = NULL", + s_name, paths$path_results_hdf5_flaeche + )) + } + NULL } - NULL - } - kwb.utils::catAndRun( - messageText = sprintf("(%d/%d)) Reading results files for model run %s", - which(simulation_names == s_name), - length(simulation_names), - paths$dir_target_output), - expr = { - element <- list( - meta = kwb.raindrop::read_hdf5_scalars(res_hdf5_element[["Metainfo"]], - numeric_only = FALSE), - rates = kwb.raindrop::read_hdf5_timeseries(res_hdf5_element[["Raten"]]), - water_balance = kwb.raindrop::read_hdf5_scalars(res_hdf5_element[["Wasserbilanz"]]), - states = kwb.raindrop::read_hdf5_timeseries(res_hdf5_element[["Zustandsvariablen"]]) - ) - - connected_area <- if (!is.null(res_hdf5_flaeche)) { - list( - meta = kwb.raindrop::read_hdf5_scalars(res_hdf5_flaeche[["Metainfo"]], + kwb.utils::catAndRun( + messageText = sprintf("(%d/%d)) Reading results files for model run %s", + which(simulation_names == s_name), + length(simulation_names), + paths$dir_target_output), + expr = { + element <- list( + meta = if (lean) NULL else kwb.raindrop::read_hdf5_scalars(res_hdf5_element[["Metainfo"]], numeric_only = FALSE), - rates = kwb.raindrop::read_hdf5_timeseries(res_hdf5_flaeche[["Raten"]]), - water_balance = kwb.raindrop::read_hdf5_scalars(res_hdf5_flaeche[["Wasserbilanz"]]), - states = kwb.raindrop::read_hdf5_timeseries(res_hdf5_flaeche[["Zustandsvariablen"]]) + rates = kwb.raindrop::read_hdf5_timeseries(res_hdf5_element[["Raten"]]), + water_balance = kwb.raindrop::read_hdf5_scalars(res_hdf5_element[["Wasserbilanz"]]), + states = if (lean) NULL else kwb.raindrop::read_hdf5_timeseries(res_hdf5_element[["Zustandsvariablen"]]) ) - } else { - NULL - } - list(element = element, connected_area = connected_area) - }, - dbg = debug - ) + connected_area <- if (!is.null(res_hdf5_flaeche)) { + list( + meta = if (lean) NULL else kwb.raindrop::read_hdf5_scalars(res_hdf5_flaeche[["Metainfo"]], + numeric_only = FALSE), + rates = if (lean) NULL else kwb.raindrop::read_hdf5_timeseries(res_hdf5_flaeche[["Raten"]]), + water_balance = kwb.raindrop::read_hdf5_scalars(res_hdf5_flaeche[["Wasserbilanz"]]), + states = if (lean) NULL else kwb.raindrop::read_hdf5_timeseries(res_hdf5_flaeche[["Zustandsvariablen"]]) + ) + } else { + NULL + } + + list(element = element, connected_area = connected_area) + }, + dbg = debug + ) + } + + tryCatch(read_result(), error = function(e) { + warning(sprintf( + "Scenario '%s': result HDF5 unreadable ('%s'): %s -- treating as missing (NULL).", + s_name, paths$path_results_hdf5_element, conditionMessage(e) + ), call. = FALSE) + NULL + }) }), nm = simulation_names) } diff --git a/R/make_swale_runner.R b/R/make_swale_runner.R new file mode 100644 index 0000000..abe4e6a --- /dev/null +++ b/R/make_swale_runner.R @@ -0,0 +1,270 @@ +#' Capillary suction from hydraulic conductivity (Rawls fit) +#' +#' Suction head Psi_s in mm as a function of the saturated hydraulic +#' conductivity in mm/h, as used by the workflow vignettes. +#' +#' @param kf_mmh Saturated hydraulic conductivity in mm/h. +#' @return Suction head in mm. +#' @keywords internal +#' @noRd +psi_s_mm <- function(kf_mmh) { + (3.237 * (kf_mmh / 25.4)^(-0.328)) * 25.4 +} + +#' Create a site-specific single-scenario runner for the optimiser +#' +#' Factors the `run_one()` function that was duplicated across the three +#' workflow vignettes (Eisenstadt 2005, Wien, Bad Aussee) into one +#' package-level closure factory. The returned function runs the RAINDROP +#' engine for one parameter set and returns the thinned one-row +#' optimisation result (overflow events + water balance), augmented with +#' the input parameters and the overflow volume in m3. +#' +#' Site differences are covered by the arguments: Eisenstadt scales the +#' rain curve shipped in `base.h5` by `rain_factor` (leave +#' `timeseries_rain` = `NULL`), Wien and Bad Aussee replace the rain and +#' ET0 curves entirely (`timeseries_rain` / `timeseries_et`, values in +#' mm/h as written by the vignettes). +#' +#' On the first call the runner prepares a **site master file** once: +#' `base.h5` plus everything identical for every run (calculation +#' settings, ET/rain time series). Each run then copies the master and +#' writes only its ~15 small parameter datasets. Compared to the +#' previous full read/rewrite of *all* datasets per run this removes +#' the dominant per-run overhead of the optimisation searches +#' (hundreds of runs; for Wien / Bad Aussee it skips rewriting the +#' 15-year rain series on every single engine run). +#' +#' @param path_list Path definition list as used by the workflow vignettes +#' (resolvable with `kwb.utils::resolve()`, must contain `path_base`, +#' `path_exe`, `dir_input`, `dir_output`, `dir_target_output`, +#' `path_target_input`, `path_results_hdf5_element`, +#' `path_results_hdf5_flaeche`, `file_target`). +#' @param timestep_hours Engine time step in hours (default 0.1). +#' @param timeseries_rain Optional data.frame `time`/`value` (mm/h) written +#' to `//Kurven/Regen` (the dataset must exist in `base.h5`); when +#' given, the `//Kurven/Growth_1` and `//Kurven/Shading_1` end times +#' are extended to the rain series end (skipped for templates without +#' these curves) and `rain_factor` is ignored. Without +#' `timeseries_rain`, a per-run `rain_factor != 1` requires +#' `//Kurven/Regen` to exist as a time series in `base.h5` -- a clear +#' error is thrown otherwise. +#' @param timeseries_et Optional data.frame `time`/`value` (mm/h) written +#' to `//Kurven/ET0`. +#' @param storage_types Soil presets of the storage layer per storage type, +#' see [default_storage_types()]. +#' @param event_separation_hours Event separation for overflow counting +#' (default 4, as in the vignettes and the monotonicity analysis). +#' @param scenario_prefix Prefix for generated scenario names (default +#' `"o"` -> `o00001`, `o00002`, ... -- distinct from the grid runs +#' `s00001` ...). +#' @param cleanup Delete each scenario's copied input file and output +#' directory right after the thinned one-row result has been read +#' (default `TRUE`). The optimisers only need that row; without the +#' cleanup an optimisation run (hundreds of engine runs per task, each +#' with its own copy of `base.h5` plus all output HDF5s) fills the +#' temp drive and the engine aborts with HDF5 `errno = 28` ("No space +#' left on device"). Set `FALSE` to keep all scenario files for +#' debugging. Files of a *failed* run are always kept. +#' @param debug Passed on to the engine/reader helpers. +#' +#' @return `function(params)` where `params` is a named list (or one-row +#' data.frame) with `mulde_area`, `mulde_height` (mm), `storage_type`, +#' `storage_height` (mm), `connected_area` (m2), `filter_height` (mm), +#' `filter_hydraulicconductivity` (mm/h), `bottom_hydraulicconductivity` +#' (mm/h) and optionally `rain_factor` (default 1) and `lai` +#' (default 3.9). It returns a one-row tibble with the parameters, the +#' scenario name and the optimisation metrics (`n_overflows`, +#' `sum_overflows` in mm, `overflow_volume_m3`, water-balance shares). +#' +#' @seealso [optimise_swale_design()], [find_min_feasible()] +#' @export +make_swale_runner <- function(path_list, + timestep_hours = 0.1, + timeseries_rain = NULL, + timeseries_et = NULL, + storage_types = default_storage_types(), + event_separation_hours = 4, + scenario_prefix = "o", + cleanup = TRUE, + debug = FALSE) { + + counter <- 0L + master_path <- NULL + template_rain <- NULL + + # One-time site master: base.h5 plus everything that is identical for + # every run (calculation settings, ET/rain time series). Each run then + # copies the master and writes only its ~15 small parameter datasets -- + # the full read/write cycle of all datasets (incl. multi-year curves) + # per engine run dominated the runtime of the optimisation searches. + prepare_master <- function(paths) { + mp <- file.path(paths$dir_input, + sprintf("%s_master.h5", scenario_prefix)) + fs::dir_create(paths$dir_input, recurse = TRUE) + fs::file_copy(path = paths$path_base, new_path = mp, overwrite = TRUE) + + h5m <- hdf5r::H5File$new(mp, mode = "a") + on.exit(try(h5m$close_all(), silent = TRUE), add = TRUE) + + static_vals <- list( + `//Berechnungsparameter/Zeitschritt_Infiltration` = timestep_hours, + `//Berechnungsparameter/Zeitschritt_ET` = timestep_hours, + `//Berechnungsparameter/Zeitschritt_Verschaltungen` = timestep_hours, + `//Berechnungsparameter/R-Plots` = 0, + `//Berechnungsparameter/Ausgabemodus` = "Optimierung", + `//Berechnungsparameter/Evapotranspiration_aktiv` = 1, + `//Massnahmenelemente/Dach/Berechnungsparameter/Evapotranspiration_aktiv` = 1, + `//Massnahmenelemente/Mulde_Rigole/Berechnungsparameter/Evapotranspiration_aktiv` = 1, + `//Massnahmenelemente/Mulde_Rigole/Allgemein/Regen-Skalierungsfaktor` = 1 + ) + if (!is.null(timeseries_et)) { + static_vals$`//Kurven/ET0` <- timeseries_et + } + # base.h5 templates differ in which curves they ship -- only touch + # datasets that actually exist (missing Growth/Shading just skips + # the end-time fix; a missing rain curve only matters if a run + # later asks for rain_factor != 1, which then errors clearly) + existing <- list_h5_datasets(h5m)$path + if (!is.null(timeseries_rain)) { + if (!"//Kurven/Regen" %in% existing) { + stop("make_swale_runner(): base.h5 has no dataset //Kurven/Regen ", + "to replace with timeseries_rain: ", paths$path_base) + } + static_vals$`//Kurven/Regen` <- timeseries_rain + for (curve in c("//Kurven/Growth_1", "//Kurven/Shading_1")) { + if (!curve %in% existing) next + cv <- h5_read_values(h5m, paths = curve)[[curve]] + if (is.data.frame(cv) && length(cv$time) >= 2) { + cv$time[2] <- max(timeseries_rain$time) + static_vals[[curve]] <- cv + } + } + } else if ("//Kurven/Regen" %in% existing) { + # kept for per-run rain_factor scaling (Eisenstadt variant) + template_rain <<- h5_read_values( + h5m, paths = "//Kurven/Regen" + )[["//Kurven/Regen"]] + } + + h5_write_values(h5m, static_vals, resize = TRUE, + scalar_strategy = "error", verbose = FALSE) + h5m$close_all() + master_path <<- mp + } + + function(params) { + params <- as.list(params) + required <- c("mulde_area", "mulde_height", "storage_type", + "storage_height", "connected_area", "filter_height", + "filter_hydraulicconductivity", + "bottom_hydraulicconductivity") + missing <- setdiff(required, names(params)) + if (length(missing) > 0) { + stop("make_swale_runner(): params is missing: ", + paste(missing, collapse = ", ")) + } + st <- storage_types[[params$storage_type]] + if (is.null(st)) { + stop("make_swale_runner(): unknown storage_type '", + params$storage_type, "'") + } + rain_factor <- if (is.null(params$rain_factor)) 1 else params$rain_factor + lai <- if (is.null(params$lai)) 3.9 else params$lai + + counter <<- counter + 1L + s_name <- sprintf("%s%05d", scenario_prefix, counter) + paths <- kwb.utils::resolve(path_list, dir_target = s_name) + + if (is.null(master_path)) prepare_master(paths) + + fs::dir_create(paths$dir_input, recurse = TRUE) + fs::dir_create(paths$dir_output, recurse = TRUE) + fs::dir_create(paths$dir_target_output, recurse = TRUE) + + fs::file_copy(path = master_path, + new_path = paths$path_target_input, + overwrite = TRUE) + + h5 <- hdf5r::H5File$new(paths$path_target_input, mode = "a") + on.exit(try(h5$close_all(), silent = TRUE), add = TRUE) + + new_path <- stringr::str_c( + normalizePath(fs::path_abs(paths$dir_target_output)), "\\" + ) + + vals <- list( + `//Berechnungsparameter/Ergebnispfad` = new_path, + `//Massnahmenelemente/Dach/Allgemein/Flaeche` = params$connected_area, + `//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` = params$mulde_area, + `//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` = params$mulde_height, + `//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` = + c(0.3, st$Startwerte_theta_ActualSoilMoisture), + `//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken` = + c(params$filter_height, params$storage_height), + `//Bodenarten/Speicher/thetaWP_MoistureAtWiltingPoint` = st$thetaWP_MoistureAtWiltingPoint, + `//Bodenarten/Speicher/thetaFC_MoistureAtFieldCapacity` = st$thetaFC_MoistureAtFieldCapacity, + `//Bodenarten/Speicher/thetaS_MoistureAtSaturation` = st$thetaS_MoistureAtSaturation, + `//Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate` = + params$bottom_hydraulicconductivity, + `//Massnahmenelemente/Mulde_Rigole/Parameter_Evapotranspiration/LAI_LeafAreaIndex` = lai, + `//Bodenarten/Bodenfilter/Ks_HydraulicConductivity` = + params$filter_hydraulicconductivity, + `//Bodenarten/Bodenfilter/Psi_Saugspannung_CapillarySuction` = + psi_s_mm(params$filter_hydraulicconductivity) + ) + # rain_factor is documented to be ignored when own rain series are + # written (timeseries_rain); it scales the base.h5 curve otherwise + if (is.null(timeseries_rain) && rain_factor != 1) { + if (!is.data.frame(template_rain)) { + stop("make_swale_runner(): rain_factor != 1 requires base.h5 to ", + "contain //Kurven/Regen as a time series (time/value)") + } + scaled <- template_rain + scaled$value <- scaled$value * rain_factor + vals$`//Kurven/Regen` <- scaled + } + + h5_write_values(h5, vals, resize = TRUE, + scalar_strategy = "error", verbose = FALSE) + h5$close_all() + + run_model(path_exe = paths$path_exe, + path_input = paths$path_target_input, + debug = debug) + + # Thin immediately (lean read), exactly like the vignettes' run_one() + sim_one <- get_simulation_results_optim( + paths = paths, + path_list = path_list, + simulation_names = s_name, + debug = debug, + lean = TRUE + ) + + row <- add_overflow_events_and_waterbalance( + simulation_results = sim_one, + event_separation_hours = event_separation_hours, + canonical_variables = default_canonical_wb_variables() + ) + + # the thinned row is all the optimiser needs -- drop the scenario's + # input copy and output directory so long searches (hundreds of + # engine runs) do not fill the temp drive. Reached only on success: + # a failed run errors above and keeps its files for debugging. + if (isTRUE(cleanup)) { + try(fs::file_delete(paths$path_target_input), silent = TRUE) + try(fs::dir_delete(paths$dir_target_output), silent = TRUE) + } + + dplyr::bind_cols( + tibble::as_tibble(params[required]), + tibble::tibble(rain_factor = rain_factor, lai = lai), + row + ) %>% + dplyr::mutate( + # sum_overflows is mm water column over the swale area + overflow_volume_m3 = .data$sum_overflows * .data$mulde_area / 1000 + ) + } +} diff --git a/R/optimise_swale_design.R b/R/optimise_swale_design.R new file mode 100644 index 0000000..a4359fe --- /dev/null +++ b/R/optimise_swale_design.R @@ -0,0 +1,352 @@ +#' Warm-start area bracket from prior (brute-force) results +#' +#' Narrows the mulde_area search interval to one grid step around the +#' cheapest feasible grid cell of the matching branch, if prior results +#' contain it. Falls back to the full bounds otherwise. +#' +#' @keywords internal +#' @noRd +area_bracket_from_prior <- function(prior, type, h_s, h_m, x, bounds) { + needed <- c("storage_type", "storage_height", "mulde_height", "mulde_area", + "n_overflows", "filter_hydraulicconductivity") + if (is.null(prior) || !all(needed %in% names(prior))) return(bounds) + kf_max <- suppressWarnings( + max(prior$filter_hydraulicconductivity, na.rm = TRUE) + ) + d <- prior[prior$storage_type == type & + prior$filter_hydraulicconductivity == kf_max & + prior$storage_height == h_s & + prior$mulde_height == h_m, , drop = FALSE] + if (nrow(d) < 2) return(bounds) + ok <- d$mulde_area[!is.na(d$n_overflows) & d$n_overflows <= x] + if (length(ok) == 0) return(bounds) + first_ok <- min(ok) + areas <- sort(unique(d$mulde_area)) + step <- if (length(areas) > 1) min(diff(areas)) else diff(bounds) + lo <- max(bounds[1], first_ok - step) + hi <- min(bounds[2], first_ok) + if (hi <= lo) bounds else c(lo, hi) +} + +#' Find the cost-optimal swale design per overflow target +#' +#' Coordinate-descent optimiser built from a single primitive +#' ([find_min_feasible()], bisection over one parameter): shrink the +#' expensive lever first (`mulde_area`), then the cheap one +#' (`mulde_height`); the storage layer starts at its smallest level and is +#' only escalated when the area is stuck at its upper bound. The filter +#' conductivity is expected to be fixed at the maximum via `fixed` (it is +#' cost-free and dominant, see the monotonicity analysis, +#' ). +#' Every engine run is cached, so the sweep over all `x_targets` and both +#' storage types shares evaluations. +#' +#' **The search order is derived from `cost_rates`** via a +#' specific-cost proxy (EUR per mm of storage capacity, capacity model +#' `V ~ area * (mulde_height + porosity * storage_height)`; the layer +#' porosity comes from `storage_spec`, see [default_storage_spec()]): +#' maximising `mulde_height` first is optimal for *any* rates under +#' this cost model (it costs only excavation, while area pays every +#' component), and the starting storage level is chosen as the +#' cheapest level per mm of capacity -- the smallest level under the +#' default rates, a high level when e.g. the storage material is cheap. +#' Without a `porosity` entry in `storage_spec` the legacy order +#' (smallest level first) is used. The proxy is a first-order +#' heuristic: it assumes capacity-additive levers and cannot rank +#' parameters with nonlinear hydraulic effects (e.g. a variable filter +#' conductivity) -- for those, and as the assumption-free cross-check, +#' use [optimise_swale_design_simultaneous()], which carries +#' `cost_rates` directly inside its objective. +#' +#' @param run_fn `function(params)` running one scenario and returning at +#' least `n_overflows` plus `sum_overflows` (mm) or `overflow_volume_m3`; +#' typically created with [make_swale_runner()]. `params` is a named list +#' of `mulde_area`, `mulde_height`, `storage_type`, `storage_height` plus +#' everything in `fixed`. +#' @param x_targets Integer vector of overflow targets (feasible :<=> +#' `n_overflows <= x`), default `0:5`. +#' @param area_bounds,area_tol Search range (m2) and resolution for +#' `mulde_area`. The found area sits up to one `area_tol` above the +#' exact feasibility boundary (worst-case cost overshoot roughly +#' `area_tol` x specific cost per m2, i.e. a few percent at the +#' default 2 m2); thanks to the bisection, every *halving* of +#' `area_tol` costs only one additional engine run per area search -- +#' the cheapest precision lever of this optimiser. It also shrinks +#' the tolerance-artefact part of the final `mulde_height` trim (the +#' values just below the height maximum at low `x_targets`). +#' @param height_bounds,height_tol Search range (mm) and resolution for +#' `mulde_height`. +#' @param storage_spec Storage search space per type, see +#' [default_storage_spec()]: discrete `levels` (infiltration box) or +#' continuous `bounds` + `tol` (gravel trench). +#' @param fixed Named list of parameters passed unchanged to `run_fn` +#' (connected area, filter geometry, kf at maximum, ...). Must contain +#' `filter_height` for the cost model. +#' @param prior_results Optional data.frame with prior (grid) results in +#' the workflow CSV schema, used as warm start (narrows the first area +#' bracket to one grid step). +#' @param split_jitter Passed to [find_min_feasible()]: 0 (default) = +#' deterministic halving; > 0 randomises every bisection split point +#' (Monte-Carlo of the search path -- repeated runs with different +#' seeds must agree within the search tolerances). +#' @param max_total_depth Optional analytic depth constraint in mm: +#' `mulde_height + filter_height + storage_height <= max_total_depth` +#' (e.g. from DWA-A 138 groundwater clearance or cover requirements). +#' Enforced without any simulation runs. +#' @param cost_rates Unit costs, see [default_cost_rates()]. +#' @param verbose Print one progress line per solved cell. +#' +#' @return Tibble with one row per (storage type, x): the optimal design +#' (`mulde_area`, `mulde_height`, `storage_height`), its metrics +#' (`n_overflows`, `overflow_volume_m3`, `et_pct`), cost columns from +#' [compute_costs()], a `status` (`"ok"` or `"infeasible_within_bounds"`), +#' `monotonicity_warning` (volume referee) and `n_runs_new` (fresh engine +#' runs spent on this cell). All evaluated designs are attached as +#' attribute `"evaluations"`. +#' +#' @seealso [optimise_swale_design_simultaneous()] (alternative: all +#' parameters at once via penalised Nelder-Mead, as an independent +#' cross-check of the coordinate descent), [find_min_feasible()], +#' [make_swale_runner()], [default_storage_spec()] +#' @export +optimise_swale_design <- function(run_fn, + x_targets = 0:5, + area_bounds = c(25, 200), + area_tol = 2, + height_bounds = c(100, 300), + height_tol = 10, + storage_spec = default_storage_spec(), + fixed = list( + connected_area = 1000, + filter_height = 300, + filter_hydraulicconductivity = 360, + bottom_hydraulicconductivity = 12 + ), + prior_results = NULL, + split_jitter = 0, + max_total_depth = NULL, + cost_rates = default_cost_rates(), + verbose = TRUE) { + + stopifnot(is.function(run_fn), !is.null(fixed$filter_height)) + filter_height <- fixed$filter_height + + # --- shared evaluation cache (one engine run per distinct design) ------- + cache <- new.env(parent = emptyenv()) + runs_executed <- 0L + + eval_design <- function(type, area, h_m, h_s) { + key <- paste(type, format(area, digits = 10), format(h_m, digits = 10), + format(h_s, digits = 10), sep = "|") + hit <- cache[[key]] + if (!is.null(hit)) return(hit) + params <- c(list(mulde_area = area, mulde_height = h_m, + storage_type = type, storage_height = h_s), fixed) + res <- as.list(run_fn(params)) + if (!"n_overflows" %in% names(res)) { + stop("optimise_swale_design(): run_fn() must return 'n_overflows'") + } + vol <- res[["overflow_volume_m3"]] + if (is.null(vol) && !is.null(res[["sum_overflows"]])) { + vol <- res[["sum_overflows"]] * area / 1000 # mm x m2 / 1000 = m3 + } + et <- res[["element.WB_Evapotranspiration_"]] + out <- list(storage_type = type, mulde_area = area, mulde_height = h_m, + storage_height = h_s, + n_overflows = as.numeric(res$n_overflows), + overflow_volume_m3 = if (is.null(vol)) NA_real_ else as.numeric(vol), + et_pct = if (is.null(et)) NA_real_ else as.numeric(et)) + runs_executed <<- runs_executed + 1L + assign(key, out, envir = cache) + out + } + + # --- analytic depth constraint ------------------------------------------ + hm_upper <- function(h_s) { + up <- rep(height_bounds[2], length(h_s)) + if (!is.null(max_total_depth)) { + up <- pmin(up, max_total_depth - filter_height - h_s) + } + up + } + + # area search with warm-start bracket; widens the bracket when the + # optimum turns out to lie below it + search_area <- function(eval_a, x, bracket) { + res <- find_min_feasible(eval_a, x_max = x, + lower = bracket[1], upper = bracket[2], + tol = area_tol, split_jitter = split_jitter) + if (identical(res$status, "at_lower_bound") && + bracket[1] > area_bounds[1]) { + res <- find_min_feasible(eval_a, x_max = x, + lower = area_bounds[1], upper = bracket[1], + tol = area_tol, split_jitter = split_jitter) + } + if (identical(res$status, "infeasible") && + bracket[2] < area_bounds[2]) { + # warm start was too optimistic -> retry up to the full upper bound + res <- find_min_feasible(eval_a, x_max = x, + lower = bracket[1], upper = area_bounds[2], + tol = area_tol, split_jitter = split_jitter) + } + res + } + + # --- solve one (storage type, x) cell ------------------------------------ + solve_cell <- function(type, x) { + runs_before <- runs_executed + spec <- storage_spec[[type]] + if (is.null(spec)) { + stop("optimise_swale_design(): storage_spec has no entry '", type, "'") + } + discrete <- !is.null(spec$levels) + mono_warn <- FALSE + + infeasible_row <- function() tibble::tibble( + x = x, storage_type = type, status = "infeasible_within_bounds", + mulde_area = NA_real_, mulde_height = NA_real_, + storage_height = NA_real_, n_overflows = NA_real_, + overflow_volume_m3 = NA_real_, et_pct = NA_real_, + monotonicity_warning = mono_warn, + n_runs_new = runs_executed - runs_before + ) + + # start storage level: derived from the cost rates when the spec + # carries the layer porosity -- the cheapest level per mm of storage + # capacity (capacity model V ~ area * (h_m + porosity * h_s), all + # cost terms ~ area, so the area cancels out of the comparison). + # Under the default rates this picks the smallest level (storage is + # the expensive lever); under e.g. cheap storage material it starts + # high where coordinate descent would otherwise never look. Without + # a porosity entry: legacy order (smallest level first). + choose_start_hs <- function(candidates) { + p <- spec$porosity + storage_rate <- switch( + type, + infiltration_box = cost_rates$infiltration_box_eur_per_m3, + gravel_trench = cost_rates$gravel_trench_eur_per_m3 + ) + if (is.null(p) || is.null(storage_rate)) return(min(candidates)) + sc <- vapply(candidates, function(s) { + hm <- hm_upper(s) + if (hm < height_bounds[1]) return(Inf) + f <- cost_rates$excavation_eur_per_m3 * + (hm + filter_height + s) / 1000 + + cost_rates$profiling_eur_per_m2 + + cost_rates$filter_eur_per_m3 * filter_height / 1000 + + storage_rate * s / 1000 + f / (hm + p * s) + }, numeric(1)) + candidates[which.min(sc)] + } + + if (discrete) { + levels_all <- sort(spec$levels) + levels_all <- levels_all[hm_upper(levels_all) >= height_bounds[1]] + if (length(levels_all) == 0) return(infeasible_row()) + h_s <- choose_start_hs(levels_all) + } else { + gb <- spec$bounds + if (!is.null(max_total_depth)) { + gb[2] <- min(gb[2], max_total_depth - filter_height - height_bounds[1]) + } + # gb[2] == gb[1] is a degenerate but valid axis (exactly one + # admissible storage height), only gb[2] < gb[1] is infeasible + if (gb[2] < gb[1]) return(infeasible_row()) + gravel_tol <- if (is.null(spec$tol)) 25 else spec$tol + # linear-fractional in h_s -> the proxy optimum is at an endpoint + h_s <- choose_start_hs(c(gb[1], gb[2])) + } + + a_star <- NA_real_ + repeat { + h_m_up <- hm_upper(h_s) + eval_a <- function(a) eval_design(type, a, h_m_up, h_s) + bracket <- area_bracket_from_prior(prior_results, type, h_s, h_m_up, + x, area_bounds) + res_a <- search_area(eval_a, x, bracket) + mono_warn <- mono_warn || res_a$monotonicity_violation + if (!identical(res_a$status, "infeasible")) { + a_star <- res_a$value + break + } + # area stuck at the upper bound -> escalate the storage layer + eval_s <- function(h) eval_design(type, area_bounds[2], hm_upper(h), h) + if (discrete) { + rest <- levels_all[levels_all > h_s] + if (length(rest) == 0) return(infeasible_row()) + res_s <- find_min_feasible(eval_s, x_max = x, levels = rest, + split_jitter = split_jitter) + } else { + if (h_s >= gb[2]) return(infeasible_row()) + res_s <- find_min_feasible(eval_s, x_max = x, + lower = h_s, upper = gb[2], + tol = gravel_tol, + split_jitter = split_jitter) + } + mono_warn <- mono_warn || res_s$monotonicity_violation + if (identical(res_s$status, "infeasible")) return(infeasible_row()) + h_s <- res_s$value + } + + # shrink the cheap lever last: mulde_height at fixed (a*, h_s). + # A second area pass is provably redundant: a smaller mulde_height + # only weakens the hydraulics, so the minimal feasible area cannot + # decrease any further. + h_m_up <- hm_upper(h_s) + h_m_star <- h_m_up + if (h_m_up > height_bounds[1]) { + res_h <- find_min_feasible( + function(h) eval_design(type, a_star, h, h_s), + x_max = x, lower = height_bounds[1], upper = h_m_up, + tol = height_tol, split_jitter = split_jitter + ) + mono_warn <- mono_warn || res_h$monotonicity_violation + if (!identical(res_h$status, "infeasible")) h_m_star <- res_h$value + } + + final <- eval_design(type, a_star, h_m_star, h_s) + if (isTRUE(verbose)) { + message(sprintf( + "[%s | x = %d] area %s m2, height %s mm, storage %s mm (%d neue Laeufe)", + type, x, format(a_star), format(h_m_star), format(h_s), + runs_executed - runs_before + )) + } + tibble::tibble( + x = x, storage_type = type, status = "ok", + mulde_area = a_star, mulde_height = h_m_star, storage_height = h_s, + n_overflows = final$n_overflows, + overflow_volume_m3 = final$overflow_volume_m3, + et_pct = final$et_pct, + monotonicity_warning = mono_warn, + n_runs_new = runs_executed - runs_before + ) + } + + # --- sweep all cells (shared cache makes repeats cheap) ------------------ + cells <- expand.grid(type = names(storage_spec), + x = sort(unique(as.integer(x_targets))), + stringsAsFactors = FALSE) + out <- dplyr::bind_rows( + lapply(seq_len(nrow(cells)), + function(i) solve_cell(cells$type[i], cells$x[i])) + ) + + out$filter_height <- filter_height + out <- compute_costs(out, cost_rates = cost_rates) + out <- dplyr::arrange(out, .data$storage_type, .data$x) + + evaluations <- dplyr::bind_rows( + lapply(ls(cache), function(k) tibble::as_tibble(get(k, envir = cache))) + ) + if (nrow(evaluations) > 0) { + # empty when every cell is analytically infeasible (no engine run) + evaluations <- dplyr::arrange( + evaluations, .data$storage_type, .data$mulde_area + ) + } + attr(out, "evaluations") <- evaluations + attr(out, "n_runs_total") <- runs_executed + out +} diff --git a/R/optimise_swale_design_simultaneous.R b/R/optimise_swale_design_simultaneous.R new file mode 100644 index 0000000..1bdf3dd --- /dev/null +++ b/R/optimise_swale_design_simultaneous.R @@ -0,0 +1,720 @@ +#' Cheapest feasible prior design as warm start for the simultaneous search +#' +#' Picks the cheapest feasible grid cell of the matching branch from prior +#' (brute-force) results in the workflow CSV schema. Returns a one-row +#' data.frame with `mulde_area`, `mulde_height`, `storage_height`, or +#' `NULL` if the prior does not contain a feasible cell for this branch. +#' +#' @keywords internal +#' @noRd +prior_start_design <- function(prior, type, x, filter_height, cost_rates) { + needed <- c("storage_type", "storage_height", "mulde_height", "mulde_area", + "n_overflows", "filter_hydraulicconductivity") + if (is.null(prior) || !all(needed %in% names(prior))) return(NULL) + kf_max <- suppressWarnings( + max(prior$filter_hydraulicconductivity, na.rm = TRUE) + ) + keep <- !is.na(prior$storage_type) & prior$storage_type == type & + !is.na(prior$filter_hydraulicconductivity) & + prior$filter_hydraulicconductivity == kf_max & + !is.na(prior$n_overflows) & prior$n_overflows <= x + d <- prior[keep, , drop = FALSE] + if (nrow(d) == 0) return(NULL) + if (!"filter_height" %in% names(d)) d$filter_height <- filter_height + d <- compute_costs(d, cost_rates = cost_rates) + best_i <- which.min(d$cost_total) # integer(0) if all costs are NA + if (length(best_i) == 0) return(NULL) + out <- d[best_i, c("mulde_area", "mulde_height", "storage_height"), + drop = FALSE] + if (anyNA(out)) return(NULL) + out +} + +#' Radical-inverse (van der Corput) sequence element +#' @keywords internal +#' @noRd +halton_1d <- function(i, base) { + f <- 1 + r <- 0 + while (i > 0) { + f <- f / base + r <- r + f * (i %% base) + i <- i %/% base + } + r +} + +#' i-th point of the 3-dimensional Halton sequence (bases 2, 3, 5) +#' @keywords internal +#' @noRd +halton_point <- function(i) { + c(halton_1d(i, 2), halton_1d(i, 3), halton_1d(i, 5)) +} + +#' Minimal deterministic uniform generator (Park-Miller LCG) +#' +#' Self-contained pseudo-random stream for the differential-evolution +#' method: fully reproducible from `seed` and independent of R's global +#' RNG (`.Random.seed` is neither read nor written). +#' +#' @keywords internal +#' @noRd +make_lcg <- function(seed) { + state <- (abs(as.double(seed)) %% 2147483646) + 1 + function() { + # 16807 * state < 2^53, exact in double arithmetic + state <<- (16807 * state) %% 2147483647 + state / 2147483647 + } +} + +#' Find the cost-optimal swale design by simultaneous parameter search +#' +#' Alternative to the coordinate-descent optimiser +#' ([optimise_swale_design()], bisection per parameter): all design +#' parameters -- `mulde_area`, `mulde_height` and `storage_height` -- are +#' optimised **simultaneously**. Infeasible designs (`n_overflows > x`) +#' are not excluded but penalised (any infeasible design is worse than any +#' feasible one; the number of excess events grades the penalty, steering +#' the search back towards feasibility), so the search moves freely +#' through the full parameter space and can trade the parameters against +#' each other in a single step -- it does not rely on the per-parameter +#' monotonicity that the bisection exploits. +#' +#' Three search `method`s share this penalised objective (plus cache, +#' tolerance snapping and final lattice polish) and differ only in how +#' they propose candidates: +#' \itemize{ +#' \item `"nelder_mead"` (default): multistart Nelder-Mead simplex via +#' `stats::optim()` -- the recommended method. +#' \item `"diff_evolution"`: a compact differential evolution +#' (DE/rand/1/bin, population 12, F = 0.7, CR = 0.9), included for +#' comparison. Deterministic: it draws from an internal Park-Miller +#' generator seeded with `seed` and leaves R's global RNG +#' (`.Random.seed`) untouched. +#' \item `"halton_search"`: quasi-random space-filling sampling +#' (Halton sequence, bases 2/3/5) -- a deliberately simple baseline +#' showing what the structured searches must beat. +#' } +#' +#' Three ingredients keep the number of engine runs in check: +#' \itemize{ +#' \item \strong{Snapping}: every candidate is snapped to the search +#' tolerances (`area_tol`, `height_tol`, storage `tol` / discrete +#' `levels`) before evaluation, so the shared cache absorbs repeated +#' visits and the sweep over all `x_targets` reuses runs. +#' \item \strong{Multistart}: `n_starts` deterministic starting points +#' (prior warm start and the optimum of the previous overflow target +#' first, then a *storage ladder* -- one anchor start per storage +#' level, smallest level first -- then fixed space-filling points) +#' guard against the simplex stalling on the plateaus that the +#' snapping and the integer overflow count create, and make sure every +#' storage level competes: along the feasibility boundary the cost +#' valley is flat, so the cheapest (usually smallest) storage level is +#' easily missed from a single start. Different starts take different +#' search paths -- the counterpart of `split_jitter` in the bisection +#' optimiser. Every start receives an equal slice of the remaining +#' `max_evals` budget (unused runs roll over). +#' \item \strong{Lattice polish}: an accelerated pattern descent +#' (steps of 8/4/2/1 tolerances downwards, cheaper by construction) +#' runs from the cheapest feasible design of every storage level +#' visited -- capped at the 6 cheapest levels, which only bites for +#' the continuous gravel trench (the discrete box has at most a +#' handful) -- because the storage axis separates cost valleys that +#' single coordinate steps cannot cross. Besides the per-axis down +#' steps each round proposes a \emph{boundary slide} (area down with +#' `mulde_height` at its maximum -- the two-coordinate trade towards +#' the cheap end of the feasibility boundary) and a +#' \emph{mulde_height floor probe} (at large `x` the overflow count +#' saturates, so the whole lower height range can be feasible even +#' when a +1 counting wobble blocks every single step). All are just +#' evaluated candidates -- no monotonicity assumption enters. The +#' result is locally optimal on the tolerance lattice, whatever the +#' search method delivered. +#' } +#' +#' The discrete infiltration-box levels are mapped onto a continuous +#' latent axis (each level owns an equal share of `[0, 1]`), the gravel +#' trench is searched continuously. The filter conductivity is expected to +#' be fixed at the maximum via `fixed` (cost-free and dominant, see the +#' monotonicity analysis, +#' ). +#' `max_total_depth` is enforced by +#' construction (the `mulde_height` axis is compressed to the remaining +#' depth), so no simulation runs are spent on depth-invalid designs. +#' +#' Compared to [optimise_swale_design()] this needs considerably more +#' engine runs per cell (typically 60-120 instead of ~15; search phase +#' plus multi-valley polish) but serves as an independent cross-check: it +#' can discover cheaper corners of the design space that coordinate +#' descent would miss if the parameter interaction were stronger than the +#' monotonicity analysis suggests. +#' +#' @param run_fn `function(params)` running one scenario and returning at +#' least `n_overflows` plus `sum_overflows` (mm) or `overflow_volume_m3`; +#' typically created with [make_swale_runner()]. `params` is a named list +#' of `mulde_area`, `mulde_height`, `storage_type`, `storage_height` plus +#' everything in `fixed`. +#' @param x_targets Integer vector of overflow targets (feasible :<=> +#' `n_overflows <= x`), default `0:5`. +#' @param area_bounds,area_tol Search range (m2) and resolution for +#' `mulde_area`. +#' @param height_bounds,height_tol Search range (mm) and resolution for +#' `mulde_height`. +#' @param storage_spec Storage search space per type, see +#' [default_storage_spec()]: discrete `levels` (infiltration box) or +#' continuous `bounds` + `tol` (gravel trench). +#' @param fixed Named list of parameters passed unchanged to `run_fn` +#' (connected area, filter geometry, kf at maximum, ...). Must contain +#' `filter_height` for the cost model. +#' @param prior_results Optional data.frame with prior (grid) results in +#' the workflow CSV schema, used as warm start (the cheapest feasible +#' grid cell of the branch becomes the first start / seeds the +#' population). +#' @param method Search method, see Details: `"nelder_mead"` (default), +#' `"diff_evolution"` or `"halton_search"` (the latter two mainly for +#' comparison). +#' @param n_starts Number of Nelder-Mead starts per (storage type, x) +#' cell (default 4; only used by `method = "nelder_mead"`). Warm starts +#' (prior, previous target) count towards this number, then the +#' storage-ladder anchors, then the space-filling points. +#' @param seed Integer seed of the internal deterministic generator used +#' by `method = "diff_evolution"` (ignored by the other methods). R's +#' global RNG state is not touched. +#' @param max_evals Soft cap on fresh engine runs per cell for the search +#' phase: once reached, the search winds down (already cached designs +#' remain free). The final multi-valley lattice polish adds its own +#' runs on top (typically 20-50 per cell). Default 80 -- thanks to the +#' shared cache the later `x_targets` of a storage type stay cheaper. +#' @param wobble Maximum counting-artefact size tolerated at the upper +#' corner (default 1, matching the +1 event-counting wobble): only if +#' the maximal design overflows by more than `wobble` events is the +#' cell declared infeasible without a search. +#' @param max_total_depth Optional analytic depth constraint in mm: +#' `mulde_height + filter_height + storage_height <= max_total_depth` +#' (e.g. from DWA-A 138 groundwater clearance or cover requirements). +#' Enforced without any simulation runs. +#' @param cost_rates Unit costs, see [default_cost_rates()]. +#' @param verbose Print one progress line per solved cell. +#' +#' @return Tibble with one row per (storage type, x), same schema as +#' [optimise_swale_design()] plus a `method` column: the optimal design +#' (`mulde_area`, `mulde_height`, `storage_height`), its metrics +#' (`n_overflows`, `overflow_volume_m3`, `et_pct`), cost columns from +#' [compute_costs()], a `status` (`"ok"` or +#' `"infeasible_within_bounds"`), `monotonicity_warning` (`TRUE` if a +#' strictly larger design produced more overflows *and* more overflow +#' volume among the cell's evaluations) and `n_runs_new` (fresh engine +#' runs spent on this cell). All evaluated designs are attached as +#' attribute `"evaluations"`. +#' +#' @examples +#' # synthetic monotone model: overflows fall with retention capacity +#' run <- function(params) { +#' cap <- params$mulde_area * +#' (params$mulde_height + 0.95 * params$storage_height) +#' list(n_overflows = max(0, floor(3.6e5 / cap) - 3), +#' sum_overflows = 800 * max(0, 3.6e5 / cap - 3)) +#' } +#' opt <- optimise_swale_design_simultaneous( +#' run, x_targets = 1, +#' storage_spec = default_storage_spec()["infiltration_box"], +#' verbose = FALSE +#' ) +#' opt[, c("x", "mulde_area", "mulde_height", "storage_height", "cost_total")] +#' +#' @seealso [optimise_swale_design()] (coordinate descent / bisection), +#' [make_swale_runner()], [default_storage_spec()] +#' @export +optimise_swale_design_simultaneous <- function(run_fn, + x_targets = 0:5, + area_bounds = c(25, 200), + area_tol = 2, + height_bounds = c(100, 300), + height_tol = 10, + storage_spec = default_storage_spec(), + fixed = list( + connected_area = 1000, + filter_height = 300, + filter_hydraulicconductivity = 360, + bottom_hydraulicconductivity = 12 + ), + prior_results = NULL, + method = c("nelder_mead", + "diff_evolution", + "halton_search"), + n_starts = 4, + max_evals = 80, + seed = 1, + wobble = 1L, + max_total_depth = NULL, + cost_rates = default_cost_rates(), + verbose = TRUE) { + + method <- match.arg(method) + stopifnot(is.function(run_fn), !is.null(fixed$filter_height), + n_starts >= 1, max_evals >= 10) + filter_height <- fixed$filter_height + + # --- shared evaluation cache (one engine run per distinct design) ------- + cache <- new.env(parent = emptyenv()) + runs_executed <- 0L + + cache_key <- function(type, area, h_m, h_s) { + paste(type, format(area, digits = 10), format(h_m, digits = 10), + format(h_s, digits = 10), sep = "|") + } + + eval_design <- function(type, area, h_m, h_s) { + key <- cache_key(type, area, h_m, h_s) + hit <- cache[[key]] + if (!is.null(hit)) return(hit) + params <- c(list(mulde_area = area, mulde_height = h_m, + storage_type = type, storage_height = h_s), fixed) + res <- as.list(run_fn(params)) + if (!"n_overflows" %in% names(res)) { + stop("optimise_swale_design_simultaneous(): ", + "run_fn() must return 'n_overflows'") + } + vol <- res[["overflow_volume_m3"]] + if (is.null(vol) && !is.null(res[["sum_overflows"]])) { + vol <- res[["sum_overflows"]] * area / 1000 # mm x m2 / 1000 = m3 + } + et <- res[["element.WB_Evapotranspiration_"]] + out <- list(storage_type = type, mulde_area = area, mulde_height = h_m, + storage_height = h_s, + n_overflows = as.numeric(res$n_overflows), + overflow_volume_m3 = if (is.null(vol)) NA_real_ else as.numeric(vol), + et_pct = if (is.null(et)) NA_real_ else as.numeric(et)) + runs_executed <<- runs_executed + 1L + assign(key, out, envir = cache) + out + } + + cost_total_of <- function(type, area, h_m, h_s) { + compute_costs( + tibble::tibble(mulde_area = area, mulde_height = h_m, + filter_height = filter_height, storage_height = h_s, + storage_type = type), + cost_rates = cost_rates + )$cost_total + } + + # --- analytic depth constraint ------------------------------------------ + hm_upper <- function(h_s) { + up <- rep(height_bounds[2], length(h_s)) + if (!is.null(max_total_depth)) { + up <- pmin(up, max_total_depth - filter_height - h_s) + } + up + } + + snap_to <- function(v, origin, step) { + origin + round((v - origin) / step) * step + } + + # fixed space-filling starts in the unit cube (area, height, storage); + # deterministic on purpose -- repeated calls give identical results + default_starts <- list( + c(0.70, 0.85, 0.25), c(0.35, 0.50, 0.65), c(0.15, 0.95, 0.85), + c(0.55, 0.25, 0.45), c(0.80, 0.35, 0.75), c(0.25, 0.70, 0.15) + ) + + # --- pairwise dominance check over the evaluations of one cell ---------- + # violation :<=> a strictly larger design (all three parameters >=, at + # least one >) has MORE overflows AND MORE overflow volume -- the + # simultaneous analogue of the bisection's volume referee + dominance_violation <- function(evs) { + if (length(evs) < 2) return(FALSE) + m <- do.call(rbind, lapply(evs, function(e) { + c(e$area, e$h_m, e$h_s, e$n, e$vol) + })) + for (i in seq_len(nrow(m) - 1)) { + for (j in (i + 1):nrow(m)) { + if (any(is.na(m[i, 4:5])) || any(is.na(m[j, 4:5]))) next + d <- m[i, 1:3] - m[j, 1:3] + big <- if (all(d >= 0) && any(d > 0)) i + else if (all(d <= 0) && any(d < 0)) j + else next + small <- if (big == i) j else i + if (m[big, 4] > m[small, 4] && m[big, 5] > m[small, 5] + 1e-9) { + return(TRUE) + } + } + } + FALSE + } + + # --- solve one (storage type, x) cell ------------------------------------ + # returns list(row = tibble, best_u = unit-cube position of the optimum, + # used as warm start for the next overflow target of the same type) + solve_cell <- function(type, x, extra_start = NULL) { + runs_before <- runs_executed + spec <- storage_spec[[type]] + if (is.null(spec)) { + stop("optimise_swale_design_simultaneous(): ", + "storage_spec has no entry '", type, "'") + } + discrete <- !is.null(spec$levels) + mono_warn <- FALSE + cell_evals <- list() + + infeasible_row <- function() list( + row = tibble::tibble( + x = x, storage_type = type, method = method, + status = "infeasible_within_bounds", + mulde_area = NA_real_, mulde_height = NA_real_, + storage_height = NA_real_, n_overflows = NA_real_, + overflow_volume_m3 = NA_real_, et_pct = NA_real_, + monotonicity_warning = mono_warn, + n_runs_new = runs_executed - runs_before + ), + best_u = NULL + ) + + if (discrete) { + levels_all <- sort(spec$levels) + levels_all <- levels_all[hm_upper(levels_all) >= height_bounds[1]] + if (length(levels_all) == 0) return(infeasible_row()) + hs_max <- levels_all[length(levels_all)] + } else { + gb <- spec$bounds + if (!is.null(max_total_depth)) { + gb[2] <- min(gb[2], max_total_depth - filter_height - height_bounds[1]) + } + # gb[2] == gb[1] is a degenerate but valid axis (exactly one + # admissible storage height), only gb[2] < gb[1] is infeasible + if (gb[2] < gb[1]) return(infeasible_row()) + s_tol <- if (is.null(spec$tol)) 25 else spec$tol + hs_max <- gb[2] + } + + # unit cube [0,1]^3 -> snapped physical design (depth-valid by + # construction: the mulde_height axis is compressed to hm_upper(h_s)) + decode <- function(u) { + u <- pmin(1, pmax(0, u)) + a <- snap_to(area_bounds[1] + u[1] * diff(area_bounds), + area_bounds[1], area_tol) + a <- min(max(a, area_bounds[1]), area_bounds[2]) + h_s <- if (discrete) { + levels_all[min(length(levels_all), + 1L + as.integer(floor(u[3] * length(levels_all))))] + } else { + s <- snap_to(gb[1] + u[3] * (gb[2] - gb[1]), gb[1], s_tol) + min(max(s, gb[1]), gb[2]) + } + up <- hm_upper(h_s) + h_m <- snap_to(height_bounds[1] + u[2] * (up - height_bounds[1]), + height_bounds[1], height_tol) + h_m <- min(max(h_m, height_bounds[1]), up) + list(area = a, h_m = h_m, h_s = h_s) + } + + encode <- function(area, h_m, h_s) { + u3 <- if (discrete) { + i <- which.min(abs(levels_all - h_s)) + (i - 0.5) / length(levels_all) + } else if (gb[2] > gb[1]) { + (h_s - gb[1]) / (gb[2] - gb[1]) + } else { + 0 # degenerate axis: exactly one admissible storage height + } + up <- hm_upper(h_s) + u2 <- if (up > height_bounds[1]) { + (h_m - height_bounds[1]) / (up - height_bounds[1]) + } else { + 0 + } + u1 <- (area - area_bounds[1]) / diff(area_bounds) + pmin(1, pmax(0, c(u1, u2, u3))) + } + + # any infeasible design must be worse than any feasible one + cost_cap <- cost_total_of(type, area_bounds[2], height_bounds[2], hs_max) + best <- NULL + + consider <- function(area, h_m, h_s) { + ev <- eval_design(type, area, h_m, h_s) + cell_evals[[cache_key(type, area, h_m, h_s)]] <<- list( + area = area, h_m = h_m, h_s = h_s, + n = ev$n_overflows, vol = ev$overflow_volume_m3 + ) + cost <- cost_total_of(type, area, h_m, h_s) + feasible <- !is.na(ev$n_overflows) && ev$n_overflows <= x + if (feasible && (is.null(best) || cost < best$cost)) { + best <<- list(area = area, h_m = h_m, h_s = h_s, cost = cost) + } + list(ev = ev, cost = cost, feasible = feasible) + } + + # fast path: if even the maximal design is infeasible beyond the + # counting wobble, the whole cell is (monotonicity) -- no search + top <- consider(area_bounds[2], hm_upper(hs_max), hs_max) + if (!top$feasible && + (is.na(top$ev$n_overflows) || top$ev$n_overflows > x + wobble)) { + mono_warn <- dominance_violation(cell_evals) + return(infeasible_row()) + } + + start_cap <- max_evals + budget_hit <- function() runs_executed - runs_before >= start_cap + + objective <- function(u) { + p <- decode(u) + if (budget_hit() && + is.null(cache[[cache_key(type, p$area, p$h_m, p$h_s)]])) { + return(4 * cost_cap) # budget spent: only cached designs are free + } + r <- consider(p$area, p$h_m, p$h_s) + n <- r$ev$n_overflows + if (is.na(n)) return(4 * cost_cap) + if (n <= x) { + r$cost + } else { + cost_cap + r$cost + 0.05 * cost_cap * (n - x) + } + } + + # --- starts: prior warm start / previous target first, then one + # anchor per storage level (min storage first -- the storage ladder + # guards the flat cost valley along the feasibility boundary), then + # fixed space-filling points -------------------------------------------- + starts_all <- list() + ps <- prior_start_design(prior_results, type, x, filter_height, + cost_rates) + if (!is.null(ps)) { + starts_all <- c(starts_all, list(encode(ps$mulde_area, ps$mulde_height, + ps$storage_height))) + } + if (!is.null(extra_start)) starts_all <- c(starts_all, list(extra_start)) + ladder_u3 <- if (discrete) { + (seq_along(levels_all) - 0.5) / length(levels_all) + } else { + c(0.02, 0.30, 0.60, 0.90) + } + ladder <- lapply(seq_along(ladder_u3), function(i) { + c(if (i %% 2 == 1) 0.85 else 0.45, 0.90, ladder_u3[[i]]) + }) + starts_all <- c(starts_all, ladder, default_starts) + + if (method == "nelder_mead") { + # every start gets a slice of the remaining run budget, unused + # runs roll over to the following starts + starts <- starts_all[seq_len(min(length(starts_all), n_starts))] + for (si in seq_along(starts)) { + used <- runs_executed - runs_before + if (max_evals - used <= 2) break + start_cap <- used + ceiling((max_evals - used) / + (length(starts) - si + 1)) + stats::optim(starts[[si]], objective, method = "Nelder-Mead", + control = list(maxit = 200, reltol = 1e-4, + warn.1d.NelderMead = FALSE)) + } + } else if (method == "diff_evolution") { + # DE/rand/1/bin on the unit cube; deterministic via internal LCG + start_cap <- max_evals + rng <- make_lcg(seed) + n_pop <- 12 + pop <- lapply(seq_len(n_pop), function(i) { + if (i <= length(starts_all)) starts_all[[i]] else halton_point(i) + }) + fit <- vapply(pop, objective, numeric(1)) + # i, r1, r2, r3 pairwise distinct, as DE/rand/1/bin requires + pick_distinct <- function(i) { + chosen <- integer(0) + while (length(chosen) < 3) { + r <- 1L + as.integer(floor(rng() * n_pop)) + if (r != i && r <= n_pop && !(r %in% chosen)) { + chosen <- c(chosen, r) + } + } + chosen + } + gen <- 0 + while (!budget_hit() && gen < 60) { + gen <- gen + 1 + for (i in seq_len(n_pop)) { + if (budget_hit()) break + r <- pick_distinct(i) + mutant <- pop[[r[1]]] + 0.7 * (pop[[r[2]]] - pop[[r[3]]]) + trial <- pop[[i]] + j_rand <- 1L + as.integer(floor(rng() * 3)) + for (j in 1:3) { + if (j == j_rand || rng() < 0.9) trial[j] <- mutant[j] + } + trial <- pmin(1, pmax(0, trial)) + f_trial <- objective(trial) + if (f_trial <= fit[i]) { + pop[[i]] <- trial + fit[i] <- f_trial + } + } + } + } else { # halton_search + # quasi-random space-filling baseline: warm starts first, then the + # Halton sequence until the run budget is spent + start_cap <- max_evals + for (u0 in starts_all) { + if (budget_hit()) break + objective(u0) + } + i <- 0 + while (!budget_hit() && i < 50 * max_evals) { + i <- i + 1 + objective(halton_point(i)) + } + } + + if (is.null(best)) { + mono_warn <- dominance_violation(cell_evals) + return(infeasible_row()) + } + + # --- lattice polish: accelerated pattern descent --------------------- + # (any reduction is cheaper by construction; step 8/4/2/1 tolerances + # downwards, halving the step whenever nothing improves -> locally + # optimal on the tolerance lattice, whatever the search delivered) + polish_from <- function(b0) { + cur <- b0 + scale <- 8 + rounds <- 0 + while (scale >= 1 && rounds < 80) { + rounds <- rounds + 1 + candidates <- list() + a_down <- max(area_bounds[1], cur$area - scale * area_tol) + if (a_down < cur$area - 1e-9) { + candidates <- c(candidates, list( + list(area = a_down, h_m = cur$h_m, h_s = cur$h_s) + )) + } + hm_down <- max(height_bounds[1], cur$h_m - scale * height_tol) + if (hm_down < cur$h_m - 1e-9) { + candidates <- c(candidates, list( + list(area = cur$area, h_m = hm_down, h_s = cur$h_s) + )) + } + h_s_down <- if (discrete) { + lower <- levels_all[levels_all < cur$h_s] + if (length(lower)) max(lower) else NA_real_ + } else { + s_down <- max(gb[1], cur$h_s - scale * s_tol) + if (s_down < cur$h_s - 1e-9) s_down else NA_real_ + } + if (!is.na(h_s_down)) { + candidates <- c(candidates, list( + list(area = cur$area, h_m = cur$h_m, h_s = h_s_down) + )) + } + # slide along the feasibility boundary: trade the expensive + # lever (area) down against the cheap one (mulde_height) at its + # maximum -- a two-coordinate move the axis steps cannot make + hm_up <- hm_upper(cur$h_s) + if (a_down < cur$area - 1e-9 && hm_up > cur$h_m + 1e-9) { + candidates <- c(candidates, list( + list(area = a_down, h_m = hm_up, h_s = cur$h_s) + )) + } + # floor probe: at large x the overflow count saturates, so the + # whole lower mulde_height range can be feasible even when a +1 + # counting wobble blocks every single step below the current + # value (cached after the first evaluation) + if (cur$h_m > height_bounds[1] + 1e-9) { + candidates <- c(candidates, list( + list(area = cur$area, h_m = height_bounds[1], h_s = cur$h_s) + )) + } + improved <- FALSE + for (p in candidates) { + r <- consider(p$area, p$h_m, p$h_s) + if (r$feasible && r$cost < cur$cost - 1e-9) { + cur <- list(area = p$area, h_m = p$h_m, h_s = p$h_s, + cost = r$cost) + improved <- TRUE + } + } + if (!improved) scale <- scale / 2 + } + } + + # the storage axis separates cost valleys that single coordinate + # steps cannot cross (dropping the storage level breaks feasibility + # on the boundary) -> polish the cheapest feasible design of every + # storage level visited, not only the single global best + seeds <- list() + for (e in cell_evals) { + if (is.na(e$n) || e$n > x) next + key <- format(e$h_s, digits = 10) + cost <- cost_total_of(type, e$area, e$h_m, e$h_s) + if (is.null(seeds[[key]]) || cost < seeds[[key]]$cost) { + seeds[[key]] <- list(area = e$area, h_m = e$h_m, h_s = e$h_s, + cost = cost) + } + } + seeds <- seeds[order(vapply(seeds, function(s) s$cost, numeric(1)))] + for (s in seeds[seq_len(min(length(seeds), 6))]) polish_from(s) + + mono_warn <- dominance_violation(cell_evals) + if (mono_warn) { + warning(sprintf( + paste0("optimise_swale_design_simultaneous(): a larger design ", + "produced more overflows AND more overflow volume ", + "(%s, x = %d) -- real non-monotonicity, result may be ", + "unreliable for this cell."), + type, x + ), call. = FALSE) + } + + final <- eval_design(type, best$area, best$h_m, best$h_s) + if (isTRUE(verbose)) { + message(sprintf( + "[%s | x = %d | %s] area %s m2, height %s mm, storage %s mm (%d neue Laeufe)", + type, x, method, format(best$area), format(best$h_m), + format(best$h_s), runs_executed - runs_before + )) + } + list( + row = tibble::tibble( + x = x, storage_type = type, method = method, status = "ok", + mulde_area = best$area, mulde_height = best$h_m, + storage_height = best$h_s, + n_overflows = final$n_overflows, + overflow_volume_m3 = final$overflow_volume_m3, + et_pct = final$et_pct, + monotonicity_warning = mono_warn, + n_runs_new = runs_executed - runs_before + ), + best_u = encode(best$area, best$h_m, best$h_s) + ) + } + + # --- sweep: per storage type in ascending x (the optimum of x - 1 is + # feasible for x too and seeds the next search) -------------------------- + xs <- sort(unique(as.integer(x_targets))) + rows <- list() + for (type in names(storage_spec)) { + last_u <- NULL + for (x in xs) { + solved <- solve_cell(type, x, extra_start = last_u) + rows[[length(rows) + 1L]] <- solved$row + if (!is.null(solved$best_u)) last_u <- solved$best_u + } + } + out <- dplyr::bind_rows(rows) + + out$filter_height <- filter_height + out <- compute_costs(out, cost_rates = cost_rates) + out <- dplyr::arrange(out, .data$storage_type, .data$x) + + evaluations <- dplyr::bind_rows( + lapply(ls(cache), function(k) tibble::as_tibble(get(k, envir = cache))) + ) + if (nrow(evaluations) > 0) { + # empty when every cell is analytically infeasible (no engine run) + evaluations <- dplyr::arrange( + evaluations, .data$storage_type, .data$mulde_area + ) + } + attr(out, "evaluations") <- evaluations + attr(out, "n_runs_total") <- runs_executed + out +} diff --git a/R/plot_cost_overflow_boxplot.R b/R/plot_cost_overflow_boxplot.R new file mode 100644 index 0000000..99305b4 --- /dev/null +++ b/R/plot_cost_overflow_boxplot.R @@ -0,0 +1,542 @@ +#' Cost boxplot per overflow-event count, points sized by overflow volume +#' +#' Companion to [plot_cost_vs_overflow_volume()]. For every number of overflow +#' events (x-axis) it draws a boxplot of the total construction cost (y-axis, +#' EUR) across all scenarios with that count, overlaid with the individual +#' scenarios as jittered points whose **size scales with `size_by`** -- +#' the overflow volume (`m3`, `sum_overflows` (`mm`) * `mulde_area` (`m2`) / +#' 1000; the default) or the element evapotranspiration share (%). One best +#' scenario per box is highlighted; `best_by` selects its objective -- cheapest, +#' smallest overflow volume, or highest evapotranspiration (cost as +#' tie-breaker) -- so the three variants trace three different frontier lines. +#' `label_best` annotates the marker. +#' +#' Overflow counts greater than `x` are collapsed into a single `">x"` +#' catch-all box (furthest right, coloured red), keeping the axis readable for +#' the long-tailed 15-year runs (Wien / Bad Aussee reach several hundred +#' overflow events). Its highlighted scenario is the one with the **fewest** +#' overflow events above `x` (closest to the valid region). Set `x` high to +#' resolve more counts individually, or to `max(n_overflows)` to give every +#' count its own box. +#' +#' The point tooltip is **identical** to [plot_cost_vs_overflow_volume()]: +#' scenario, overflow count / sum (`mm`) / volume (`m3`), the element water +#' balance (evapotranspiration / infiltration / overflow, %), the cost breakdown +#' (EUR) +#' and the varying `param_grid` parameters translated via `param_labels`. +#' Points and boxes are coloured with the same green (low counts) to red +#' (`">x"`) palette as the sibling plots; because the colour merely echoes the +#' x-axis it carries no separate legend. When both storage types share one +#' panel, the overlaid points are additionally **shaped by the storage type** +#' (filled square = infiltration box / Sickerbox, filled triangle = gravel +#' trench / Schotterrigol), matching the scatter siblings, and a storage-type +#' legend is shown next to the point-size legend. With `facet_storage_type = +#' TRUE` the plot splits into two stacked storage-type panels instead; the +#' facet strips then carry that information and the points stay **plain +#' circles** for readability. `y_var = "cost_per_evap_pct"` switches the +#' y-axis to the cost per percentage point of evapotranspiration (EUR/%). +#' +#' The point-size scale is calibrated to the valid region (`0..x`): the extreme +#' overflow volumes of the `">x"` catch-all are capped and a minimum size keeps +#' even zero-volume points (the `0`-overflow box) visible, so the many-overflow +#' outliers no longer shrink every valid-region point to an invisible dot. +#' When the storage-type shapes are in use (no faceting), its legend keys are +#' drawn with the storage-type marker (grey; the single present shape, or the +#' square when both types are shown) instead of the default circle; the +#' faceted variant uses circular points and matching circular keys. +#' +#' @inheritParams plot_cost_vs_overflow_volume +#' @param x Numeric threshold. Counts `0..x` each get their own box; counts +#' `> x` collapse into a single `">x"` box. +#' @param filter_n_gtx Logical. If `TRUE`, scenarios with `n_overflows > x` +#' are dropped (removing the `">x"` box) before plotting. +#' @param use_jitter Logical. If `TRUE`, points are horizontally jittered. +#' @param jitter_width Numeric. Horizontal jitter half-width. +#' @param jitter_seed Integer. Seed for reproducible jitter. +#' @param max_point_size Numeric. Point size for the largest (valid-region) +#' `size_by` value; the smallest maps to a fixed minimum so no point vanishes. +#' @param box_alpha,point_alpha Numeric in `[0, 1]`. Box-fill / point opacity. +#' @param lab_size Optional character override for the size-legend title. +#' @param size_by Character. Which variable drives the point area (and its +#' legend): `"overflow_volume"` (default, m3) or `"evapotranspiration"` (the +#' element evapotranspiration share in %, from `element.WB_Evapotranspiration_` +#' -- larger points then mean *more* evapotranspiration, which is desirable). +#' @param best_by Character. Objective for the highlighted best scenario per +#' box, with cost as the tie-breaker: `"min_cost"` (default; cheapest, ties +#' broken by `scenario_name`), `"min_overflow"` (smallest overflow volume) or +#' `"max_evapotranspiration"` (highest evapotranspiration). In the `">x"` box +#' the fewest-overflow scenario is picked first, `best_by` then breaking ties. +#' "Cost" always refers to the active `y_var`, so with +#' `y_var = "cost_per_evap_pct"` the `"min_cost"` objective picks the +#' scenario with the lowest cost per percentage point of evapotranspiration. +#' @param y_var Character. Which cost measure the y-axis (boxes, points, best +#' markers, frontier) shows: `"cost_total"` (default; total construction +#' cost, EUR) or `"cost_per_evap_pct"` (total cost divided by the element +#' evapotranspiration share **above the reference minimum**, EUR per +#' percentage point -- the marginal cost efficiency of evapotranspiration; +#' the baseline comes "for free"). The reference is the **lowest +#' evapotranspiration among the scenarios that satisfy the validity +#' criterion** (`n_overflows <= x`; fallback: the complete run when none +#' does) and is named -- share, criterion and scenario id -- on a second +#' title line. Scenarios at or below the reference (including the reference +#' scenario itself) have no defined marginal cost and are dropped from the +#' `"cost_per_evap_pct"` variant; `label_best = TRUE` additionally +#' annotates the evapotranspiration gain (`"(+NN % Evapotranspiration)"`) +#' after the price. Titles and the y-axis label switch accordingly. +#' @param facet_storage_type Logical. If `TRUE`, the plot is split by +#' `storage_type` into two stacked panels (infiltration box on top, gravel +#' trench below, via `ggplot2::facet_grid()`), each with its own boxes, +#' best-per-box markers and frontier line; the overlaid points then stay +#' plain circles (the strips already name the type). +#' `plotly::ggplotly()` keeps the panel split as stacked subplots. +#' Default `FALSE`. +#' @param label_best Logical. If `TRUE`, the best scenario per box is annotated +#' next to it: overflow volume plus overflow share (`"NN m3 / NN %"`) for +#' `min_overflow`, the evapotranspiration share (`"NN %"`) for +#' `max_evapotranspiration`, or the active `y_var` value for `min_cost` -- +#' the total cost (`"NN EUR"`) by default, the cost per percentage point of +#' evapotranspiration (`"NN EUR/%"`) with `y_var = "cost_per_evap_pct"`. +#' Default `FALSE`. +#' @param legend_position Character. Legend position, default `"right"`. +#' @param mark_best Logical. If `TRUE` (default), the best scenario per box +#' (see `best_by`) is highlighted with a black-outlined diamond filled in +#' that box's group colour, so its plotly tooltip inherits the group colour. +#' @param connect_best Logical. If `TRUE` (default), the highlighted best +#' scenarios of **all** boxes (overflow counts `0..x` plus the `">x"` +#' catch-all) are connected by a line -- the best-per-overflow-level frontier. +#' +#' @return A `ggplot` object. Convert to interactive via +#' `plotly::ggplotly(p, tooltip = "text")`. +#' +#' @seealso [plot_cost_vs_overflow_volume()] +#' +#' @export +#' +#' @importFrom dplyr %>% filter mutate left_join case_when group_by arrange desc slice ungroup if_else +#' @importFrom ggplot2 ggplot aes geom_boxplot geom_jitter geom_line geom_point geom_text position_jitter position_nudge scale_size scale_color_manual scale_fill_manual scale_shape_manual scale_x_discrete facet_grid vars guides guide_legend labs theme_bw theme element_text +#' @importFrom grDevices colorRampPalette +#' @importFrom utils modifyList +#' @importFrom rlang .data +plot_cost_overflow_boxplot <- function(simulation_results_optimisation, + param_grid, + x = 5, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.2, + jitter_seed = 1L, + max_point_size = 6, + box_alpha = 0.35, + point_alpha = 0.6, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + size_by = c("overflow_volume", + "evapotranspiration"), + best_by = c("min_cost", + "min_overflow", + "max_evapotranspiration"), + y_var = c("cost_total", + "cost_per_evap_pct"), + facet_storage_type = FALSE, + label_best = FALSE, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + lab_size = NULL, + mark_best = TRUE, + connect_best = TRUE, + legend_position = "right") { + + lang <- match.arg(lang) + size_by <- match.arg(size_by) + best_by <- match.arg(best_by) + y_var <- match.arg(y_var) + if (is.null(param_labels)) param_labels <- default_param_labels(lang) + + size_col <- if (size_by == "evapotranspiration") { + "element.WB_Evapotranspiration_" + } else { + "overflow_volume_m3" + } + + txt <- switch( + lang, + de = list( + x = "Anzahl \u00dcberlaufereignisse", + y = "Gesamtkosten [\u20ac]", + title_cheapest = "Kosten je \u00dcberlaufanzahl \u2014 g\u00fcnstigste je Kategorie", + title_min_overflow = "Kosten je \u00dcberlaufanzahl \u2014 geringstes \u00dcberlaufvolumen je Kategorie", + title_max_evap = "Kosten je \u00dcberlaufanzahl \u2014 h\u00f6chste Evapotranspiration je Kategorie", + size_volume = "\u00dcberlaufvolumen [m\u00b3]", + size_evap = "Evapotranspiration [%]", + best_cheapest = "G\u00fcnstigste L\u00f6sung", + best_min_overflow = "Geringstes \u00dcberlaufvolumen", + best_max_evap = "H\u00f6chste Evapotranspiration" + ), + en = list( + x = "Number of overflow events", + y = "Total cost [\u20ac]", + title_cheapest = "Cost by overflow count \u2014 cheapest per class", + title_min_overflow = "Cost by overflow count \u2014 lowest overflow volume per class", + title_max_evap = "Cost by overflow count \u2014 highest evapotranspiration per class", + size_volume = "Overflow volume [m\u00b3]", + size_evap = "Evapotranspiration [%]", + best_cheapest = "Cheapest solution", + best_min_overflow = "Lowest overflow volume", + best_max_evap = "Highest evapotranspiration" + ) + ) + txt <- c(txt, cost_tooltip_labels(lang)) + + # Cost-efficiency variant: y = total cost per percentage point of element + # evapotranspiration [EUR/%] instead of the plain total cost. Only the + # y-dependent labels change; palette / boxes / tooltip stay identical. + y_col <- if (y_var == "cost_per_evap_pct") "cost_per_evap_pct" else "cost_total" + if (y_var == "cost_per_evap_pct") { + txt$y <- switch(lang, + de = "Kosten je Prozent Evapotranspiration \u00fcber Minimum [\u20ac/%]", + en = "Cost per percent evapotranspiration above minimum [\u20ac/%]") + evap_prefix <- switch(lang, + de = "Kosten je % Evapotranspiration", + en = "Cost per % evapotranspiration") + txt$title_cheapest <- paste0(evap_prefix, switch(lang, + de = " \u2014 g\u00fcnstigste je Kategorie", + en = " \u2014 cheapest per class")) + txt$title_min_overflow <- paste0(evap_prefix, switch(lang, + de = " \u2014 geringstes \u00dcberlaufvolumen je Kategorie", + en = " \u2014 lowest overflow volume per class")) + txt$title_max_evap <- paste0(evap_prefix, switch(lang, + de = " \u2014 h\u00f6chste Evapotranspiration je Kategorie", + en = " \u2014 highest evapotranspiration per class")) + } + + def_title <- switch(best_by, + min_cost = txt$title_cheapest, + min_overflow = txt$title_min_overflow, + max_evapotranspiration = txt$title_max_evap) + def_size <- if (size_by == "evapotranspiration") txt$size_evap else txt$size_volume + best_lab <- switch(best_by, + min_cost = txt$best_cheapest, + min_overflow = txt$best_min_overflow, + max_evapotranspiration = txt$best_max_evap) + + if (is.null(lab_x)) lab_x <- txt$x + if (is.null(lab_y)) lab_y <- txt$y + if (is.null(lab_size)) lab_size <- def_size + + req_grid <- c("scenario_name") + req_res <- c( + "scenario_name", "n_overflows", "sum_overflows", "mulde_area", + "element.WB_Evapotranspiration_", "element.WB_InfiltrationNetto_", + "element.WB_Oberflaechenablauf_Ueberlauf_", + "cost_excavation", "cost_profiling", "cost_filter", + "cost_storage", "cost_total", "storage_type" + ) + + miss_grid <- setdiff(req_grid, names(param_grid)) + miss_res <- setdiff(req_res, names(simulation_results_optimisation)) + + if (length(miss_grid) > 0) { + stop("param_grid is missing column(s): ", paste(miss_grid, collapse = ", ")) + } + if (length(miss_res) > 0) { + stop("simulation_results_optimisation is missing column(s): ", + paste(miss_res, collapse = ", ")) + } + if (!is.numeric(x) || length(x) != 1 || is.na(x) || x < 0) { + stop("x must be a single non-negative numeric value.") + } + + x_int <- as.integer(round(x)) + if (!isTRUE(all.equal(x, x_int))) { + warning("x is not an integer; using x_int = ", x_int, + " for discrete axis/palette.") + } + + # Reference for the cost-per-percent-evapotranspiration measure: the + # minimum evapotranspiration among the scenarios that SATISFY the validity + # criterion (n_overflows <= x); computed before any filtering, falls back + # to the complete run when no scenario is valid. The matching scenario id + # is named in the title. + evap_all <- simulation_results_optimisation[["element.WB_Evapotranspiration_"]] + valid_mask <- !is.na(simulation_results_optimisation$n_overflows) & + simulation_results_optimisation$n_overflows <= x_int & !is.na(evap_all) + ref_idx <- if (any(valid_mask)) which(valid_mask) else seq_along(evap_all) + evap_min <- suppressWarnings(min(evap_all[ref_idx], na.rm = TRUE)) + evap_min_scenario <- simulation_results_optimisation$scenario_name[ + ref_idx[which.min(evap_all[ref_idx])]] + + # Share of scenarios meeting the validity criterion (n_overflows <= x), + # appended to the auto-generated title (a plotly-safe place -- ggplotly + # drops ggplot subtitles). + valid_pct <- round(100 * mean( + simulation_results_optimisation$n_overflows <= x_int, na.rm = TRUE)) + share_txt <- switch(lang, + de = paste0(valid_pct, " % mit <= ", x_int, " \u00dcberl\u00e4ufen"), + en = paste0(valid_pct, " % with <= ", x_int, " overflows")) + if (is.null(title)) { + title <- paste0(def_title, " (", share_txt, ")") + if (y_var == "cost_per_evap_pct") { + # name the reference of the marginal measure in the title + title <- paste0(title, "\n", switch(lang, + de = paste0("Referenz: minimale Evapotranspiration der g\u00fcltigen ", + "Szenarien (<= ", x_int, " \u00dcberl\u00e4ufe): ", + round(evap_min, 1), " % (Szenario ", + evap_min_scenario, ")"), + en = paste0("Reference: minimum evapotranspiration of the valid ", + "scenarios (<= ", x_int, " overflows): ", + round(evap_min, 1), " % (scenario ", + evap_min_scenario, ")"))) + } + } + if (is.null(caption)) caption <- cost_rates_caption(lang) + + param_tooltip <- build_varying_param_html(param_grid, lang, param_labels, + digits_params) + + # Counts greater than x collapse into a single ">x" catch-all box, shown + # furthest right and coloured red. Its highlighted scenario is the one with + # the fewest overflow events (closest to the valid region); see the best + # selection below. + hi_lab <- paste0(">", x_int) + base_levels <- as.character(0:x_int) + levs <- c(base_levels, hi_lab) + + df <- simulation_results_optimisation %>% + dplyr::left_join(param_tooltip, by = "scenario_name") %>% + dplyr::filter(!isTRUE(filter_n_gtx) | + is.na(.data$n_overflows) | + .data$n_overflows <= x_int) %>% + dplyr::mutate( + overflow_volume_m3 = .data$sum_overflows * .data$mulde_area / 1000, + overflow_cat = dplyr::case_when( + is.na(.data$n_overflows) ~ NA_character_, + .data$n_overflows > x_int ~ hi_lab, + TRUE ~ as.character(.data$n_overflows) + ), + overflow_cat = factor(.data$overflow_cat, levels = levs) + ) + + # Cost per percentage point of evapotranspiration ABOVE the run minimum + # [EUR/%]: the baseline evapotranspiration comes "for free", only the gain + # beyond the worst scenario is paid for. Scenarios at the minimum (incl. + # the reference scenario itself) have no defined marginal cost and are + # dropped from the cost_per_evap_pct variant (the active y column must not + # be NA). + df <- df %>% + dplyr::mutate( + cost_per_evap_pct = dplyr::if_else( + .data[["element.WB_Evapotranspiration_"]] - evap_min > 0, + .data$cost_total / + (.data[["element.WB_Evapotranspiration_"]] - evap_min), + NA_real_ + ) + ) %>% + dplyr::filter(!is.na(.data[[y_col]])) + + # Storage type: display factor for the facet strips and the point shapes + # (filled square = infiltration box, filled triangle = gravel trench). + st <- storage_type_shapes(df$storage_type, lang) + df$storage_type_disp <- st$display + + df$tooltip_html <- cost_tooltip_text(df, txt, digits, evap_min = evap_min) + + # Point size: calibrate the scale to the valid region (0..x) and cap the + # extreme ">x" values, otherwise the many-overflow outliers (overflow + # volumes of several thousand m3) shrink every valid-region point to an + # invisible dot. pmin() caps; scale_size() below adds a minimum size so even + # zero-volume points (the 0-overflow box) stay visible. + valid_size <- df[[size_col]][!is.na(df$n_overflows) & df$n_overflows <= x_int] + size_cap <- suppressWarnings(max(valid_size[is.finite(valid_size)])) + if (!is.finite(size_cap) || size_cap <= 0) { + size_cap <- suppressWarnings(max(df[[size_col]], na.rm = TRUE)) + } + if (!is.finite(size_cap) || size_cap <= 0) size_cap <- 1 + df$size_plot <- pmin(df[[size_col]], size_cap) + + # Best scenario per box. n_overflows is the first sort key, so the ">x" + # catch-all box highlights the scenario with the fewest overflow events + # (closest to the valid region); the objective (best_by) then breaks ties, + # with cost as the final tie-breaker: + # min_cost -> cheapest + # min_overflow -> smallest overflow volume + # max_evapotranspiration -> highest evapotranspiration + # For the single-count boxes 0..x, n_overflows is constant, so only the + # objective matters there. The frontier line runs through the best of every + # box, so the three objectives yield three different lines. + best_grp <- df %>% + dplyr::filter(!is.na(.data$overflow_cat), !is.na(.data[[y_col]])) + # With storage-type facets every panel gets its own best-per-box marker and + # frontier line, so the two technologies stay comparable. + best_grp <- if (isTRUE(facet_storage_type)) { + best_grp %>% dplyr::group_by(.data$storage_type_disp, .data$overflow_cat) + } else { + best_grp %>% dplyr::group_by(.data$overflow_cat) + } + best <- switch(best_by, + max_evapotranspiration = best_grp %>% + dplyr::arrange(.data$n_overflows, + dplyr::desc(.data[["element.WB_Evapotranspiration_"]]), + .data[[y_col]], .data$scenario_name, .by_group = TRUE), + min_overflow = best_grp %>% + dplyr::arrange(.data$n_overflows, .data$overflow_volume_m3, + .data[[y_col]], .data$scenario_name, .by_group = TRUE), + min_cost = best_grp %>% + dplyr::arrange(.data$n_overflows, .data[[y_col]], + .data$scenario_name, .by_group = TRUE) + ) + best <- best %>% dplyr::slice(1L) %>% dplyr::ungroup() + best$tooltip_best <- paste0("", best_lab, "
", best$tooltip_html) + if (isTRUE(label_best)) { + best$label_text <- switch(best_by, + min_overflow = paste0( + format(round(best$overflow_volume_m3, 1), trim = TRUE), " m\u00b3 / ", + format(round(best[["element.WB_Oberflaechenablauf_Ueberlauf_"]], 1), + trim = TRUE), " %"), + max_evapotranspiration = paste0( + format(round(best[["element.WB_Evapotranspiration_"]], 1), + trim = TRUE), " %"), + min_cost = paste0( + format(round(best[[y_col]], 0), big.mark = " ", trim = TRUE), + if (y_var == "cost_per_evap_pct") { + # ... and the evapotranspiration gain over the reference minimum + # that this price buys + paste0(" \u20ac/% (+", + format(round(best[["element.WB_Evapotranspiration_"]] - + evap_min, 1), trim = TRUE), + " % ", switch(lang, de = "Evapotranspiration", + en = "evapotranspiration"), ")") + } else { + " \u20ac" + }) + ) + } + + if (x_int == 0L) { + pal <- stats::setNames(c("orange", "red"), c("0", hi_lab)) + } else if (x_int == 1L) { + pal <- stats::setNames(c("darkgreen", "orange", "red"), + c("0", "1", hi_lab)) + } else { + pal_green <- grDevices::colorRampPalette(c("darkgreen", "yellowgreen"))(x_int) + pal <- stats::setNames(c(pal_green, "orange", "red"), + c(base_levels, hi_lab)) + } + + pos <- if (isTRUE(use_jitter)) { + ggplot2::position_jitter(width = jitter_width, height = 0, + seed = jitter_seed) + } else { + "identity" + } + + # Storage-type shapes only when both types share one panel: in the faceted + # layout the strips already name the type, so the points stay plain circles + # (better readable with the size scaling). + use_shapes <- !isTRUE(facet_storage_type) + jitter_mapping <- ggplot2::aes(size = .data$size_plot, + colour = .data$overflow_cat, + text = .data$tooltip_html) + if (use_shapes) { + jitter_mapping <- utils::modifyList( + jitter_mapping, + ggplot2::aes(shape = .data$storage_type_disp) + ) + } + # With shapes in use, the size-legend keys would default to circles, which + # then never occur in the plot; draw them with the storage-type marker + # instead (neutral grey) -- the single present shape, or the square when + # both types are shown. + present_types <- unique(as.character(df$storage_type_disp)) + size_key_shape <- if (length(present_types) == 1L) { + unname(st$shape_values[present_types]) + } else { + 15 + } + + p <- ggplot2::ggplot(df, ggplot2::aes(x = .data$overflow_cat, + y = .data[[y_col]])) + + ggplot2::geom_boxplot( + ggplot2::aes(fill = .data$overflow_cat), + alpha = box_alpha, outlier.shape = NA, colour = "grey40" + ) + + ggplot2::geom_jitter( + jitter_mapping, + position = pos, alpha = point_alpha + ) + + ggplot2::scale_size(range = c(1.5, max_point_size), name = lab_size) + + ggplot2::scale_fill_manual(values = pal, limits = levs, drop = FALSE, + guide = "none") + + ggplot2::scale_color_manual(values = pal, limits = levs, drop = FALSE, + guide = "none") + + ggplot2::scale_x_discrete(drop = FALSE) + + ggplot2::labs(title = title, x = lab_x, y = lab_y, + caption = if (nzchar(caption)) caption else NULL) + + ggplot2::theme_bw() + + ggplot2::theme( + legend.position = legend_position, + plot.title = ggplot2::element_text(size = 11) + ) + + # Square = infiltration box, triangle = gravel trench -- only when both + # types share one panel. The size-legend keys are then drawn with the same + # marker, so no circle appears in the legend that is absent from the plot. + if (use_shapes) { + p <- p + + ggplot2::scale_shape_manual( + values = st$shape_values, drop = FALSE, + name = txt$tt_storage_type + ) + + ggplot2::guides( + size = ggplot2::guide_legend( + override.aes = list(shape = size_key_shape, colour = "grey30", + alpha = 1) + ) + ) + } + + # Two stacked panels (infiltration box on top, gravel trench below). + # plotly::ggplotly() converts the facets to stacked subplots, so the + # interactive HTML keeps the panel split. + if (isTRUE(facet_storage_type)) { + p <- p + ggplot2::facet_grid( + rows = ggplot2::vars(.data$storage_type_disp) + ) + } + + # Frontier line across the best of every box (all classes; per panel when + # faceting -- facet_grid subsets `best` by storage type), then the + # group-coloured best-marker on top of everything. + if (isTRUE(connect_best) && nrow(best) > 1L) { + p <- p + ggplot2::geom_line( + data = best, + ggplot2::aes(x = .data$overflow_cat, y = .data[[y_col]], group = 1L), + colour = "black", linewidth = 0.7, na.rm = TRUE + ) + } + if (isTRUE(mark_best) && nrow(best) > 0L) { + p <- p + ggplot2::geom_point( + data = best, + ggplot2::aes(x = .data$overflow_cat, y = .data[[y_col]], + fill = .data$overflow_cat, + text = .data$tooltip_best), + shape = 23, size = 3.2, colour = "black", stroke = 1.2, na.rm = TRUE + ) + } + if (isTRUE(label_best) && "label_text" %in% names(best) && nrow(best) > 0L) { + # place the label just above the marker (centred), so long labels such as + # "3515 m3 / 35 %" never run off the right edge of the last box. + lab_nudge_y <- 0.045 * diff(range(df[[y_col]], na.rm = TRUE)) + p <- p + ggplot2::geom_text( + data = best, + ggplot2::aes(x = .data$overflow_cat, y = .data[[y_col]], + label = .data$label_text), + position = ggplot2::position_nudge(y = lab_nudge_y), + hjust = 0.5, vjust = 0, size = 2.8, colour = "black", na.rm = TRUE + ) + } + + p +} diff --git a/R/plot_cost_vs_evaporation.R b/R/plot_cost_vs_evaporation.R new file mode 100644 index 0000000..3e70d9a --- /dev/null +++ b/R/plot_cost_vs_evaporation.R @@ -0,0 +1,253 @@ +#' Cost vs. evapotranspiration scatter with storage-type shapes +#' +#' Second companion to \code{\link{plot_cost_vs_overflow_volume}} for +#' cost-aware optimisation. Plots the per-scenario **total construction cost** +#' (EUR) on the x-axis against the element **evapotranspiration share** (% of +#' the total water input, from `element.WB_Evapotranspiration_`) on the +#' y-axis. Points are coloured discretely by the **number** of overflow events +#' (same 0..x / >x palette used by the sibling plots, legend at the top) and +#' **shaped by the storage type**: filled square = infiltration box +#' (Sickerbox), filled triangle = gravel trench (Schotterrigol). +#' +#' The tooltip is identical to [plot_cost_vs_overflow_volume()]: scenario, +#' overflow count / sum (mm) / volume (m3), the element water balance +#' (`element.WB_Evapotranspiration_`, `element.WB_InfiltrationNetto_`, +#' `element.WB_Oberflaechenablauf_Ueberlauf_`, all as % of the total water +#' input), the storage type, the usable storage volume of the storage layer +#' (m3), the cost breakdown (`cost_excavation`, +#' `cost_profiling`, `cost_filter`, `cost_storage`, `cost_total`), the derived +#' **cost per percentage point of evapotranspiration** (EUR/%) plus the +#' varying parameters from `param_grid` (excluding `scenario_name`). +#' +#' The plot language can be switched via `lang = "de"` or `lang = "en"`. +#' Titles / axis labels / legend / tooltip labels follow the choice unless +#' explicit overrides are supplied. +#' +#' @inheritParams plot_cost_vs_overflow_volume +#' +#' @return A `ggplot` object. Convert to interactive via +#' `plotly::ggplotly(p, tooltip = "text")`. +#' +#' @seealso [plot_cost_vs_overflow_volume()] for cost vs. overflow volume and +#' [plot_cost_overflow_boxplot()] for the boxplot views (including +#' `y_var = "cost_per_evap_pct"`, the cost per percentage point of +#' evapotranspiration). +#' +#' @export +#' +#' @importFrom dplyr %>% filter mutate left_join case_when +#' @importFrom ggplot2 ggplot aes geom_point scale_color_manual scale_shape_manual labs theme_bw position_jitter theme guides guide_legend +#' @importFrom grDevices colorRampPalette +#' @importFrom rlang .data +plot_cost_vs_evaporation <- function(simulation_results_optimisation, + param_grid, + x = 1, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.15, + jitter_height = 0.15, + jitter_seed = 1L, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + legend_position = "top") { + + lang <- match.arg(lang) + if (is.null(param_labels)) param_labels <- default_param_labels(lang) + + txt <- switch( + lang, + de = list( + title = "Kosten vs. Evapotranspiration", + x = "Gesamtkosten [\u20ac]", + y = "Evapotranspiration [%]", + legend = "Anzahl \u00dcberlaufereignisse" + ), + en = list( + title = "Cost vs. evapotranspiration", + x = "Total cost [\u20ac]", + y = "Evapotranspiration [%]", + legend = "Number of overflow events" + ) + ) + txt <- c(txt, cost_tooltip_labels(lang)) + + if (is.null(lab_x)) lab_x <- txt$x + if (is.null(lab_y)) lab_y <- txt$y + + req_grid <- c("scenario_name") + req_res <- c( + "scenario_name", "n_overflows", "sum_overflows", "mulde_area", + "element.WB_Evapotranspiration_", "element.WB_InfiltrationNetto_", + "element.WB_Oberflaechenablauf_Ueberlauf_", + "cost_excavation", "cost_profiling", "cost_filter", + "cost_storage", "cost_total", "storage_type" + ) + + miss_grid <- setdiff(req_grid, names(param_grid)) + miss_res <- setdiff(req_res, names(simulation_results_optimisation)) + + if (length(miss_grid) > 0) { + stop("param_grid is missing column(s): ", paste(miss_grid, collapse = ", ")) + } + if (length(miss_res) > 0) { + stop( + "simulation_results_optimisation is missing column(s): ", + paste(miss_res, collapse = ", ") + ) + } + if (!is.numeric(x) || length(x) != 1 || is.na(x) || x < 0) { + stop("x must be a single non-negative numeric value.") + } + + x_int <- as.integer(round(x)) + if (!isTRUE(all.equal(x, x_int))) { + warning("x is not an integer; using x_int = ", x_int, + " for discrete palette/legend.") + } + + # Append the share of scenarios meeting the validity criterion + # (n_overflows <= x) to the auto title (ggplotly drops ggplot subtitles). + valid_pct <- round(100 * mean( + simulation_results_optimisation$n_overflows <= x_int, na.rm = TRUE)) + share_txt <- switch(lang, + de = paste0(valid_pct, " % mit <= ", x_int, " \u00dcberl\u00e4ufen"), + en = paste0(valid_pct, " % with <= ", x_int, " overflows")) + if (is.null(title)) title <- paste0(txt$title, " (", share_txt, ")") + if (is.null(caption)) caption <- cost_rates_caption(lang) + + param_tooltip <- build_varying_param_html(param_grid, lang, param_labels, + digits_params) + + df <- simulation_results_optimisation %>% + dplyr::left_join(param_tooltip, by = "scenario_name") %>% + dplyr::filter(!isTRUE(filter_n_gtx) | + is.na(.data$n_overflows) | + .data$n_overflows <= x_int) %>% + dplyr::mutate( + overflow_volume_m3 = .data$sum_overflows * .data$mulde_area / 1000 + ) + + hi_lab <- paste0(">", x_int) + df <- df %>% + dplyr::mutate( + overflow_cat = dplyr::case_when( + is.na(.data$n_overflows) ~ NA_character_, + .data$n_overflows > x_int ~ hi_lab, + TRUE ~ as.character(.data$n_overflows) + ) + ) + + base_levels <- as.character(0:x_int) + levs <- c(base_levels, hi_lab) + + df <- df %>% + dplyr::mutate( + overflow_cat = factor(.data$overflow_cat, levels = levs) + ) + + # Storage type drives the marker shape (square = infiltration box, + # triangle = gravel trench); shared with the sibling cost plots. + st <- storage_type_shapes(df$storage_type, lang) + df$storage_type_disp <- st$display + + # Reference for the tooltip's cost-per-percent-evapotranspiration line: + # minimum evapotranspiration among the scenarios that satisfy the validity + # criterion (n_overflows <= x); computed before any filtering, falls back + # to the complete run when no scenario is valid. + evap_all <- simulation_results_optimisation[["element.WB_Evapotranspiration_"]] + valid_mask <- !is.na(simulation_results_optimisation$n_overflows) & + simulation_results_optimisation$n_overflows <= x_int & !is.na(evap_all) + evap_min <- suppressWarnings(min( + if (any(valid_mask)) evap_all[valid_mask] else evap_all, na.rm = TRUE)) + df$tooltip_html <- cost_tooltip_text(df, txt, digits, evap_min = evap_min) + + if (x_int == 0L) { + pal <- c("0" = "orange", ">0" = "red") + } else if (x_int == 1L) { + pal <- c("0" = "darkgreen", "1" = "orange", ">1" = "red") + } else { + pal_green <- grDevices::colorRampPalette(c("darkgreen", "yellowgreen"))(x_int) + pal_vals <- c(pal_green, "orange", "red") + pal_names <- c(base_levels, hi_lab) + pal <- stats::setNames(pal_vals, pal_names) + } + + legend_breaks <- levs + + pos <- if (isTRUE(use_jitter)) { + ggplot2::position_jitter( + width = jitter_width, + height = jitter_height, + seed = jitter_seed + ) + } else { + "identity" + } + + legend_direction <- if (legend_position %in% c("top", "bottom")) { + "horizontal" + } else { + "vertical" + } + + legend_nrow <- if (legend_direction == "horizontal") 1 else NULL + legend_ncol <- if (legend_direction == "vertical") 1 else NULL + + p <- ggplot2::ggplot(df, ggplot2::aes( + x = .data$cost_total, + y = .data[["element.WB_Evapotranspiration_"]], + color = .data$overflow_cat, + shape = .data$storage_type_disp, + text = .data$tooltip_html + )) + + ggplot2::geom_point(alpha = 0.7, position = pos) + + ggplot2::scale_color_manual( + values = pal, + breaks = legend_breaks, + limits = levs, + drop = FALSE, + name = txt$legend + ) + + ggplot2::scale_shape_manual( + values = st$shape_values, + drop = FALSE, + name = txt$tt_storage_type + ) + + ggplot2::guides( + colour = ggplot2::guide_legend( + direction = legend_direction, + nrow = legend_nrow, + ncol = legend_ncol, + byrow = TRUE, + order = 1 + ), + shape = ggplot2::guide_legend( + direction = legend_direction, + nrow = legend_nrow, + ncol = legend_ncol, + byrow = TRUE, + order = 2 + ) + ) + + ggplot2::labs( + title = title, + x = lab_x, + y = lab_y, + caption = if (nzchar(caption)) caption else NULL + ) + + ggplot2::theme_bw() + + ggplot2::theme( + legend.position = legend_position, + legend.direction = legend_direction, + # stack the colour and shape legends so both fit at the top + legend.box = if (legend_direction == "horizontal") "vertical" else "horizontal" + ) + + p +} diff --git a/R/plot_cost_vs_overflow_volume.R b/R/plot_cost_vs_overflow_volume.R new file mode 100644 index 0000000..97dd2c2 --- /dev/null +++ b/R/plot_cost_vs_overflow_volume.R @@ -0,0 +1,287 @@ +#' Cost vs. overflow-volume scatter with n_overflows-coloured points +#' +#' Companion to \code{\link{plot_wb_tradeoff_overflows}} for cost-aware +#' optimisation. Plots the per-scenario **total construction cost** (EUR) on +#' the x-axis against the **overflow volume** (m3) on the y-axis, with the +#' points coloured discretely by the **number** of overflow events (same +#' 0..x / >x palette used by `plot_wb_tradeoff_overflows`, legend at the top) +#' and **shaped by the storage type**: filled square = infiltration box +#' (Sickerbox), filled triangle = gravel trench (Schotterrigol). +#' +#' Overflow volume is computed from `sum_overflows` (in mm on the swale +#' surface, as returned by [`add_overflow_events_and_waterbalance()`]) +#' multiplied by `mulde_area` (m2) and converted to m3: +#' `overflow_volume_m3 = sum_overflows * mulde_area / 1000`. +#' +#' The tooltip carries the element water balance +#' (`element.WB_Evapotranspiration_`, `element.WB_InfiltrationNetto_`, +#' `element.WB_Oberflaechenablauf_Ueberlauf_`, all as % of the total water +#' input) and the cost breakdown (`cost_excavation`, `cost_profiling`, +#' `cost_filter`, `cost_storage`, `cost_total`), the derived **cost per +#' percentage point of evapotranspiration** (EUR/%), the **usable storage +#' volume** of the storage layer (m3; area x height x usable porosity +#' `thetaS - thetaFC`, from a `storage_volume_m3` column or derived from the +#' `storage_theta*` columns) plus the varying parameters from `param_grid` +#' (excluding `scenario_name`), so the user can hover over a scatter point +#' and see exactly why it landed where it did. +#' +#' The plot language can be switched via `lang = "de"` or `lang = "en"`. +#' Titles / axis labels / legend / tooltip labels follow the choice unless +#' explicit overrides are supplied. +#' +#' @param simulation_results_optimisation Data frame with the columns +#' `scenario_name`, `n_overflows`, `sum_overflows`, `mulde_area`, +#' `element.WB_Evapotranspiration_`, `element.WB_InfiltrationNetto_`, +#' `element.WB_Oberflaechenablauf_Ueberlauf_`, `cost_excavation`, +#' `cost_profiling`, `cost_filter`, `cost_storage`, `cost_total`, +#' `storage_type`. Typically the joined output of +#' [`add_overflow_events_and_waterbalance()`] and [`compute_costs()`]. +#' @param param_grid Data frame with parameter grid. Must contain +#' `scenario_name`. +#' @param x Numeric threshold for the overflow-count colour bucket. Values +#' greater than `x` are pushed into the red `">x"` category. +#' @param filter_n_gtx Logical. If `TRUE`, scenarios with `n_overflows > x` +#' are dropped before plotting. +#' @param use_jitter,jitter_width,jitter_height,jitter_seed As in +#' [`plot_wb_tradeoff_overflows()`]. +#' @param digits Integer. Rounding for numeric values in the tooltip. +#' @param digits_params Integer. Rounding for parameter values in the +#' tooltip. +#' @param lang Character. Plot language: `"de"` or `"en"`. +#' @param param_labels Named character vector translating `param_grid` columns +#' to tooltip labels, or `NULL` to use [default_param_labels()] for `lang`. +#' @param title,lab_x,lab_y Optional character overrides for the default +#' language-specific title / axis labels. +#' @param caption Character or `NULL`. Caption below the plot naming the +#' unit-cost rates the EUR values were computed with. `NULL` (default) +#' uses [cost_rates_caption()] with the [default_cost_rates()]; pass your +#' own string if the costs were computed with different rates, or `""` to +#' drop the caption. Note that `plotly::ggplotly()` drops ggplot captions +#' -- re-add it to the interactive version via [plotly_add_caption()]. +#' @param legend_position Character. Legend position, default `"top"`. +#' +#' @return A `ggplot` object. Convert to interactive via +#' `plotly::ggplotly(p, tooltip = "text")`. +#' +#' @seealso [plot_cost_overflow_boxplot()] for the same data / tooltip shown as +#' a cost-by-overflow-count boxplot and [plot_cost_vs_evaporation()] for +#' cost vs. the element evapotranspiration share. +#' +#' @export +#' +#' @importFrom dplyr %>% filter mutate left_join case_when +#' @importFrom ggplot2 ggplot aes geom_point scale_color_manual scale_shape_manual labs theme_bw position_jitter theme guides guide_legend +#' @importFrom grDevices colorRampPalette +#' @importFrom rlang .data +plot_cost_vs_overflow_volume <- function(simulation_results_optimisation, + param_grid, + x = 1, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.15, + jitter_height = 0.15, + jitter_seed = 1L, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + legend_position = "top") { + + lang <- match.arg(lang) + if (is.null(param_labels)) param_labels <- default_param_labels(lang) + + txt <- switch( + lang, + de = list( + title = "Kosten vs. \u00dcberlaufvolumen", + x = "Gesamtkosten [\u20ac]", + y = "\u00dcberlaufvolumen [m\u00b3]", + legend = "Anzahl \u00dcberlaufereignisse" + ), + en = list( + title = "Cost vs. overflow volume", + x = "Total cost [\u20ac]", + y = "Overflow volume [m\u00b3]", + legend = "Number of overflow events" + ) + ) + txt <- c(txt, cost_tooltip_labels(lang)) + + if (is.null(lab_x)) lab_x <- txt$x + if (is.null(lab_y)) lab_y <- txt$y + + req_grid <- c("scenario_name") + req_res <- c( + "scenario_name", "n_overflows", "sum_overflows", "mulde_area", + "element.WB_Evapotranspiration_", "element.WB_InfiltrationNetto_", + "element.WB_Oberflaechenablauf_Ueberlauf_", + "cost_excavation", "cost_profiling", "cost_filter", + "cost_storage", "cost_total", "storage_type" + ) + + miss_grid <- setdiff(req_grid, names(param_grid)) + miss_res <- setdiff(req_res, names(simulation_results_optimisation)) + + if (length(miss_grid) > 0) { + stop("param_grid is missing column(s): ", paste(miss_grid, collapse = ", ")) + } + if (length(miss_res) > 0) { + stop( + "simulation_results_optimisation is missing column(s): ", + paste(miss_res, collapse = ", ") + ) + } + if (!is.numeric(x) || length(x) != 1 || is.na(x) || x < 0) { + stop("x must be a single non-negative numeric value.") + } + + x_int <- as.integer(round(x)) + if (!isTRUE(all.equal(x, x_int))) { + warning("x is not an integer; using x_int = ", x_int, + " for discrete palette/legend.") + } + + # Append the share of scenarios meeting the validity criterion + # (n_overflows <= x) to the auto title (ggplotly drops ggplot subtitles). + valid_pct <- round(100 * mean( + simulation_results_optimisation$n_overflows <= x_int, na.rm = TRUE)) + share_txt <- switch(lang, + de = paste0(valid_pct, " % mit <= ", x_int, " \u00dcberl\u00e4ufen"), + en = paste0(valid_pct, " % with <= ", x_int, " overflows")) + if (is.null(title)) title <- paste0(txt$title, " (", share_txt, ")") + if (is.null(caption)) caption <- cost_rates_caption(lang) + + param_tooltip <- build_varying_param_html(param_grid, lang, param_labels, + digits_params) + + df <- simulation_results_optimisation %>% + dplyr::left_join(param_tooltip, by = "scenario_name") %>% + dplyr::filter(!isTRUE(filter_n_gtx) | + is.na(.data$n_overflows) | + .data$n_overflows <= x_int) %>% + dplyr::mutate( + overflow_volume_m3 = .data$sum_overflows * .data$mulde_area / 1000 + ) + + hi_lab <- paste0(">", x_int) + df <- df %>% + dplyr::mutate( + overflow_cat = dplyr::case_when( + is.na(.data$n_overflows) ~ NA_character_, + .data$n_overflows > x_int ~ hi_lab, + TRUE ~ as.character(.data$n_overflows) + ) + ) + + base_levels <- as.character(0:x_int) + levs <- c(base_levels, hi_lab) + + df <- df %>% + dplyr::mutate( + overflow_cat = factor(.data$overflow_cat, levels = levs) + ) + + # Storage type drives the marker shape (filled square = infiltration box, + # filled triangle = gravel trench); shared with the sibling cost plots. + st <- storage_type_shapes(df$storage_type, lang) + df$storage_type_disp <- st$display + + # Reference for the tooltip's cost-per-percent-evapotranspiration line: + # minimum evapotranspiration among the scenarios that satisfy the validity + # criterion (n_overflows <= x); computed before any filtering, falls back + # to the complete run when no scenario is valid. + evap_all <- simulation_results_optimisation[["element.WB_Evapotranspiration_"]] + valid_mask <- !is.na(simulation_results_optimisation$n_overflows) & + simulation_results_optimisation$n_overflows <= x_int & !is.na(evap_all) + evap_min <- suppressWarnings(min( + if (any(valid_mask)) evap_all[valid_mask] else evap_all, na.rm = TRUE)) + df$tooltip_html <- cost_tooltip_text(df, txt, digits, evap_min = evap_min) + + if (x_int == 0L) { + pal <- c("0" = "orange", ">0" = "red") + } else if (x_int == 1L) { + pal <- c("0" = "darkgreen", "1" = "orange", ">1" = "red") + } else { + pal_green <- grDevices::colorRampPalette(c("darkgreen", "yellowgreen"))(x_int) + pal_vals <- c(pal_green, "orange", "red") + pal_names <- c(base_levels, hi_lab) + pal <- stats::setNames(pal_vals, pal_names) + } + + legend_breaks <- levs + + pos <- if (isTRUE(use_jitter)) { + ggplot2::position_jitter( + width = jitter_width, + height = jitter_height, + seed = jitter_seed + ) + } else { + "identity" + } + + legend_direction <- if (legend_position %in% c("top", "bottom")) { + "horizontal" + } else { + "vertical" + } + + legend_nrow <- if (legend_direction == "horizontal") 1 else NULL + legend_ncol <- if (legend_direction == "vertical") 1 else NULL + + p <- ggplot2::ggplot(df, ggplot2::aes( + x = .data$cost_total, + y = .data$overflow_volume_m3, + color = .data$overflow_cat, + shape = .data$storage_type_disp, + text = .data$tooltip_html + )) + + ggplot2::geom_point(alpha = 0.7, position = pos) + + ggplot2::scale_color_manual( + values = pal, + breaks = legend_breaks, + limits = levs, + drop = FALSE, + name = txt$legend + ) + + ggplot2::scale_shape_manual( + values = st$shape_values, + drop = FALSE, + name = txt$tt_storage_type + ) + + ggplot2::guides( + colour = ggplot2::guide_legend( + direction = legend_direction, + nrow = legend_nrow, + ncol = legend_ncol, + byrow = TRUE, + order = 1 + ), + shape = ggplot2::guide_legend( + direction = legend_direction, + nrow = legend_nrow, + ncol = legend_ncol, + byrow = TRUE, + order = 2 + ) + ) + + ggplot2::labs( + title = title, + x = lab_x, + y = lab_y, + caption = if (nzchar(caption)) caption else NULL + ) + + ggplot2::theme_bw() + + ggplot2::theme( + legend.position = legend_position, + legend.direction = legend_direction, + # stack the colour and shape legends so both fit at the top + legend.box = if (legend_direction == "horizontal") "vertical" else "horizontal" + ) + + p +} diff --git a/R/plot_main_effects.R b/R/plot_main_effects.R index 5e98e8c..45a3d97 100644 --- a/R/plot_main_effects.R +++ b/R/plot_main_effects.R @@ -8,6 +8,11 @@ #' The function is intended for optimisation or sensitivity grids with many #' parameters, where a single 2D scatter plot is not informative. #' +#' Both numeric and character parameters are supported; a character parameter +#' such as \code{storage_type} gets its own facet panel (its levels are shown +#' as \code{Sickerbox} / \code{Schotterrigol} for \code{lang = "de"}, +#' \code{Infiltration box} / \code{Gravel trench} for \code{lang = "en"}). +#' #' The plot language can be switched via \code{lang = "de"} or #' \code{lang = "en"}. This affects the title, y-axis label, and selected #' parameter labels. @@ -29,8 +34,7 @@ #' @export #' @importFrom dplyr %>% select all_of group_by summarise left_join mutate n_distinct #' @importFrom tidyr pivot_longer -#' @importFrom ggplot2 ggplot aes geom_violin geom_boxplot geom_jitter facet_wrap -#' theme_bw theme labs element_text coord_cartesian +#' @importFrom ggplot2 ggplot aes geom_violin geom_boxplot geom_jitter facet_wrap theme_bw theme labs element_text coord_cartesian #' @importFrom forcats fct_reorder #' @importFrom stats median #' @importFrom rlang .data @@ -72,8 +76,13 @@ plot_main_effects <- function(df, "filter_height" = "Filterh\u00f6he [mm]", "bottom_hydraulicconductivity" = "hydr. Leitf\u00e4higkeit des Untergrunds [mm/h]", "rain_factor" = "Regenfaktor", - "lai" = "Blattfl\u00e4chenindex (Mulde-Rigole) [m\u00b2/m\u00b2]" + "lai" = "Blattfl\u00e4chenindex (Mulde-Rigole) [m\u00b2/m\u00b2]", + "storage_type" = "Speichertyp" ) + + # Display names for the storage_type levels on the x-axis of its panel + # (shared with the tooltips, see cost_tooltip.R). + storage_value_labels <- storage_type_value_labels(lang) translate_param <- function(x) { if (lang == "de" && x %in% names(param_labels_de)) { @@ -92,13 +101,33 @@ plot_main_effects <- function(df, dl <- df %>% dplyr::select(dplyr::all_of(c(y, params_use))) %>% + # values_transform: numeric and character parameters (e.g. storage_type) + # cannot share one `value` column otherwise. tidyr::pivot_longer( cols = dplyr::all_of(params_use), names_to = "parameter", - values_to = "value" + values_to = "value", + values_transform = list(value = as.character) ) %>% dplyr::mutate( - value = as.factor(.data$value) + value = ifelse( + .data$parameter == "storage_type" & + .data$value %in% names(storage_value_labels), + storage_value_labels[.data$value], + .data$value + ) + ) + + # Order the shared factor levels numerically where possible (a plain + # as.factor on characters would sort "1000" before "500"); non-numeric + # levels (e.g. the storage types) come last, alphabetically. + val_u <- unique(dl$value) + val_n <- suppressWarnings(as.numeric(val_u)) + value_levels <- c(val_u[!is.na(val_n)][order(val_n[!is.na(val_n)])], + sort(val_u[is.na(val_n)])) + dl <- dl %>% + dplyr::mutate( + value = factor(.data$value, levels = value_levels) ) eff <- dl %>% @@ -140,4 +169,4 @@ plot_main_effects <- function(df, } p -} \ No newline at end of file +} diff --git a/R/plot_valid_design_space.R b/R/plot_valid_design_space.R index 4b027a2..849074b 100644 --- a/R/plot_valid_design_space.R +++ b/R/plot_valid_design_space.R @@ -73,6 +73,16 @@ #' \code{drop_overflow_gt_valid_max = TRUE}, the x/y axis limits are fixed to #' the full range or full set of levels found in \code{param_grid}, so the #' design-space axes do not shrink after filtering. Default \code{TRUE}. +#' @param facet_storage_type Logical. If \code{TRUE}, the design space is +#' split by \code{storage_type} into two stacked panels (infiltration box on +#' top, gravel trench below) with free y-scales, so disjoint per-type levels +#' (e.g. \code{storage_height}: 300-1200 mm boxes vs. 900-3600 mm trenches) +#' fill their own panel; duplicate counting for \code{alpha_mode = +#' "duplicates"} then happens per panel and the points stay plain circles +#' (the strips already name the type). Requires a \code{storage_type} +#' column in \code{param_grid}. Without faceting, points are shaped by the +#' storage type (filled square = infiltration box, filled triangle = gravel +#' trench) whenever \code{storage_type} varies. Default \code{FALSE}. #' @param lang Character. Plot language: \code{"de"} or \code{"en"}. #' @param title Character or \code{NULL}. Plot title. If \code{NULL}, a #' language-specific default title is used. @@ -88,7 +98,9 @@ #' @importFrom ggplot2 position_identity scale_alpha_identity coord_cartesian #' @importFrom ggplot2 scale_x_discrete scale_y_discrete scale_colour_manual #' @importFrom ggplot2 guides guide_legend theme scale_x_continuous scale_y_continuous +#' @importFrom ggplot2 scale_shape_manual facet_grid vars #' @importFrom rlang .data +#' @importFrom utils modifyList #' @importFrom grDevices colorRampPalette #' @export plot_valid_design_space <- function(param_grid, @@ -111,6 +123,7 @@ plot_valid_design_space <- function(param_grid, alpha_min = 0.20, alpha_max = 1.00, keep_param_grid_limits = TRUE, + facet_storage_type = FALSE, lang = c("de", "en"), title = NULL, subtitle = NULL, @@ -132,10 +145,12 @@ plot_valid_design_space <- function(param_grid, txt <- switch( lang, de = list( + # two lines: the composed title (threshold + both axis labels) is too + # long for one line in the 9-inch PDFs and the interactive HTMLs title = paste0( - "G\u00fcltige L\u00f6sungen (Anzahl \u00dcberlaufereignisse \u2264 ", + "G\u00fcltige L\u00f6sungen (Anzahl \u00dcberlaufereignisse <= ", valid_max, - ") im Designraum: ", + ")\nim Designraum: ", lab_x, " \u00d7 ", lab_y @@ -148,9 +163,9 @@ plot_valid_design_space <- function(param_grid, ), en = list( title = paste0( - "Valid solutions (Number of overflow events \u2264 ", + "Valid solutions (Number of overflow events <= ", valid_max, - ") in design space: ", + ")\nin design space: ", x, " \u00d7 ", y @@ -194,14 +209,29 @@ plot_valid_design_space <- function(param_grid, stop("Missing columns in sim_results: ", paste(miss_res, collapse = ", ")) } + if (isTRUE(facet_storage_type) && !"storage_type" %in% names(param_grid)) { + stop("facet_storage_type = TRUE requires a 'storage_type' column in param_grid.") + } + cand <- setdiff(names(param_grid), id_col) lvl <- vapply(param_grid[cand], function(v) dplyr::n_distinct(v, na.rm = TRUE), numeric(1)) varied_params <- cand[lvl > 1 & lvl <= max_levels] - - keep_pg <- unique(c(id_col, x, y, varied_params)) + + keep_pg <- unique(c(id_col, x, y, varied_params, + if (isTRUE(facet_storage_type)) "storage_type")) + + # Usable storage volume [m3] for the tooltip, taken from the results side + # (precomputed storage_volume_m3 column or derived from the storage_theta* + # columns there) unless param_grid already carries the column itself. + sim_sel <- dplyr::select(sim_results, dplyr::all_of(c(id_col, overflow_col))) + if (!"storage_volume_m3" %in% keep_pg) { + sim_volume <- storage_volume_from_df(sim_results) + if (!is.null(sim_volume)) sim_sel$storage_volume_m3 <- sim_volume + } + d <- dplyr::left_join( dplyr::select(param_grid, dplyr::all_of(keep_pg)), - dplyr::select(sim_results, dplyr::all_of(c(id_col, overflow_col))), + sim_sel, by = id_col ) @@ -213,8 +243,29 @@ plot_valid_design_space <- function(param_grid, if (isTRUE(drop_overflow_gt_valid_max)) { d <- dplyr::filter(d, .data[[overflow_col]] <= valid_max_int) } - - other_params <- setdiff(varied_params, c(x, y)) + + # Storage-type tagging (filled square = infiltration box, filled triangle = + # gravel trench, as in the cost plots): active whenever storage_type is one + # of the varied parameters -- except in the faceted layout, where the strips + # already name the type and the points stay plain circles for readability. + st_labels <- cost_tooltip_labels(lang) + has_storage_type <- "storage_type" %in% names(d) + if (has_storage_type) { + st <- storage_type_shapes(d$storage_type, lang) + d$storage_type_disp <- st$display + } + use_shapes <- has_storage_type && !isTRUE(facet_storage_type) + add_shape <- function(mapping) { + if (use_shapes) { + utils::modifyList(mapping, ggplot2::aes(shape = .data$storage_type_disp)) + } else { + mapping + } + } + + # storage_volume_m3 gets its own dedicated tooltip line below, so keep it + # out of the generic "other parameters" block. + other_params <- setdiff(varied_params, c(x, y, "storage_volume_m3")) fmt <- function(v) { if (is.numeric(v)) { @@ -233,11 +284,19 @@ plot_valid_design_space <- function(param_grid, "" } + vol_line <- if ("storage_volume_m3" %in% names(d)) { + paste0("
", st_labels$tt_storage_volume, ": ", + fmt(d$storage_volume_m3)) + } else { + "" + } + d$hover <- paste0( "", txt$tt_id, ": ", d[[id_col]], "
", txt$tt_overflow, ": ", fmt(d[[overflow_col]]), "
", lab_x, ": ", fmt(d[[x]]), "
", lab_y, ": ", fmt(d[[y]]), + vol_line, other_block ) @@ -274,8 +333,14 @@ plot_valid_design_space <- function(param_grid, legend_breaks <- levs if (alpha_mode == "duplicates") { + # With storage-type facets, identical x/y coordinates only overplot within + # the same panel, so duplicates are counted per storage type. + d <- if (isTRUE(facet_storage_type) && has_storage_type) { + d %>% dplyr::group_by(.data$storage_type_disp, .data[[x]], .data[[y]]) + } else { + d %>% dplyr::group_by(.data[[x]], .data[[y]]) + } d <- d %>% - dplyr::group_by(.data[[x]], .data[[y]]) %>% dplyr::mutate(dup_n = dplyr::n()) %>% dplyr::ungroup() @@ -320,11 +385,11 @@ plot_valid_design_space <- function(param_grid, if (isTRUE(drop_overflow_gt_valid_max)) { p <- ggplot2::ggplot(d, ggplot2::aes(x = .data[[x]], y = .data[[y]])) + ggplot2::geom_point( - ggplot2::aes( + add_shape(ggplot2::aes( colour = .data$overflow_cat, text = .data$hover, alpha = .data$alpha_valid - ), + )), size = size + 0.6, position = pos ) + @@ -359,21 +424,21 @@ plot_valid_design_space <- function(param_grid, p <- ggplot2::ggplot(d, ggplot2::aes(x = .data[[x]], y = .data[[y]])) + ggplot2::geom_point( data = dplyr::filter(d, !.data$valid), - ggplot2::aes( + add_shape(ggplot2::aes( colour = .data$overflow_cat, text = .data$hover, alpha = .data$alpha_invalid - ), + )), size = size, position = pos ) + ggplot2::geom_point( data = dplyr::filter(d, .data$valid), - ggplot2::aes( + add_shape(ggplot2::aes( colour = .data$overflow_cat, text = .data$hover, alpha = .data$alpha_valid - ), + )), size = size + 0.8, position = pos ) + @@ -406,6 +471,30 @@ plot_valid_design_space <- function(param_grid, ) } + if (use_shapes) { + p <- p + + # Square = infiltration box, triangle = gravel trench. + ggplot2::scale_shape_manual( + values = st$shape_values, + drop = FALSE, + name = st_labels$tt_storage_type + ) + + # stack the colour and shape legends so both fit at the top + ggplot2::theme( + legend.box = if (legend_direction == "horizontal") "vertical" else "horizontal" + ) + } + if (isTRUE(facet_storage_type)) { + # Two stacked panels (infiltration box on top, gravel trench below); + # free y-scales let disjoint per-type levels (e.g. storage_height: + # 300-1200 mm boxes vs. 900-3600 mm trenches) fill their own panel. + # plotly::ggplotly() keeps the split as stacked subplots. + p <- p + ggplot2::facet_grid( + rows = ggplot2::vars(.data$storage_type_disp), + scales = "free_y" + ) + } + if (isTRUE(keep_param_grid_limits)) { if (is.numeric(param_grid[[x]]) && is.numeric(param_grid[[y]])) { x_vals <- sort(unique(param_grid[[x]])) @@ -418,7 +507,8 @@ plot_valid_design_space <- function(param_grid, if (isTRUE(drop_overflow_gt_valid_max)) { p <- p + ggplot2::coord_cartesian( xlim = range(x_vals), - ylim = range(y_vals) + # a fixed ylim would override the per-panel free y-scales + ylim = if (isTRUE(facet_storage_type)) NULL else range(y_vals) ) } } else { diff --git a/R/plot_wb_tradeoff_overflows.R b/R/plot_wb_tradeoff_overflows.R index 71c0339..9da99e1 100644 --- a/R/plot_wb_tradeoff_overflows.R +++ b/R/plot_wb_tradeoff_overflows.R @@ -18,7 +18,15 @@ #' tooltip labels unless custom labels are supplied explicitly. #' #' Tooltip text additionally includes all parameters from \code{param_grid} that -#' vary across scenarios, excluding \code{scenario_name}. +#' vary across scenarios, excluding \code{scenario_name} (translated via +#' \code{\link{default_param_labels}}; mixed numeric / character parameters +#' such as \code{storage_type} are supported). +#' +#' If \code{simulation_results_optimisation} carries a \code{storage_type} +#' column, the points are additionally **shaped by the storage type** (filled +#' square = infiltration box / Sickerbox, filled triangle = gravel trench / +#' Schotterrigol, as in the cost plots) and the tooltip names the storage +#' type; older single-type result sets plot exactly as before. #' #' @param simulation_results_optimisation Data frame with simulation results. #' Required columns are \code{scenario_name}, \code{n_overflows}, @@ -55,12 +63,10 @@ #' #' @export #' -#' @importFrom dplyr %>% select summarise across everything n_distinct -#' @importFrom dplyr filter pull mutate group_by left_join -#' @importFrom tidyr pivot_longer -#' @importFrom purrr map_chr -#' @importFrom ggplot2 ggplot aes geom_point scale_color_manual labs theme_minimal position_jitter theme +#' @importFrom dplyr %>% filter mutate left_join case_when +#' @importFrom ggplot2 ggplot aes geom_point scale_color_manual scale_shape_manual labs theme_bw position_jitter theme guides guide_legend #' @importFrom grDevices colorRampPalette +#' @importFrom utils modifyList #' @importFrom rlang .data plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, param_grid, @@ -84,7 +90,7 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, lang, de = list( title = paste0( - "Wasserbilanz vs. \u00DCberlaufereignisse (Anzahl \u2264 ", + "Wasserbilanz vs. \u00DCberlaufereignisse (Anzahl <= ", x, ")" ), @@ -101,7 +107,7 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, ), en = list( title = paste0( - "Water balance vs. overflow events (number \u2264 ", + "Water balance vs. overflow events (number <= ", x, ")" ), @@ -157,35 +163,13 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, warning("x is not an integer; using x_int = ", x_int, " for discrete palette/legend.") } - varying_params <- param_grid %>% - dplyr::select(-scenario_name) %>% - dplyr::summarise(dplyr::across(dplyr::everything(), ~ dplyr::n_distinct(.) > 1)) %>% - tidyr::pivot_longer(dplyr::everything(), names_to = "param", values_to = "vary") %>% - dplyr::filter(vary) %>% - dplyr::pull(param) - - if (length(varying_params) == 0) { - param_tooltip <- param_grid %>% - dplyr::select(scenario_name) %>% - dplyr::mutate(params_html = "") - } else { - param_tooltip <- param_grid %>% - dplyr::select(scenario_name, dplyr::all_of(varying_params)) %>% - tidyr::pivot_longer(-scenario_name, names_to = "param", values_to = "val") %>% - dplyr::mutate( - val_chr = purrr::map_chr(val, ~ paste(.x, collapse = ",")), - val_num = suppressWarnings(as.numeric(val_chr)), - val_fmt = ifelse( - is.na(val_num), - val_chr, - format(round(val_num, digits_params), trim = TRUE) - ), - kv = paste0(param, "=", val_fmt) - ) %>% - dplyr::group_by(scenario_name) %>% - dplyr::summarise(params_html = paste(kv, collapse = "
"), .groups = "drop") - } - + # Shared helper (same as the cost plots): handles mixed numeric / character + # parameter columns (e.g. storage_type) and translates the parameter names + # via default_param_labels(). + param_tooltip <- build_varying_param_html(param_grid, lang, + param_labels = NULL, + digits_params = digits_params) + df <- simulation_results_optimisation %>% dplyr::left_join(param_tooltip, by = "scenario_name") %>% dplyr::filter(!isTRUE(filter_n_gtx) | is.na(.data$n_overflows) | .data$n_overflows <= x_int) @@ -207,7 +191,52 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, dplyr::mutate( overflow_cat = factor(.data$overflow_cat, levels = levs) ) - + + # Optional storage-type tagging (filled square = infiltration box, filled + # triangle = gravel trench, as in the cost plots): active when the results + # carry a storage_type column; older single-type result sets plot as before. + st_labels <- cost_tooltip_labels(lang) + has_storage_type <- "storage_type" %in% names(df) + if (has_storage_type) { + # short language-specific names for the legend keys ... + st <- storage_type_shapes(df$storage_type, lang) + df$storage_type_disp <- st$display + # ... but the bilingual names for the tooltip line, matching the cost + # plots' tooltips + st_raw <- as.character(df$storage_type) + df$storage_type_tooltip <- ifelse( + !is.na(st_raw) & st_raw == "gravel_trench", + st_labels$st_gravel_trench, st_labels$st_infiltration_box) + } + # Usable storage volume of the storage layer [m3] (precomputed column or + # derived from the storage_theta* columns); line omitted if not derivable. + storage_volume <- storage_volume_from_df(df) + + df$tooltip_html <- paste0( + txt$tt_scenario, ": ", df$scenario_name, + "
", txt$tt_n_overflows, ": ", df$n_overflows, + "
", txt$tt_infil, ": ", + round(df[["element.WB_InfiltrationNetto_"]], digits), + "
", txt$tt_evap, ": ", + round(df[["element.WB_Evapotranspiration_"]], digits), + "
", txt$tt_overflow, ": ", + round(df[["element.WB_Oberflaechenablauf_Ueberlauf_"]], digits), + "
", txt$tt_sum_overflows, ": ", df$sum_overflows, + if (has_storage_type) { + paste0("

", st_labels$tt_storage_type, ": ", + df$storage_type_tooltip, "") + } else { + "" + }, + if (!is.null(storage_volume)) { + paste0("
", st_labels$tt_storage_volume, ": ", + round(storage_volume, digits)) + } else { + "" + }, + "

", txt$tt_params, "
", df$params_html + ) + if (x_int == 0L) { pal <- c("0" = "orange", ">0" = "red") } else if (x_int == 1L) { @@ -240,20 +269,20 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, legend_nrow <- if (legend_direction == "horizontal") 1 else NULL legend_ncol <- if (legend_direction == "vertical") 1 else NULL - p <- ggplot2::ggplot(df, ggplot2::aes( - x = element.WB_InfiltrationNetto_, - y = element.WB_Evapotranspiration_, - color = overflow_cat, - text = paste0( - txt$tt_scenario, ": ", scenario_name, - "
", txt$tt_n_overflows, ": ", n_overflows, - "
", txt$tt_infil, ": ", round(element.WB_InfiltrationNetto_, digits), - "
", txt$tt_evap, ": ", round(element.WB_Evapotranspiration_, digits), - "
", txt$tt_overflow, ": ", round(element.WB_Oberflaechenablauf_Ueberlauf_, digits), - "
", txt$tt_sum_overflows, ": ", sum_overflows, - "

", txt$tt_params, "
", params_html + mapping <- ggplot2::aes( + x = .data[["element.WB_InfiltrationNetto_"]], + y = .data[["element.WB_Evapotranspiration_"]], + color = .data$overflow_cat, + text = .data$tooltip_html + ) + if (has_storage_type) { + mapping <- utils::modifyList( + mapping, + ggplot2::aes(shape = .data$storage_type_disp) ) - )) + + } + + p <- ggplot2::ggplot(df, mapping) + ggplot2::geom_point(alpha = 0.7, position = pos) + ggplot2::scale_color_manual( values = pal, @@ -267,7 +296,8 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, direction = legend_direction, nrow = legend_nrow, ncol = legend_ncol, - byrow = TRUE + byrow = TRUE, + order = 1 ) ) + ggplot2::labs( @@ -278,8 +308,28 @@ plot_wb_tradeoff_overflows <- function(simulation_results_optimisation, ggplot2::theme_bw() + ggplot2::theme( legend.position = legend_position, - legend.direction = legend_direction + legend.direction = legend_direction, + # stack the colour and shape legends so both fit at the top + legend.box = if (legend_direction == "horizontal") "vertical" else "horizontal" ) - + + if (has_storage_type) { + p <- p + + ggplot2::scale_shape_manual( + values = st$shape_values, + drop = FALSE, + name = st_labels$tt_storage_type + ) + + ggplot2::guides( + shape = ggplot2::guide_legend( + direction = legend_direction, + nrow = legend_nrow, + ncol = legend_ncol, + byrow = TRUE, + order = 2 + ) + ) + } + p -} \ No newline at end of file +} diff --git a/R/plotly_split_legend.R b/R/plotly_split_legend.R new file mode 100644 index 0000000..e4d9519 --- /dev/null +++ b/R/plotly_split_legend.R @@ -0,0 +1,310 @@ +#' Split the combined (colour, shape) ggplotly legend into two clean legends +#' +#' `plotly::ggplotly()` flattens a ggplot with both a colour and a shape +#' aesthetic into one trace per (colour, shape) combination and names the +#' legend entries as tuples such as `"(0,Sickerbox / Infiltration box)"` -- +#' with two storage types and the 0..x / ">x" overflow palette that yields an +#' unreadable legend. This helper post-processes the plotly object: +#' +#' * the real traces lose their legend entries; instead every overflow class +#' gets one legend-only key drawn as a **neutral circle in the class +#' colour** (a coloured square or triangle would wrongly suggest one +#' specific storage type). The key shares its legend group with the real +#' traces of that class, so clicking it toggles **both** storage types of +#' the class together; +#' * two legend-only keys (**neutral grey** filled square = infiltration box, +#' filled triangle = gravel trench) are appended under their own +#' **storage-type group title**, so the shape encoding is explained +#' separately from the colours -- set `add_shape_legend = FALSE` to skip +#' them (e.g. for storage-type-faceted plots whose strips already label the +#' panels). The keys are **clickable**: since a plotly trace can only carry +#' one legend group (taken by the overflow class), a small JavaScript +#' handler (via `htmlwidgets::onRender()`) toggles all traces drawn with +#' that marker symbol, so each storage type can be shown or hidden +#' individually; the key greys out to reflect the state; +#' * the combined `"colour,shape"` legend-title annotation that ggplotly +#' draws over the plot title is removed; group titles take its place and +#' the legend moves to a vertical layout on the right, where the groups +#' stack cleanly. +#' +#' Traces whose name is not a `"(colour,shape)"` tuple (frontier lines, best +#' markers, single-aesthetic plots) are left untouched, so the helper is safe +#' to apply to any of the package's interactive plots. +#' +#' @param pl A plotly object as returned by +#' `plotly::ggplotly(p, tooltip = "text")`. +#' @param lang Character. `"de"` or `"en"`; sets the default legend group +#' titles. +#' @param colour_title Character or `NULL`. Title of the colour legend group. +#' Defaults to the language-specific "Number of overflow events". +#' @param shape_title Character or `NULL`. Title of the storage-type legend +#' group. Defaults to the language-specific "Storage type". +#' @param add_shape_legend Logical. If `TRUE` (default), append the two +#' legend-only storage-type entries. +#' +#' @return The modified plotly object. +#' +#' @examples +#' \dontrun{ +#' p <- plot_cost_vs_evaporation(sim_results, param_grid, x = 5, lang = "de") +#' pl <- plotly::ggplotly(p, tooltip = "text") +#' pl <- plotly_split_legend(pl, lang = "de") +#' } +#' +#' @export +plotly_split_legend <- function(pl, + lang = c("de", "en"), + colour_title = NULL, + shape_title = NULL, + add_shape_legend = TRUE) { + + lang <- match.arg(lang) + tt <- cost_tooltip_labels(lang) + if (is.null(colour_title)) { + colour_title <- switch(lang, + de = "Anzahl \u00dcberlaufereignisse", + en = "Number of overflow events") + } + if (is.null(shape_title)) shape_title <- tt$tt_storage_type + + traces <- pl$x$data + tuple_re <- "^\\((.+?),(.+)\\)$" + seen_colour <- character(0) + colour_swatch <- list() + shape_symbols <- list() + found_tuples <- FALSE + + # Every real (colour, shape) trace loses its legend entry; the legend is + # rebuilt from legend-only dummy traces below, so the colour keys can be + # neutral circles and the storage-type keys neutral grey shapes -- a + # coloured square/triangle key would wrongly suggest one specific + # (colour, type) combination. + for (i in seq_along(traces)) { + nm <- traces[[i]]$name + if (is.null(nm) || length(nm) != 1) next + m <- regmatches(as.character(nm), regexec(tuple_re, as.character(nm)))[[1]] + if (length(m) != 3) next + found_tuples <- TRUE + + colour_lab <- trimws(m[2]) + shape_lab <- trimws(m[3]) + + sym <- traces[[i]]$marker$symbol + if (!is.null(sym) && length(sym) >= 1 && !shape_lab %in% names(shape_symbols)) { + shape_symbols[[shape_lab]] <- sym[[1]] + } + col <- traces[[i]]$marker$color + if (!is.null(col) && length(col) >= 1 && !colour_lab %in% names(colour_swatch)) { + colour_swatch[[colour_lab]] <- col[[1]] + } + if (!colour_lab %in% seen_colour) seen_colour <- c(seen_colour, colour_lab) + + traces[[i]]$name <- colour_lab + traces[[i]]$legendgroup <- colour_lab + traces[[i]]$showlegend <- FALSE + } + + # Layout clean-up shared by both cases: strip the legend-title annotation + # that ggplotly draws over the plot title, convert two-line ggplot titles + # ("\n") to plotly's "
", and move the legend to a vertical layout on + # the right where nothing collides with the title. + fix_layout <- function(pl) { + ann <- pl$x$layout$annotations + if (length(ann) > 0) { + keep <- vapply(ann, function(a) { + txt <- if (is.null(a$text)) "" else gsub("<[^>]+>", "", as.character(a$text)) + !(grepl(colour_title, txt, fixed = TRUE) || + grepl(shape_title, txt, fixed = TRUE)) + }, logical(1)) + pl$x$layout$annotations <- ann[keep] + } + if (!is.null(pl$x$layout$title$text)) { + pl$x$layout$title$text <- gsub("\n", "
", + pl$x$layout$title$text, fixed = TRUE) + } + pl$x$layout$showlegend <- TRUE + pl$x$layout$legend$orientation <- "v" + pl$x$layout$legend$x <- 1.02 + pl$x$layout$legend$xanchor <- "left" + pl$x$layout$legend$y <- 1.0 + pl$x$layout$legend$yanchor <- "top" + pl + } + + # No combined-legend traces (single-aesthetic plot, e.g. the faceted design + # space): keep the existing colour entries, but give the legend its title + # (instead of the removed annotation) and order the classes numerically + # with the ">x" catch-all last. + if (!found_tuples) { + for (i in seq_along(traces)) { + nm <- traces[[i]]$name + nm <- if (is.null(nm)) "" else as.character(nm) + if (grepl("^[0-9]+$", nm)) { + traces[[i]]$legendrank <- 100 + as.numeric(nm) + } else if (grepl("^>", nm)) { + traces[[i]]$legendrank <- 800 + } + } + pl$x$data <- traces + pl$x$layout$legend$title <- list(text = colour_title) + return(fix_layout(pl)) + } + + # Colour classes ordered numerically, the ">x" catch-all after them. + colour_num <- suppressWarnings(as.numeric(seen_colour)) + colour_sorted <- c(seen_colour[!is.na(colour_num)][order(colour_num[!is.na(colour_num)])], + sort(seen_colour[is.na(colour_num)])) + + # Legend-only colour keys: neutral circles in the class colour. They share + # the legendgroup with the real traces of that class, so clicking a key + # still toggles both storage types of the class together. + # Legend-only traces need one null data point (x/y = NA -> [null] in the + # JSON): plotly.js does not create legend entries for traces whose data + # arrays are completely empty. + first_colour <- TRUE + for (lab in colour_sorted) { + tr <- list( + x = NA_real_, y = NA_real_, + type = "scatter", mode = "markers", + marker = list(symbol = "circle", + color = colour_swatch[[lab]], + size = 10), + name = lab, + legendgroup = lab, + legendrank = 100 + match(lab, colour_sorted), + showlegend = TRUE, + hoverinfo = "none" + ) + if (first_colour) { + tr$legendgrouptitle <- list(text = colour_title) + first_colour <- FALSE + } + traces[[length(traces) + 1]] <- tr + } + + # Legend-only storage-type keys: neutral grey square / triangle. + has_shape_keys <- isTRUE(add_shape_legend) && length(shape_symbols) > 0 + if (has_shape_keys) { + first_shape <- TRUE + for (lab in names(shape_symbols)) { + tr <- list( + x = NA_real_, y = NA_real_, + type = "scatter", mode = "markers", + # "#666666" = R "grey40"; R colour names are not valid CSS for + # plotly.js + marker = list(symbol = shape_symbols[[lab]], color = "#666666", + size = 10), + name = lab, + legendgroup = "storage_type_legend", + legendrank = 900 + match(lab, names(shape_symbols)), + showlegend = TRUE, + hoverinfo = "none" + ) + if (first_shape) { + tr$legendgrouptitle <- list(text = shape_title) + first_shape <- FALSE + } + traces[[length(traces) + 1]] <- tr + } + } + + pl$x$data <- traces + + # Group titles replace the legend title in the rebuilt legend. + pl$x$layout$legend$title <- list(text = "") + + pl <- fix_layout(pl) + + # Make the storage-type keys interactive: a trace can only belong to one + # legend group (taken by the overflow class), so clicking a square/triangle + # key toggles all real traces with that marker symbol via a small + # plotly_legendclick handler; the key itself greys out to show the state. + if (has_shape_keys && requireNamespace("htmlwidgets", quietly = TRUE)) { + js_toggle <- paste0( + "function(el, x) {", + " el.on('plotly_legendclick', function(d) {", + " var tr = el.data[d.curveNumber];", + " if (!tr || tr.legendgroup !== 'storage_type_legend') return true;", + " var sym = tr.marker.symbol;", + " var idx = [];", + " var target = null;", + " el.data.forEach(function(t, i) {", + " var isKey = t.legendgroup === 'storage_type_legend';", + " var symMatch = t.marker && t.marker.symbol === sym;", + " if (symMatch && (!isKey || i === d.curveNumber)) {", + " idx.push(i);", + " if (!isKey && target === null) {", + " target = (t.visible === undefined || t.visible === true) ?", + " 'legendonly' : true;", + " }", + " }", + " });", + " if (target === null) return true;", + " Plotly.restyle(el, {visible: target}, idx);", + " return false;", + " });", + "}" + ) + pl <- htmlwidgets::onRender(pl, js_toggle) + } + + pl +} + +#' Add a caption annotation to a ggplotly object +#' +#' `plotly::ggplotly()` drops ggplot captions (and subtitles). This helper +#' re-adds the caption as a small grey annotation below the plot area (bottom +#' left, under the x-axis title) and widens the bottom margin accordingly. +#' `\n` line breaks are converted to `
`. +#' +#' Used by the vignettes together with [cost_rates_caption()] so the +#' interactive cost plots name the unit-cost rates they were computed with. +#' +#' @param pl A plotly object as returned by `plotly::ggplotly()`. +#' @param caption Character. The caption text; `NULL` or `""` returns `pl` +#' unchanged. +#' @param font_size Numeric. Caption font size in px. Default 10. +#' +#' @return The modified plotly object. +#' +#' @examples +#' \dontrun{ +#' pl <- plotly::ggplotly(p, tooltip = "text") +#' pl <- plotly_add_caption(pl, cost_rates_caption("de")) +#' } +#' +#' @export +plotly_add_caption <- function(pl, caption, font_size = 10) { + if (is.null(caption) || !nzchar(caption)) return(pl) + # Two-line ggplot titles ("\n") need "
" in plotly and a little more + # headroom (relevant for plots that do not pass plotly_split_legend(), + # e.g. the cost boxplots with their reference line in the title). + if (!is.null(pl$x$layout$title$text)) { + pl$x$layout$title$text <- gsub("\n", "
", + pl$x$layout$title$text, fixed = TRUE) + if (grepl("
", pl$x$layout$title$text, fixed = TRUE) && + (is.null(pl$x$layout$margin$t) || pl$x$layout$margin$t < 75)) { + pl$x$layout$margin$t <- 75 + } + } + # Anchored to the bottom of the plot area with a fixed PIXEL offset + # (yshift): a paper-coordinate offset would scale with the plot height and + # push the caption out of the margin on tall (e.g. faceted) plots. + ann <- list( + text = gsub("\n", "
", caption, fixed = TRUE), + x = 0, y = 0, + xref = "paper", yref = "paper", + xanchor = "left", yanchor = "top", + yshift = -58, + showarrow = FALSE, + align = "left", + font = list(size = font_size, color = "#666666") + ) + pl$x$layout$annotations <- c(pl$x$layout$annotations, list(ann)) + # room below the x-axis tick labels and title for the caption line + if (is.null(pl$x$layout$margin$b) || pl$x$layout$margin$b < 85) { + pl$x$layout$margin$b <- 85 + } + pl +} diff --git a/R/read_site_timeseries.R b/R/read_site_timeseries.R new file mode 100644 index 0000000..266abf2 --- /dev/null +++ b/R/read_site_timeseries.R @@ -0,0 +1,83 @@ +#' Read and prepare site rain/ET0 time series for the engine (mm/h) +#' +#' Factors the time-series preparation duplicated in the Wien and Bad +#' Aussee workflow vignettes into one helper: reads the shipped GeoSphere +#' rain series (`rain.csv.gz`: columns `time` (datetime), `rr` (mm per +#' interval), `station`, further columns tolerated) and reference ET0 +#' series (`et.csv`: +#' `date;value` with `dd.mm.yyyy`, mm per day), converts both to hours +#' since series start, aligns the series ends (the shorter series is +#' extended to the longer one's end, repeating its last value) and +#' converts the values to the engine's **mm/h** rate convention (rain: +#' mm per interval / interval hours; ET0: mm per day / 24). +#' +#' @param path_rain Path to the rain CSV (may be gzipped). +#' @param path_et Path to the ET0 CSV (semicolon separated). +#' @param verbose Print alignment messages (default TRUE). +#' +#' @return List with data.frames `rain` and `et` (columns `time` = hours +#' since start, `value` = mm/h) ready for +#' `make_swale_runner(timeseries_rain = , timeseries_et = )`. +#' +#' @seealso [make_swale_runner()] +#' @export +read_site_timeseries <- function(path_rain, path_et, verbose = TRUE) { + + if (!requireNamespace("readr", quietly = TRUE)) { + stop("read_site_timeseries() requires the 'readr' package") + } + + timeseries_et <- readr::read_delim(path_et, delim = ";", + col_types = "cd") %>% + dplyr::mutate( + date = lubridate::dmy(.data$date), + time = as.integer(difftime(.data$date, min(.data$date), + units = "hours")) + ) %>% + dplyr::select(-"date") %>% + dplyr::filter(!is.na(.data$value)) %>% + dplyr::relocate("time", .before = "value") + timeseries_et$time[nrow(timeseries_et)] <- + ceiling(timeseries_et$time[nrow(timeseries_et)]) + + timeseries_rain <- readr::read_csv(path_rain, show_col_types = FALSE) %>% + dplyr::rename(datetime = "time", value = "rr") %>% + dplyr::mutate( + time = as.double(difftime(.data$datetime, min(.data$datetime), + units = "secs")) / 3600 + ) %>% + dplyr::filter(!is.na(.data$value)) %>% + # robust gegen zusaetzliche Spalten (Bad Aussee hat z.B. "substation"): + # die Engine erwartet exakt time + value + dplyr::select("time", "value") + timeseries_rain$time[nrow(timeseries_rain)] <- + ceiling(timeseries_rain$time[nrow(timeseries_rain)]) + + # Serien-Enden angleichen: die kuerzere Serie wird mit ihrem letzten + # Wert bis zum Ende der laengeren verlaengert (wie in den Vignetten). + extend_to <- function(df, t_end, label) { + if (t_end <= max(df$time)) return(df) + if (isTRUE(verbose)) { + message(sprintf( + "%s series extended by %.1f hours to %.0f h (last value %.4f)", + label, t_end - max(df$time), t_end, df$value[nrow(df)] + )) + } + dplyr::bind_rows(df, tibble::tibble(time = t_end, + value = df$value[nrow(df)])) + } + t_end <- max(max(timeseries_rain$time), max(timeseries_et$time)) + timeseries_et <- extend_to(timeseries_et, t_end, "ET0") + timeseries_rain <- extend_to(timeseries_rain, t_end, "Rain") + + # mm je Intervall -> mm/h (Engine liest beide Kurven als mm/h-Rate; + # ET0-Tageswerte ohne /24 wuerden 24x zu hoch integriert) + period_rain <- c(diff(timeseries_rain$time), + mean(diff(timeseries_rain$time))) + timeseries_rain$value <- timeseries_rain$value / period_rain + period_et <- c(diff(timeseries_et$time), mean(diff(timeseries_et$time))) + timeseries_et$value <- timeseries_et$value / period_et + + list(rain = as.data.frame(timeseries_rain), + et = as.data.frame(timeseries_et)) +} diff --git a/R/stack_levels.R b/R/stack_levels.R new file mode 100644 index 0000000..de0698b --- /dev/null +++ b/R/stack_levels.R @@ -0,0 +1,125 @@ +#' Achievable storage-layer stack heights from module heights +#' +#' Enumerates all storage-layer heights that can be built by stacking +#' (and mixing) the given module heights, e.g. full blocks combined with +#' at most one half block. +#' +#' @param modules Numeric vector of module heights in mm (e.g. `c(660, 350)` +#' for a full block plus a half block). +#' @param max_count Integer vector (recycled to `length(modules)`): maximum +#' number of modules of each type in one stack. Defaults to 7 for every +#' module (cf. GRAF EcoBloc smart, stackable up to 7 layers). +#' @param max_height Maximum total stack height in mm (default 2600). +#' +#' @return Sorted numeric vector of achievable stack heights in mm. +#' +#' @examples +#' stack_levels(360) # 360, 720, ..., 2520 +#' stack_levels(c(660, 350), max_count = c(7, 1)) # Rigofill full + half block +#' +#' @export +stack_levels <- function(modules, + max_count = rep(7L, length(modules)), + max_height = 2600) { + stopifnot(is.numeric(modules), all(modules > 0)) + max_count <- rep_len(as.integer(max_count), length(modules)) + counts <- Map(function(m, k) 0:min(k, floor(max_height / m)), + modules, max_count) + grid <- do.call(expand.grid, counts) + h <- as.vector(as.matrix(grid) %*% modules) + sort(unique(h[h > 0 & h <= max_height])) +} + +#' Sickerbox storage-height presets (brute force default + manufacturers) +#' +#' Named list of storage-height level vectors (mm) for the infiltration-box +#' storage layer. `brute_force` is the default used by the workflow +#' vignettes (300/600/900/1200 mm -- itself a combination of several box +#' types). The manufacturer presets are generated with [stack_levels()] +#' from typical module heights of commercial block systems; verify against +#' the current data sheets before productive optimisation runs. +#' +#' @param max_height Maximum total stack height in mm passed to +#' [stack_levels()] (default 2600). +#' +#' @return Named list of sorted numeric vectors (mm). +#' +#' @export +sickerbox_level_presets <- function(max_height = 2600) { + list( + brute_force = c(300, 600, 900, 1200), + graf_ecobloc_smart = stack_levels(360, max_height = max_height), + graf_ecobloc_420 = stack_levels(660, max_height = max_height), + fraenkische_rigofill = stack_levels(c(660, 350), max_count = c(7L, 1L), + max_height = max_height), + aco_stormbrixx_hd = stack_levels(614, max_height = max_height), + aco_stormbrixx_sd = stack_levels(342, max_height = max_height), + wavin_aquacell = stack_levels(400, max_height = max_height) + ) +} + +#' Default storage specification for the swale-design optimiser +#' +#' Storage-layer search space per storage type: the infiltration box uses +#' discrete stack levels (default: the brute-force grid levels), the gravel +#' trench is continuous with bounds coupled to the box level range by +#' `coupling_factor` (default 3, approximating the usable-porosity ratio +#' 0.95 / 0.3). +#' +#' Each entry also carries the **usable porosity** of the storage layer +#' (box 0.95, trench 0.3, matching [default_storage_types()]). The +#' bisection optimiser uses it to *derive* its search order from the +#' cost rates (cost per mm of storage capacity); without a `porosity` +#' entry it falls back to the default-rate hierarchy (smallest storage +#' level first). +#' +#' @param levels Numeric vector of infiltration-box stack heights in mm. +#' @param coupling_factor Factor between gravel-trench bounds and the box +#' level range. +#' @param gravel_tol Bisection tolerance for the continuous gravel-trench +#' height in mm. +#' +#' @return Named list with entries `infiltration_box` (with `levels` and +#' `porosity`) and `gravel_trench` (with `bounds`, `tol` and +#' `porosity`). +#' +#' @export +default_storage_spec <- function(levels = sickerbox_level_presets()$brute_force, + coupling_factor = 3, + gravel_tol = 25) { + list( + infiltration_box = list(levels = sort(unique(levels)), + porosity = 0.95), + gravel_trench = list(bounds = coupling_factor * range(levels), + tol = gravel_tol, + porosity = 0.3) + ) +} + +#' Default storage-type soil presets (Speicher layer) +#' +#' Soil parameters of the storage (2nd Bodenschichtung) layer per storage +#' type, as used by the workflow vignettes: infiltration box ("Sickerbox", +#' thetaS 0.95) and gravel trench ("Schotterrigol", thetaS 0.3). +#' +#' @return Named list (per storage type) of lists with +#' `Startwerte_theta_ActualSoilMoisture`, `thetaWP_MoistureAtWiltingPoint`, +#' `thetaFC_MoistureAtFieldCapacity`, `thetaS_MoistureAtSaturation`. +#' +#' @export +default_storage_types <- function() { + list( + infiltration_box = list( + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.95 + ), + gravel_trench = list( + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.3 + ) + ) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 41550e7..016636d 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -4,8 +4,8 @@ authors: href: https://www.kompetenz-wasser.de/en/ueber-uns/team/michael-rustler RAINDROP: href: https://www.kompetenz-wasser.de/en/forschung/projekte/raindrop - # html: Project RAINDROP + html: Project RAINDROP Kompetenzzentrum Wasser Berlin gGmbH (KWB): href: http://www.kompetenz-wasser.de html: _} and \verb{connectedarea._} \code{NA}-filled stub columns to -such rows so the rendered datatable still exposes the expected column -structure. Defaults to \code{NULL} (no canonical fallback).} +without the trailing \verb{_}), e.g. \code{default_canonical_wb_variables()}. +This is a \strong{per-scenario} fallback: for any scenario whose +\code{wb_element} and \code{wb_connectedarea} are both empty after the regular +pivot / mirror logic (including scenarios that are entirely \code{NULL}), +the function attaches \verb{element._} and \verb{connectedarea._} +\code{NA}-filled stub columns built from this list. This guarantees the +output tibble keeps the expected water-balance column structure even +when no scenario contributes real data — \code{dplyr::bind_rows()} would +otherwise drop columns that no row supplies. Defaults to \code{NULL} (no +canonical fallback).} } \value{ A tibble with one row per scenario containing: diff --git a/man/cost_rates_caption.Rd b/man/cost_rates_caption.Rd new file mode 100644 index 0000000..4001158 --- /dev/null +++ b/man/cost_rates_caption.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/compute_costs.R +\name{cost_rates_caption} +\alias{cost_rates_caption} +\title{Caption line naming the unit-cost rates behind the cost plots} +\usage{ +cost_rates_caption(lang = c("de", "en"), cost_rates = default_cost_rates()) +} +\arguments{ +\item{lang}{Character. \code{"de"} or \code{"en"}.} + +\item{cost_rates}{\code{list} of unit costs as returned by +\code{\link[=default_cost_rates]{default_cost_rates()}}.} +} +\value{ +\code{character(1)}, a single line. +} +\description{ +Formats the unit-cost rates (EUR per m2 / m3, see \code{\link[=default_cost_rates]{default_cost_rates()}}) +as a single-line caption for the cost plots, so every figure names the +rates its EUR values were computed with. Used as the default \code{caption} of +\code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}}, \code{\link[=plot_cost_vs_evaporation]{plot_cost_vs_evaporation()}} and +\code{\link[=plot_cost_overflow_boxplot]{plot_cost_overflow_boxplot()}} (rendered by ggplot at the bottom of the +PDFs) and passed to \code{\link[=plotly_add_caption]{plotly_add_caption()}} for the interactive HTMLs +(\code{plotly::ggplotly()} drops ggplot captions). +} +\details{ +If the costs were computed with non-default rates, pass the same +\code{cost_rates} list here so the caption matches the numbers. +} +\examples{ +cost_rates_caption("de") + +} diff --git a/man/default_param_labels.Rd b/man/default_param_labels.Rd new file mode 100644 index 0000000..b79e588 --- /dev/null +++ b/man/default_param_labels.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cost_tooltip.R +\name{default_param_labels} +\alias{default_param_labels} +\title{German / English labels for optimisation parameter-grid columns} +\usage{ +default_param_labels(lang = c("de", "en")) +} +\arguments{ +\item{lang}{Character. \code{"de"} or \code{"en"}.} +} +\value{ +A named \code{character} vector: names are \code{param_grid} column names, +values are the display labels. +} +\description{ +Maps the raw \code{param_grid} column names produced by the case-study workflows +to human-readable, unit-carrying labels. Used to translate the +"varying parameters" block in the interactive tooltips of +\code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}} and \code{\link[=plot_cost_overflow_boxplot]{plot_cost_overflow_boxplot()}}, so a +hovered point shows e.g. \code{Muldenflaeche [m2]=125} instead of the raw +\code{mulde_area=125}. +} +\details{ +Unknown columns fall back to their raw name, so a grid gaining a new column +still renders (just untranslated). Override individual entries or pass your +own named vector via the \code{param_labels} argument of the plot functions. +} +\examples{ +default_param_labels("de")[["mulde_area"]] +default_param_labels("en")[["storage_height"]] +} diff --git a/man/default_storage_spec.Rd b/man/default_storage_spec.Rd new file mode 100644 index 0000000..4f26657 --- /dev/null +++ b/man/default_storage_spec.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/stack_levels.R +\name{default_storage_spec} +\alias{default_storage_spec} +\title{Default storage specification for the swale-design optimiser} +\usage{ +default_storage_spec( + levels = sickerbox_level_presets()$brute_force, + coupling_factor = 3, + gravel_tol = 25 +) +} +\arguments{ +\item{levels}{Numeric vector of infiltration-box stack heights in mm.} + +\item{coupling_factor}{Factor between gravel-trench bounds and the box +level range.} + +\item{gravel_tol}{Bisection tolerance for the continuous gravel-trench +height in mm.} +} +\value{ +Named list with entries \code{infiltration_box} (with \code{levels} and +\code{porosity}) and \code{gravel_trench} (with \code{bounds}, \code{tol} and +\code{porosity}). +} +\description{ +Storage-layer search space per storage type: the infiltration box uses +discrete stack levels (default: the brute-force grid levels), the gravel +trench is continuous with bounds coupled to the box level range by +\code{coupling_factor} (default 3, approximating the usable-porosity ratio +0.95 / 0.3). +} +\details{ +Each entry also carries the \strong{usable porosity} of the storage layer +(box 0.95, trench 0.3, matching \code{\link[=default_storage_types]{default_storage_types()}}). The +bisection optimiser uses it to \emph{derive} its search order from the +cost rates (cost per mm of storage capacity); without a \code{porosity} +entry it falls back to the default-rate hierarchy (smallest storage +level first). +} diff --git a/man/default_storage_types.Rd b/man/default_storage_types.Rd new file mode 100644 index 0000000..7e951c0 --- /dev/null +++ b/man/default_storage_types.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/stack_levels.R +\name{default_storage_types} +\alias{default_storage_types} +\title{Default storage-type soil presets (Speicher layer)} +\usage{ +default_storage_types() +} +\value{ +Named list (per storage type) of lists with +\code{Startwerte_theta_ActualSoilMoisture}, \code{thetaWP_MoistureAtWiltingPoint}, +\code{thetaFC_MoistureAtFieldCapacity}, \code{thetaS_MoistureAtSaturation}. +} +\description{ +Soil parameters of the storage (2nd Bodenschichtung) layer per storage +type, as used by the workflow vignettes: infiltration box ("Sickerbox", +thetaS 0.95) and gravel trench ("Schotterrigol", thetaS 0.3). +} diff --git a/man/download_engine.Rd b/man/download_engine.Rd index d4969d5..d131caa 100644 --- a/man/download_engine.Rd +++ b/man/download_engine.Rd @@ -12,14 +12,14 @@ download_engine( } \arguments{ \item{version}{\code{character(1)} -Engine release version, matching the part after \code{engine-} in the -\code{KWB-R/kwb.raindrop.binaries} Release tag. Defaults to the -package's pinned version.} +Engine release version, matching the part after \verb{engine-} in the +\code{KWB-R/kwb.raindrop.binaries} Release tag. Defaults to the package's +pinned version.} \item{cache_dir}{\code{character(1)} Directory where engine binaries are cached. A sub-directory named after \code{version} is used so multiple versions can coexist. Defaults to -\code{\link[tools:R_user_dir]{tools::R_user_dir}}\code{("kwb.raindrop", "cache")}.} +\code{\link[tools:R_user_dir]{tools::R_user_dir}}\verb{("kwb.raindrop", "cache")}.} \item{force}{\code{logical(1)} If \code{TRUE}, re-download even if the executable already exists in the @@ -36,9 +36,9 @@ are no-ops once the file is present. } \details{ Releases in \code{KWB-R/kwb.raindrop.binaries} follow the tag scheme -\code{engine-} and contain a single asset named -\code{Regenwasserbewirtschaftung.exe} (the version is encoded in the -tag, not in the filename, so multiple engine versions can coexist +\verb{engine-} and contain a single asset named +\code{Regenwasserbewirtschaftung.exe} (the version is encoded in the tag, +not in the filename, so multiple engine versions can coexist side-by-side in the cache). The executable is a Windows binary; on non-Windows platforms the file is diff --git a/man/find_min_feasible.Rd b/man/find_min_feasible.Rd new file mode 100644 index 0000000..5ab26cc --- /dev/null +++ b/man/find_min_feasible.Rd @@ -0,0 +1,93 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/find_min_feasible.R +\name{find_min_feasible} +\alias{find_min_feasible} +\title{Smallest feasible parameter value via bisection (monotone threshold search)} +\usage{ +find_min_feasible( + evaluate, + x_max, + lower = NULL, + upper = NULL, + tol = 1, + levels = NULL, + wobble = 1L, + split_jitter = 0, + volume_column = "overflow_volume_m3", + verbose = FALSE +) +} +\arguments{ +\item{evaluate}{\verb{function(value)} returning a list / one-row data.frame +with at least \code{n_overflows}; if it also contains \code{volume_column}, the +volume referee is active. Evaluations are memoised per value.} + +\item{x_max}{Feasibility target: feasible iff \code{n_overflows <= x_max}.} + +\item{lower, upper}{Numeric search bounds (continuous mode).} + +\item{tol}{Resolution of the continuous search (same unit as the value).} + +\item{levels}{Sorted numeric vector of discrete candidate values +(discrete mode, e.g. Sickerbox stack heights). If given, \code{lower}, +\code{upper} and \code{tol} are ignored and a binary search over the levels is +performed.} + +\item{wobble}{Maximum counting-artefact size tolerated by the edge guard +(default 1, matching the observed +1 flips).} + +\item{split_jitter}{Numeric in \verb{[0, 0.45]}, default 0. With 0 the +interval is split exactly in half (deterministic). A positive value +draws the split fraction uniformly from \code{0.5 +- split_jitter} -- +a Monte-Carlo of the \emph{search path}: repeated runs with different +seeds take different routes to the threshold and must agree within +\code{tol} if the result is a property of the problem, not of the path.} + +\item{volume_column}{Name of the volume element in the \code{evaluate} result +used by the volume referee (default \code{"overflow_volume_m3"}).} + +\item{verbose}{Print one line per evaluation.} +} +\value{ +List with +\describe{ +\item{value}{smallest feasible value, or \code{NA} if infeasible} +\item{n_overflows}{overflow count at \code{value}} +\item{status}{\code{"ok"}, \code{"at_lower_bound"} (already feasible at the lower +end -- caller may widen the bracket) or \code{"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}{\code{TRUE} if the volume referee fired} +} +} +\description{ +Core building block of the swale-design optimiser: finds the smallest +value of one design parameter for which the overflow target is met +(\code{n_overflows <= x_max}), assuming quasi-monotone feasibility (larger +value = never more overflows; verified for the RAINDROP model in the +monotonicity analysis, +\url{https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/}). +Each evaluation halves the search interval, so +\code{ceiling(log2(range / tol))} evaluations suffice. +} +\details{ +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 \code{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 \code{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 +\code{monotonicity_violation} is set (real non-monotonicity -- never +observed at the three validation sites). +} +} +\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 + +} diff --git a/man/get_simulation_results_optim.Rd b/man/get_simulation_results_optim.Rd index 1c7d586..1a1c68b 100644 --- a/man/get_simulation_results_optim.Rd +++ b/man/get_simulation_results_optim.Rd @@ -4,7 +4,13 @@ \alias{get_simulation_results_optim} \title{Read Raindrop optimisation simulation results from HDF5} \usage{ -get_simulation_results_optim(paths, path_list, simulation_names, debug = TRUE) +get_simulation_results_optim( + paths, + path_list, + simulation_names, + debug = TRUE, + lean = FALSE +) } \arguments{ \item{paths}{A list of path definitions. Used for messaging and expected to @@ -20,6 +26,14 @@ run-specific paths (must yield \code{path_results_hdf5_element}, (e.g. \code{c("s00001", "s00002")}).} \item{debug}{print debug messages (default: TRUE)} + +\item{lean}{Logical. If \code{TRUE}, read only the fields consumed by +\code{\link{add_overflow_events_and_waterbalance}} -- \code{element$rates}, +\code{element$water_balance} and \code{connected_area$water_balance} -- and +leave \code{meta}/\code{states} (both sides) and \code{connected_area$rates} +as \code{NULL}. This keeps per-run memory and I/O minimal when each run is +thinned to its optimisation row immediately instead of collecting every +run's full results first. Defaults to \code{FALSE} (read everything).} } \value{ A named list with one entry per \code{simulation_names}. Each entry is diff --git a/man/make_swale_runner.Rd b/man/make_swale_runner.Rd new file mode 100644 index 0000000..b42c579 --- /dev/null +++ b/man/make_swale_runner.Rd @@ -0,0 +1,97 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/make_swale_runner.R +\name{make_swale_runner} +\alias{make_swale_runner} +\title{Create a site-specific single-scenario runner for the optimiser} +\usage{ +make_swale_runner( + path_list, + timestep_hours = 0.1, + timeseries_rain = NULL, + timeseries_et = NULL, + storage_types = default_storage_types(), + event_separation_hours = 4, + scenario_prefix = "o", + cleanup = TRUE, + debug = FALSE +) +} +\arguments{ +\item{path_list}{Path definition list as used by the workflow vignettes +(resolvable with \code{kwb.utils::resolve()}, must contain \code{path_base}, +\code{path_exe}, \code{dir_input}, \code{dir_output}, \code{dir_target_output}, +\code{path_target_input}, \code{path_results_hdf5_element}, +\code{path_results_hdf5_flaeche}, \code{file_target}).} + +\item{timestep_hours}{Engine time step in hours (default 0.1).} + +\item{timeseries_rain}{Optional data.frame \code{time}/\code{value} (mm/h) written +to \verb{//Kurven/Regen} (the dataset must exist in \code{base.h5}); when +given, the \verb{//Kurven/Growth_1} and \verb{//Kurven/Shading_1} end times +are extended to the rain series end (skipped for templates without +these curves) and \code{rain_factor} is ignored. Without +\code{timeseries_rain}, a per-run \code{rain_factor != 1} requires +\verb{//Kurven/Regen} to exist as a time series in \code{base.h5} -- a clear +error is thrown otherwise.} + +\item{timeseries_et}{Optional data.frame \code{time}/\code{value} (mm/h) written +to \verb{//Kurven/ET0}.} + +\item{storage_types}{Soil presets of the storage layer per storage type, +see \code{\link[=default_storage_types]{default_storage_types()}}.} + +\item{event_separation_hours}{Event separation for overflow counting +(default 4, as in the vignettes and the monotonicity analysis).} + +\item{scenario_prefix}{Prefix for generated scenario names (default +\code{"o"} -> \code{o00001}, \code{o00002}, ... -- distinct from the grid runs +\code{s00001} ...).} + +\item{cleanup}{Delete each scenario's copied input file and output +directory right after the thinned one-row result has been read +(default \code{TRUE}). The optimisers only need that row; without the +cleanup an optimisation run (hundreds of engine runs per task, each +with its own copy of \code{base.h5} plus all output HDF5s) fills the +temp drive and the engine aborts with HDF5 \code{errno = 28} ("No space +left on device"). Set \code{FALSE} to keep all scenario files for +debugging. Files of a \emph{failed} run are always kept.} + +\item{debug}{Passed on to the engine/reader helpers.} +} +\value{ +\verb{function(params)} where \code{params} is a named list (or one-row +data.frame) with \code{mulde_area}, \code{mulde_height} (mm), \code{storage_type}, +\code{storage_height} (mm), \code{connected_area} (m2), \code{filter_height} (mm), +\code{filter_hydraulicconductivity} (mm/h), \code{bottom_hydraulicconductivity} +(mm/h) and optionally \code{rain_factor} (default 1) and \code{lai} +(default 3.9). It returns a one-row tibble with the parameters, the +scenario name and the optimisation metrics (\code{n_overflows}, +\code{sum_overflows} in mm, \code{overflow_volume_m3}, water-balance shares). +} +\description{ +Factors the \code{run_one()} function that was duplicated across the three +workflow vignettes (Eisenstadt 2005, Wien, Bad Aussee) into one +package-level closure factory. The returned function runs the RAINDROP +engine for one parameter set and returns the thinned one-row +optimisation result (overflow events + water balance), augmented with +the input parameters and the overflow volume in m3. +} +\details{ +Site differences are covered by the arguments: Eisenstadt scales the +rain curve shipped in \code{base.h5} by \code{rain_factor} (leave +\code{timeseries_rain} = \code{NULL}), Wien and Bad Aussee replace the rain and +ET0 curves entirely (\code{timeseries_rain} / \code{timeseries_et}, values in +mm/h as written by the vignettes). + +On the first call the runner prepares a \strong{site master file} once: +\code{base.h5} plus everything identical for every run (calculation +settings, ET/rain time series). Each run then copies the master and +writes only its ~15 small parameter datasets. Compared to the +previous full read/rewrite of \emph{all} datasets per run this removes +the dominant per-run overhead of the optimisation searches +(hundreds of runs; for Wien / Bad Aussee it skips rewriting the +15-year rain series on every single engine run). +} +\seealso{ +\code{\link[=optimise_swale_design]{optimise_swale_design()}}, \code{\link[=find_min_feasible]{find_min_feasible()}} +} diff --git a/man/optimise_swale_design.Rd b/man/optimise_swale_design.Rd new file mode 100644 index 0000000..c181baf --- /dev/null +++ b/man/optimise_swale_design.Rd @@ -0,0 +1,117 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/optimise_swale_design.R +\name{optimise_swale_design} +\alias{optimise_swale_design} +\title{Find the cost-optimal swale design per overflow target} +\usage{ +optimise_swale_design( + run_fn, + x_targets = 0:5, + area_bounds = c(25, 200), + area_tol = 2, + height_bounds = c(100, 300), + height_tol = 10, + storage_spec = default_storage_spec(), + fixed = list(connected_area = 1000, filter_height = 300, filter_hydraulicconductivity = + 360, bottom_hydraulicconductivity = 12), + prior_results = NULL, + split_jitter = 0, + max_total_depth = NULL, + cost_rates = default_cost_rates(), + verbose = TRUE +) +} +\arguments{ +\item{run_fn}{\verb{function(params)} running one scenario and returning at +least \code{n_overflows} plus \code{sum_overflows} (mm) or \code{overflow_volume_m3}; +typically created with \code{\link[=make_swale_runner]{make_swale_runner()}}. \code{params} is a named list +of \code{mulde_area}, \code{mulde_height}, \code{storage_type}, \code{storage_height} plus +everything in \code{fixed}.} + +\item{x_targets}{Integer vector of overflow targets (feasible :<=> +\code{n_overflows <= x}), default \code{0:5}.} + +\item{area_bounds, area_tol}{Search range (m2) and resolution for +\code{mulde_area}. The found area sits up to one \code{area_tol} above the +exact feasibility boundary (worst-case cost overshoot roughly +\code{area_tol} x specific cost per m2, i.e. a few percent at the +default 2 m2); thanks to the bisection, every \emph{halving} of +\code{area_tol} costs only one additional engine run per area search -- +the cheapest precision lever of this optimiser. It also shrinks +the tolerance-artefact part of the final \code{mulde_height} trim (the +values just below the height maximum at low \code{x_targets}).} + +\item{height_bounds, height_tol}{Search range (mm) and resolution for +\code{mulde_height}.} + +\item{storage_spec}{Storage search space per type, see +\code{\link[=default_storage_spec]{default_storage_spec()}}: discrete \code{levels} (infiltration box) or +continuous \code{bounds} + \code{tol} (gravel trench).} + +\item{fixed}{Named list of parameters passed unchanged to \code{run_fn} +(connected area, filter geometry, kf at maximum, ...). Must contain +\code{filter_height} for the cost model.} + +\item{prior_results}{Optional data.frame with prior (grid) results in +the workflow CSV schema, used as warm start (narrows the first area +bracket to one grid step).} + +\item{split_jitter}{Passed to \code{\link[=find_min_feasible]{find_min_feasible()}}: 0 (default) = +deterministic halving; > 0 randomises every bisection split point +(Monte-Carlo of the search path -- repeated runs with different +seeds must agree within the search tolerances).} + +\item{max_total_depth}{Optional analytic depth constraint in mm: +\code{mulde_height + filter_height + storage_height <= max_total_depth} +(e.g. from DWA-A 138 groundwater clearance or cover requirements). +Enforced without any simulation runs.} + +\item{cost_rates}{Unit costs, see \code{\link[=default_cost_rates]{default_cost_rates()}}.} + +\item{verbose}{Print one progress line per solved cell.} +} +\value{ +Tibble with one row per (storage type, x): the optimal design +(\code{mulde_area}, \code{mulde_height}, \code{storage_height}), its metrics +(\code{n_overflows}, \code{overflow_volume_m3}, \code{et_pct}), cost columns from +\code{\link[=compute_costs]{compute_costs()}}, a \code{status} (\code{"ok"} or \code{"infeasible_within_bounds"}), +\code{monotonicity_warning} (volume referee) and \code{n_runs_new} (fresh engine +runs spent on this cell). All evaluated designs are attached as +attribute \code{"evaluations"}. +} +\description{ +Coordinate-descent optimiser built from a single primitive +(\code{\link[=find_min_feasible]{find_min_feasible()}}, bisection over one parameter): shrink the +expensive lever first (\code{mulde_area}), then the cheap one +(\code{mulde_height}); the storage layer starts at its smallest level and is +only escalated when the area is stuck at its upper bound. The filter +conductivity is expected to be fixed at the maximum via \code{fixed} (it is +cost-free and dominant, see the monotonicity analysis, +\url{https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/}). +Every engine run is cached, so the sweep over all \code{x_targets} and both +storage types shares evaluations. +} +\details{ +\strong{The search order is derived from \code{cost_rates}} via a +specific-cost proxy (EUR per mm of storage capacity, capacity model +\code{V ~ area * (mulde_height + porosity * storage_height)}; the layer +porosity comes from \code{storage_spec}, see \code{\link[=default_storage_spec]{default_storage_spec()}}): +maximising \code{mulde_height} first is optimal for \emph{any} rates under +this cost model (it costs only excavation, while area pays every +component), and the starting storage level is chosen as the +cheapest level per mm of capacity -- the smallest level under the +default rates, a high level when e.g. the storage material is cheap. +Without a \code{porosity} entry in \code{storage_spec} the legacy order +(smallest level first) is used. The proxy is a first-order +heuristic: it assumes capacity-additive levers and cannot rank +parameters with nonlinear hydraulic effects (e.g. a variable filter +conductivity) -- for those, and as the assumption-free cross-check, +use \code{\link[=optimise_swale_design_simultaneous]{optimise_swale_design_simultaneous()}}, which carries +\code{cost_rates} directly inside its objective. +} +\seealso{ +\code{\link[=optimise_swale_design_simultaneous]{optimise_swale_design_simultaneous()}} (alternative: all +parameters at once via penalised Nelder-Mead, as an independent +cross-check of the coordinate descent), \code{\link[=find_min_feasible]{find_min_feasible()}}, +\code{\link[=make_swale_runner]{make_swale_runner()}}, \code{\link[=default_storage_spec]{default_storage_spec()}} +} diff --git a/man/optimise_swale_design_simultaneous.Rd b/man/optimise_swale_design_simultaneous.Rd new file mode 100644 index 0000000..6e80ae4 --- /dev/null +++ b/man/optimise_swale_design_simultaneous.Rd @@ -0,0 +1,203 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/optimise_swale_design_simultaneous.R +\name{optimise_swale_design_simultaneous} +\alias{optimise_swale_design_simultaneous} +\title{Find the cost-optimal swale design by simultaneous parameter search} +\usage{ +optimise_swale_design_simultaneous( + run_fn, + x_targets = 0:5, + area_bounds = c(25, 200), + area_tol = 2, + height_bounds = c(100, 300), + height_tol = 10, + storage_spec = default_storage_spec(), + fixed = list(connected_area = 1000, filter_height = 300, filter_hydraulicconductivity = + 360, bottom_hydraulicconductivity = 12), + prior_results = NULL, + method = c("nelder_mead", "diff_evolution", "halton_search"), + n_starts = 4, + max_evals = 80, + seed = 1, + wobble = 1L, + max_total_depth = NULL, + cost_rates = default_cost_rates(), + verbose = TRUE +) +} +\arguments{ +\item{run_fn}{\verb{function(params)} running one scenario and returning at +least \code{n_overflows} plus \code{sum_overflows} (mm) or \code{overflow_volume_m3}; +typically created with \code{\link[=make_swale_runner]{make_swale_runner()}}. \code{params} is a named list +of \code{mulde_area}, \code{mulde_height}, \code{storage_type}, \code{storage_height} plus +everything in \code{fixed}.} + +\item{x_targets}{Integer vector of overflow targets (feasible :<=> +\code{n_overflows <= x}), default \code{0:5}.} + +\item{area_bounds, area_tol}{Search range (m2) and resolution for +\code{mulde_area}.} + +\item{height_bounds, height_tol}{Search range (mm) and resolution for +\code{mulde_height}.} + +\item{storage_spec}{Storage search space per type, see +\code{\link[=default_storage_spec]{default_storage_spec()}}: discrete \code{levels} (infiltration box) or +continuous \code{bounds} + \code{tol} (gravel trench).} + +\item{fixed}{Named list of parameters passed unchanged to \code{run_fn} +(connected area, filter geometry, kf at maximum, ...). Must contain +\code{filter_height} for the cost model.} + +\item{prior_results}{Optional data.frame with prior (grid) results in +the workflow CSV schema, used as warm start (the cheapest feasible +grid cell of the branch becomes the first start / seeds the +population).} + +\item{method}{Search method, see Details: \code{"nelder_mead"} (default), +\code{"diff_evolution"} or \code{"halton_search"} (the latter two mainly for +comparison).} + +\item{n_starts}{Number of Nelder-Mead starts per (storage type, x) +cell (default 4; only used by \code{method = "nelder_mead"}). Warm starts +(prior, previous target) count towards this number, then the +storage-ladder anchors, then the space-filling points.} + +\item{max_evals}{Soft cap on fresh engine runs per cell for the search +phase: once reached, the search winds down (already cached designs +remain free). The final multi-valley lattice polish adds its own +runs on top (typically 20-50 per cell). Default 80 -- thanks to the +shared cache the later \code{x_targets} of a storage type stay cheaper.} + +\item{seed}{Integer seed of the internal deterministic generator used +by \code{method = "diff_evolution"} (ignored by the other methods). R's +global RNG state is not touched.} + +\item{wobble}{Maximum counting-artefact size tolerated at the upper +corner (default 1, matching the +1 event-counting wobble): only if +the maximal design overflows by more than \code{wobble} events is the +cell declared infeasible without a search.} + +\item{max_total_depth}{Optional analytic depth constraint in mm: +\code{mulde_height + filter_height + storage_height <= max_total_depth} +(e.g. from DWA-A 138 groundwater clearance or cover requirements). +Enforced without any simulation runs.} + +\item{cost_rates}{Unit costs, see \code{\link[=default_cost_rates]{default_cost_rates()}}.} + +\item{verbose}{Print one progress line per solved cell.} +} +\value{ +Tibble with one row per (storage type, x), same schema as +\code{\link[=optimise_swale_design]{optimise_swale_design()}} plus a \code{method} column: the optimal design +(\code{mulde_area}, \code{mulde_height}, \code{storage_height}), its metrics +(\code{n_overflows}, \code{overflow_volume_m3}, \code{et_pct}), cost columns from +\code{\link[=compute_costs]{compute_costs()}}, a \code{status} (\code{"ok"} or +\code{"infeasible_within_bounds"}), \code{monotonicity_warning} (\code{TRUE} if a +strictly larger design produced more overflows \emph{and} more overflow +volume among the cell's evaluations) and \code{n_runs_new} (fresh engine +runs spent on this cell). All evaluated designs are attached as +attribute \code{"evaluations"}. +} +\description{ +Alternative to the coordinate-descent optimiser +(\code{\link[=optimise_swale_design]{optimise_swale_design()}}, bisection per parameter): all design +parameters -- \code{mulde_area}, \code{mulde_height} and \code{storage_height} -- are +optimised \strong{simultaneously}. Infeasible designs (\code{n_overflows > x}) +are not excluded but penalised (any infeasible design is worse than any +feasible one; the number of excess events grades the penalty, steering +the search back towards feasibility), so the search moves freely +through the full parameter space and can trade the parameters against +each other in a single step -- it does not rely on the per-parameter +monotonicity that the bisection exploits. +} +\details{ +Three search \code{method}s share this penalised objective (plus cache, +tolerance snapping and final lattice polish) and differ only in how +they propose candidates: +\itemize{ +\item \code{"nelder_mead"} (default): multistart Nelder-Mead simplex via +\code{stats::optim()} -- the recommended method. +\item \code{"diff_evolution"}: a compact differential evolution +(DE/rand/1/bin, population 12, F = 0.7, CR = 0.9), included for +comparison. Deterministic: it draws from an internal Park-Miller +generator seeded with \code{seed} and leaves R's global RNG +(\code{.Random.seed}) untouched. +\item \code{"halton_search"}: quasi-random space-filling sampling +(Halton sequence, bases 2/3/5) -- a deliberately simple baseline +showing what the structured searches must beat. +} + +Three ingredients keep the number of engine runs in check: +\itemize{ +\item \strong{Snapping}: every candidate is snapped to the search +tolerances (\code{area_tol}, \code{height_tol}, storage \code{tol} / discrete +\code{levels}) before evaluation, so the shared cache absorbs repeated +visits and the sweep over all \code{x_targets} reuses runs. +\item \strong{Multistart}: \code{n_starts} deterministic starting points +(prior warm start and the optimum of the previous overflow target +first, then a \emph{storage ladder} -- one anchor start per storage +level, smallest level first -- then fixed space-filling points) +guard against the simplex stalling on the plateaus that the +snapping and the integer overflow count create, and make sure every +storage level competes: along the feasibility boundary the cost +valley is flat, so the cheapest (usually smallest) storage level is +easily missed from a single start. Different starts take different +search paths -- the counterpart of \code{split_jitter} in the bisection +optimiser. Every start receives an equal slice of the remaining +\code{max_evals} budget (unused runs roll over). +\item \strong{Lattice polish}: an accelerated pattern descent +(steps of 8/4/2/1 tolerances downwards, cheaper by construction) +runs from the cheapest feasible design of every storage level +visited -- capped at the 6 cheapest levels, which only bites for +the continuous gravel trench (the discrete box has at most a +handful) -- because the storage axis separates cost valleys that +single coordinate steps cannot cross. Besides the per-axis down +steps each round proposes a \emph{boundary slide} (area down with +\code{mulde_height} at its maximum -- the two-coordinate trade towards +the cheap end of the feasibility boundary) and a +\emph{mulde_height floor probe} (at large \code{x} the overflow count +saturates, so the whole lower height range can be feasible even +when a +1 counting wobble blocks every single step). All are just +evaluated candidates -- no monotonicity assumption enters. The +result is locally optimal on the tolerance lattice, whatever the +search method delivered. +} + +The discrete infiltration-box levels are mapped onto a continuous +latent axis (each level owns an equal share of \verb{[0, 1]}), the gravel +trench is searched continuously. The filter conductivity is expected to +be fixed at the maximum via \code{fixed} (cost-free and dominant, see the +monotonicity analysis, +\url{https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/}). +\code{max_total_depth} is enforced by +construction (the \code{mulde_height} axis is compressed to the remaining +depth), so no simulation runs are spent on depth-invalid designs. + +Compared to \code{\link[=optimise_swale_design]{optimise_swale_design()}} this needs considerably more +engine runs per cell (typically 60-120 instead of ~15; search phase +plus multi-valley polish) but serves as an independent cross-check: it +can discover cheaper corners of the design space that coordinate +descent would miss if the parameter interaction were stronger than the +monotonicity analysis suggests. +} +\examples{ +# synthetic monotone model: overflows fall with retention capacity +run <- function(params) { + cap <- params$mulde_area * + (params$mulde_height + 0.95 * params$storage_height) + list(n_overflows = max(0, floor(3.6e5 / cap) - 3), + sum_overflows = 800 * max(0, 3.6e5 / cap - 3)) +} +opt <- optimise_swale_design_simultaneous( + run, x_targets = 1, + storage_spec = default_storage_spec()["infiltration_box"], + verbose = FALSE +) +opt[, c("x", "mulde_area", "mulde_height", "storage_height", "cost_total")] + +} +\seealso{ +\code{\link[=optimise_swale_design]{optimise_swale_design()}} (coordinate descent / bisection), +\code{\link[=make_swale_runner]{make_swale_runner()}}, \code{\link[=default_storage_spec]{default_storage_spec()}} +} diff --git a/man/plot_cost_overflow_boxplot.Rd b/man/plot_cost_overflow_boxplot.Rd new file mode 100644 index 0000000..8432989 --- /dev/null +++ b/man/plot_cost_overflow_boxplot.Rd @@ -0,0 +1,195 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot_cost_overflow_boxplot.R +\name{plot_cost_overflow_boxplot} +\alias{plot_cost_overflow_boxplot} +\title{Cost boxplot per overflow-event count, points sized by overflow volume} +\usage{ +plot_cost_overflow_boxplot( + simulation_results_optimisation, + param_grid, + x = 5, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.2, + jitter_seed = 1L, + max_point_size = 6, + box_alpha = 0.35, + point_alpha = 0.6, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + size_by = c("overflow_volume", "evapotranspiration"), + best_by = c("min_cost", "min_overflow", "max_evapotranspiration"), + y_var = c("cost_total", "cost_per_evap_pct"), + facet_storage_type = FALSE, + label_best = FALSE, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + lab_size = NULL, + mark_best = TRUE, + connect_best = TRUE, + legend_position = "right" +) +} +\arguments{ +\item{simulation_results_optimisation}{Data frame with the columns +\code{scenario_name}, \code{n_overflows}, \code{sum_overflows}, \code{mulde_area}, +\code{element.WB_Evapotranspiration_}, \code{element.WB_InfiltrationNetto_}, +\code{element.WB_Oberflaechenablauf_Ueberlauf_}, \code{cost_excavation}, +\code{cost_profiling}, \code{cost_filter}, \code{cost_storage}, \code{cost_total}, +\code{storage_type}. Typically the joined output of +\code{\link[=add_overflow_events_and_waterbalance]{add_overflow_events_and_waterbalance()}} and \code{\link[=compute_costs]{compute_costs()}}.} + +\item{param_grid}{Data frame with parameter grid. Must contain +\code{scenario_name}.} + +\item{x}{Numeric threshold. Counts \verb{0..x} each get their own box; counts +\verb{> x} collapse into a single \code{">x"} box.} + +\item{filter_n_gtx}{Logical. If \code{TRUE}, scenarios with \code{n_overflows > x} +are dropped (removing the \code{">x"} box) before plotting.} + +\item{use_jitter}{Logical. If \code{TRUE}, points are horizontally jittered.} + +\item{jitter_width}{Numeric. Horizontal jitter half-width.} + +\item{jitter_seed}{Integer. Seed for reproducible jitter.} + +\item{max_point_size}{Numeric. Point size for the largest (valid-region) +\code{size_by} value; the smallest maps to a fixed minimum so no point vanishes.} + +\item{box_alpha, point_alpha}{Numeric in \verb{[0, 1]}. Box-fill / point opacity.} + +\item{digits}{Integer. Rounding for numeric values in the tooltip.} + +\item{digits_params}{Integer. Rounding for parameter values in the +tooltip.} + +\item{lang}{Character. Plot language: \code{"de"} or \code{"en"}.} + +\item{param_labels}{Named character vector translating \code{param_grid} columns +to tooltip labels, or \code{NULL} to use \code{\link[=default_param_labels]{default_param_labels()}} for \code{lang}.} + +\item{size_by}{Character. Which variable drives the point area (and its +legend): \code{"overflow_volume"} (default, m3) or \code{"evapotranspiration"} (the +element evapotranspiration share in \%, from \code{element.WB_Evapotranspiration_} +-- larger points then mean \emph{more} evapotranspiration, which is desirable).} + +\item{best_by}{Character. Objective for the highlighted best scenario per +box, with cost as the tie-breaker: \code{"min_cost"} (default; cheapest, ties +broken by \code{scenario_name}), \code{"min_overflow"} (smallest overflow volume) or +\code{"max_evapotranspiration"} (highest evapotranspiration). In the \code{">x"} box +the fewest-overflow scenario is picked first, \code{best_by} then breaking ties. +"Cost" always refers to the active \code{y_var}, so with +\code{y_var = "cost_per_evap_pct"} the \code{"min_cost"} objective picks the +scenario with the lowest cost per percentage point of evapotranspiration.} + +\item{y_var}{Character. Which cost measure the y-axis (boxes, points, best +markers, frontier) shows: \code{"cost_total"} (default; total construction +cost, EUR) or \code{"cost_per_evap_pct"} (total cost divided by the element +evapotranspiration share \strong{above the reference minimum}, EUR per +percentage point -- the marginal cost efficiency of evapotranspiration; +the baseline comes "for free"). The reference is the \strong{lowest +evapotranspiration among the scenarios that satisfy the validity +criterion} (\code{n_overflows <= x}; fallback: the complete run when none +does) and is named -- share, criterion and scenario id -- on a second +title line. Scenarios at or below the reference (including the reference +scenario itself) have no defined marginal cost and are dropped from the +\code{"cost_per_evap_pct"} variant; \code{label_best = TRUE} additionally +annotates the evapotranspiration gain (\code{"(+NN \% Evapotranspiration)"}) +after the price. Titles and the y-axis label switch accordingly.} + +\item{facet_storage_type}{Logical. If \code{TRUE}, the plot is split by +\code{storage_type} into two stacked panels (infiltration box on top, gravel +trench below, via \code{ggplot2::facet_grid()}), each with its own boxes, +best-per-box markers and frontier line; the overlaid points then stay +plain circles (the strips already name the type). +\code{plotly::ggplotly()} keeps the panel split as stacked subplots. +Default \code{FALSE}.} + +\item{label_best}{Logical. If \code{TRUE}, the best scenario per box is annotated +next to it: overflow volume plus overflow share (\code{"NN m3 / NN \%"}) for +\code{min_overflow}, the evapotranspiration share (\code{"NN \%"}) for +\code{max_evapotranspiration}, or the active \code{y_var} value for \code{min_cost} -- +the total cost (\code{"NN EUR"}) by default, the cost per percentage point of +evapotranspiration (\code{"NN EUR/\%"}) with \code{y_var = "cost_per_evap_pct"}. +Default \code{FALSE}.} + +\item{title, lab_x, lab_y}{Optional character overrides for the default +language-specific title / axis labels.} + +\item{caption}{Character or \code{NULL}. Caption below the plot naming the +unit-cost rates the EUR values were computed with. \code{NULL} (default) +uses \code{\link[=cost_rates_caption]{cost_rates_caption()}} with the \code{\link[=default_cost_rates]{default_cost_rates()}}; pass your +own string if the costs were computed with different rates, or \code{""} to +drop the caption. Note that \code{plotly::ggplotly()} drops ggplot captions +-- re-add it to the interactive version via \code{\link[=plotly_add_caption]{plotly_add_caption()}}.} + +\item{lab_size}{Optional character override for the size-legend title.} + +\item{mark_best}{Logical. If \code{TRUE} (default), the best scenario per box +(see \code{best_by}) is highlighted with a black-outlined diamond filled in +that box's group colour, so its plotly tooltip inherits the group colour.} + +\item{connect_best}{Logical. If \code{TRUE} (default), the highlighted best +scenarios of \strong{all} boxes (overflow counts \verb{0..x} plus the \code{">x"} +catch-all) are connected by a line -- the best-per-overflow-level frontier.} + +\item{legend_position}{Character. Legend position, default \code{"right"}.} +} +\value{ +A \code{ggplot} object. Convert to interactive via +\code{plotly::ggplotly(p, tooltip = "text")}. +} +\description{ +Companion to \code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}}. For every number of overflow +events (x-axis) it draws a boxplot of the total construction cost (y-axis, +EUR) across all scenarios with that count, overlaid with the individual +scenarios as jittered points whose \strong{size scales with \code{size_by}} -- +the overflow volume (\code{m3}, \code{sum_overflows} (\code{mm}) * \code{mulde_area} (\code{m2}) / +1000; the default) or the element evapotranspiration share (\%). One best +scenario per box is highlighted; \code{best_by} selects its objective -- cheapest, +smallest overflow volume, or highest evapotranspiration (cost as +tie-breaker) -- so the three variants trace three different frontier lines. +\code{label_best} annotates the marker. +} +\details{ +Overflow counts greater than \code{x} are collapsed into a single \code{">x"} +catch-all box (furthest right, coloured red), keeping the axis readable for +the long-tailed 15-year runs (Wien / Bad Aussee reach several hundred +overflow events). Its highlighted scenario is the one with the \strong{fewest} +overflow events above \code{x} (closest to the valid region). Set \code{x} high to +resolve more counts individually, or to \code{max(n_overflows)} to give every +count its own box. + +The point tooltip is \strong{identical} to \code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}}: +scenario, overflow count / sum (\code{mm}) / volume (\code{m3}), the element water +balance (evapotranspiration / infiltration / overflow, \%), the cost breakdown +(EUR) +and the varying \code{param_grid} parameters translated via \code{param_labels}. +Points and boxes are coloured with the same green (low counts) to red +(\code{">x"}) palette as the sibling plots; because the colour merely echoes the +x-axis it carries no separate legend. When both storage types share one +panel, the overlaid points are additionally \strong{shaped by the storage type} +(filled square = infiltration box / Sickerbox, filled triangle = gravel +trench / Schotterrigol), matching the scatter siblings, and a storage-type +legend is shown next to the point-size legend. With \code{facet_storage_type = TRUE} the plot splits into two stacked storage-type panels instead; the +facet strips then carry that information and the points stay \strong{plain +circles} for readability. \code{y_var = "cost_per_evap_pct"} switches the +y-axis to the cost per percentage point of evapotranspiration (EUR/\%). + +The point-size scale is calibrated to the valid region (\verb{0..x}): the extreme +overflow volumes of the \code{">x"} catch-all are capped and a minimum size keeps +even zero-volume points (the \code{0}-overflow box) visible, so the many-overflow +outliers no longer shrink every valid-region point to an invisible dot. +When the storage-type shapes are in use (no faceting), its legend keys are +drawn with the storage-type marker (grey; the single present shape, or the +square when both types are shown) instead of the default circle; the +faceted variant uses circular points and matching circular keys. +} +\seealso{ +\code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}} +} diff --git a/man/plot_cost_vs_evaporation.Rd b/man/plot_cost_vs_evaporation.Rd new file mode 100644 index 0000000..9296a30 --- /dev/null +++ b/man/plot_cost_vs_evaporation.Rd @@ -0,0 +1,104 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot_cost_vs_evaporation.R +\name{plot_cost_vs_evaporation} +\alias{plot_cost_vs_evaporation} +\title{Cost vs. evapotranspiration scatter with storage-type shapes} +\usage{ +plot_cost_vs_evaporation( + simulation_results_optimisation, + param_grid, + x = 1, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.15, + jitter_height = 0.15, + jitter_seed = 1L, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + legend_position = "top" +) +} +\arguments{ +\item{simulation_results_optimisation}{Data frame with the columns +\code{scenario_name}, \code{n_overflows}, \code{sum_overflows}, \code{mulde_area}, +\code{element.WB_Evapotranspiration_}, \code{element.WB_InfiltrationNetto_}, +\code{element.WB_Oberflaechenablauf_Ueberlauf_}, \code{cost_excavation}, +\code{cost_profiling}, \code{cost_filter}, \code{cost_storage}, \code{cost_total}, +\code{storage_type}. Typically the joined output of +\code{\link[=add_overflow_events_and_waterbalance]{add_overflow_events_and_waterbalance()}} and \code{\link[=compute_costs]{compute_costs()}}.} + +\item{param_grid}{Data frame with parameter grid. Must contain +\code{scenario_name}.} + +\item{x}{Numeric threshold for the overflow-count colour bucket. Values +greater than \code{x} are pushed into the red \code{">x"} category.} + +\item{filter_n_gtx}{Logical. If \code{TRUE}, scenarios with \code{n_overflows > x} +are dropped before plotting.} + +\item{use_jitter, jitter_width, jitter_height, jitter_seed}{As in +\code{\link[=plot_wb_tradeoff_overflows]{plot_wb_tradeoff_overflows()}}.} + +\item{digits}{Integer. Rounding for numeric values in the tooltip.} + +\item{digits_params}{Integer. Rounding for parameter values in the +tooltip.} + +\item{lang}{Character. Plot language: \code{"de"} or \code{"en"}.} + +\item{param_labels}{Named character vector translating \code{param_grid} columns +to tooltip labels, or \code{NULL} to use \code{\link[=default_param_labels]{default_param_labels()}} for \code{lang}.} + +\item{title, lab_x, lab_y}{Optional character overrides for the default +language-specific title / axis labels.} + +\item{caption}{Character or \code{NULL}. Caption below the plot naming the +unit-cost rates the EUR values were computed with. \code{NULL} (default) +uses \code{\link[=cost_rates_caption]{cost_rates_caption()}} with the \code{\link[=default_cost_rates]{default_cost_rates()}}; pass your +own string if the costs were computed with different rates, or \code{""} to +drop the caption. Note that \code{plotly::ggplotly()} drops ggplot captions +-- re-add it to the interactive version via \code{\link[=plotly_add_caption]{plotly_add_caption()}}.} + +\item{legend_position}{Character. Legend position, default \code{"top"}.} +} +\value{ +A \code{ggplot} object. Convert to interactive via +\code{plotly::ggplotly(p, tooltip = "text")}. +} +\description{ +Second companion to \code{\link{plot_cost_vs_overflow_volume}} for +cost-aware optimisation. Plots the per-scenario \strong{total construction cost} +(EUR) on the x-axis against the element \strong{evapotranspiration share} (\% of +the total water input, from \code{element.WB_Evapotranspiration_}) on the +y-axis. Points are coloured discretely by the \strong{number} of overflow events +(same 0..x / >x palette used by the sibling plots, legend at the top) and +\strong{shaped by the storage type}: filled square = infiltration box +(Sickerbox), filled triangle = gravel trench (Schotterrigol). +} +\details{ +The tooltip is identical to \code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}}: scenario, +overflow count / sum (mm) / volume (m3), the element water balance +(\code{element.WB_Evapotranspiration_}, \code{element.WB_InfiltrationNetto_}, +\code{element.WB_Oberflaechenablauf_Ueberlauf_}, all as \% of the total water +input), the storage type, the usable storage volume of the storage layer +(m3), the cost breakdown (\code{cost_excavation}, +\code{cost_profiling}, \code{cost_filter}, \code{cost_storage}, \code{cost_total}), the derived +\strong{cost per percentage point of evapotranspiration} (EUR/\%) plus the +varying parameters from \code{param_grid} (excluding \code{scenario_name}). + +The plot language can be switched via \code{lang = "de"} or \code{lang = "en"}. +Titles / axis labels / legend / tooltip labels follow the choice unless +explicit overrides are supplied. +} +\seealso{ +\code{\link[=plot_cost_vs_overflow_volume]{plot_cost_vs_overflow_volume()}} for cost vs. overflow volume and +\code{\link[=plot_cost_overflow_boxplot]{plot_cost_overflow_boxplot()}} for the boxplot views (including +\code{y_var = "cost_per_evap_pct"}, the cost per percentage point of +evapotranspiration). +} diff --git a/man/plot_cost_vs_overflow_volume.Rd b/man/plot_cost_vs_overflow_volume.Rd new file mode 100644 index 0000000..7599e76 --- /dev/null +++ b/man/plot_cost_vs_overflow_volume.Rd @@ -0,0 +1,109 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot_cost_vs_overflow_volume.R +\name{plot_cost_vs_overflow_volume} +\alias{plot_cost_vs_overflow_volume} +\title{Cost vs. overflow-volume scatter with n_overflows-coloured points} +\usage{ +plot_cost_vs_overflow_volume( + simulation_results_optimisation, + param_grid, + x = 1, + filter_n_gtx = FALSE, + use_jitter = TRUE, + jitter_width = 0.15, + jitter_height = 0.15, + jitter_seed = 1L, + digits = 2L, + digits_params = 4L, + lang = c("de", "en"), + param_labels = NULL, + title = NULL, + lab_x = NULL, + lab_y = NULL, + caption = NULL, + legend_position = "top" +) +} +\arguments{ +\item{simulation_results_optimisation}{Data frame with the columns +\code{scenario_name}, \code{n_overflows}, \code{sum_overflows}, \code{mulde_area}, +\code{element.WB_Evapotranspiration_}, \code{element.WB_InfiltrationNetto_}, +\code{element.WB_Oberflaechenablauf_Ueberlauf_}, \code{cost_excavation}, +\code{cost_profiling}, \code{cost_filter}, \code{cost_storage}, \code{cost_total}, +\code{storage_type}. Typically the joined output of +\code{\link[=add_overflow_events_and_waterbalance]{add_overflow_events_and_waterbalance()}} and \code{\link[=compute_costs]{compute_costs()}}.} + +\item{param_grid}{Data frame with parameter grid. Must contain +\code{scenario_name}.} + +\item{x}{Numeric threshold for the overflow-count colour bucket. Values +greater than \code{x} are pushed into the red \code{">x"} category.} + +\item{filter_n_gtx}{Logical. If \code{TRUE}, scenarios with \code{n_overflows > x} +are dropped before plotting.} + +\item{use_jitter, jitter_width, jitter_height, jitter_seed}{As in +\code{\link[=plot_wb_tradeoff_overflows]{plot_wb_tradeoff_overflows()}}.} + +\item{digits}{Integer. Rounding for numeric values in the tooltip.} + +\item{digits_params}{Integer. Rounding for parameter values in the +tooltip.} + +\item{lang}{Character. Plot language: \code{"de"} or \code{"en"}.} + +\item{param_labels}{Named character vector translating \code{param_grid} columns +to tooltip labels, or \code{NULL} to use \code{\link[=default_param_labels]{default_param_labels()}} for \code{lang}.} + +\item{title, lab_x, lab_y}{Optional character overrides for the default +language-specific title / axis labels.} + +\item{caption}{Character or \code{NULL}. Caption below the plot naming the +unit-cost rates the EUR values were computed with. \code{NULL} (default) +uses \code{\link[=cost_rates_caption]{cost_rates_caption()}} with the \code{\link[=default_cost_rates]{default_cost_rates()}}; pass your +own string if the costs were computed with different rates, or \code{""} to +drop the caption. Note that \code{plotly::ggplotly()} drops ggplot captions +-- re-add it to the interactive version via \code{\link[=plotly_add_caption]{plotly_add_caption()}}.} + +\item{legend_position}{Character. Legend position, default \code{"top"}.} +} +\value{ +A \code{ggplot} object. Convert to interactive via +\code{plotly::ggplotly(p, tooltip = "text")}. +} +\description{ +Companion to \code{\link{plot_wb_tradeoff_overflows}} for cost-aware +optimisation. Plots the per-scenario \strong{total construction cost} (EUR) on +the x-axis against the \strong{overflow volume} (m3) on the y-axis, with the +points coloured discretely by the \strong{number} of overflow events (same +0..x / >x palette used by \code{plot_wb_tradeoff_overflows}, legend at the top) +and \strong{shaped by the storage type}: filled square = infiltration box +(Sickerbox), filled triangle = gravel trench (Schotterrigol). +} +\details{ +Overflow volume is computed from \code{sum_overflows} (in mm on the swale +surface, as returned by \code{\link[=add_overflow_events_and_waterbalance]{add_overflow_events_and_waterbalance()}}) +multiplied by \code{mulde_area} (m2) and converted to m3: +\code{overflow_volume_m3 = sum_overflows * mulde_area / 1000}. + +The tooltip carries the element water balance +(\code{element.WB_Evapotranspiration_}, \code{element.WB_InfiltrationNetto_}, +\code{element.WB_Oberflaechenablauf_Ueberlauf_}, all as \% of the total water +input) and the cost breakdown (\code{cost_excavation}, \code{cost_profiling}, +\code{cost_filter}, \code{cost_storage}, \code{cost_total}), the derived \strong{cost per +percentage point of evapotranspiration} (EUR/\%), the \strong{usable storage +volume} of the storage layer (m3; area x height x usable porosity +\code{thetaS - thetaFC}, from a \code{storage_volume_m3} column or derived from the +\verb{storage_theta*} columns) plus the varying parameters from \code{param_grid} +(excluding \code{scenario_name}), so the user can hover over a scatter point +and see exactly why it landed where it did. + +The plot language can be switched via \code{lang = "de"} or \code{lang = "en"}. +Titles / axis labels / legend / tooltip labels follow the choice unless +explicit overrides are supplied. +} +\seealso{ +\code{\link[=plot_cost_overflow_boxplot]{plot_cost_overflow_boxplot()}} for the same data / tooltip shown as +a cost-by-overflow-count boxplot and \code{\link[=plot_cost_vs_evaporation]{plot_cost_vs_evaporation()}} for +cost vs. the element evapotranspiration share. +} diff --git a/man/plot_main_effects.Rd b/man/plot_main_effects.Rd index be88794..b0e8778 100644 --- a/man/plot_main_effects.Rd +++ b/man/plot_main_effects.Rd @@ -45,6 +45,11 @@ median outcome values across parameter levels. The function is intended for optimisation or sensitivity grids with many parameters, where a single 2D scatter plot is not informative. +Both numeric and character parameters are supported; a character parameter +such as \code{storage_type} gets its own facet panel (its levels are shown +as \code{Sickerbox} / \code{Schotterrigol} for \code{lang = "de"}, +\code{Infiltration box} / \code{Gravel trench} for \code{lang = "en"}). + The plot language can be switched via \code{lang = "de"} or \code{lang = "en"}. This affects the title, y-axis label, and selected parameter labels. diff --git a/man/plot_valid_design_space.Rd b/man/plot_valid_design_space.Rd index 98da154..f40f2e6 100644 --- a/man/plot_valid_design_space.Rd +++ b/man/plot_valid_design_space.Rd @@ -25,6 +25,7 @@ plot_valid_design_space( alpha_min = 0.2, alpha_max = 1, keep_param_grid_limits = TRUE, + facet_storage_type = FALSE, lang = c("de", "en"), title = NULL, subtitle = NULL, @@ -96,6 +97,17 @@ same x/y. Default \code{"none"}.} the full range or full set of levels found in \code{param_grid}, so the design-space axes do not shrink after filtering. Default \code{TRUE}.} +\item{facet_storage_type}{Logical. If \code{TRUE}, the design space is +split by \code{storage_type} into two stacked panels (infiltration box on +top, gravel trench below) with free y-scales, so disjoint per-type levels +(e.g. \code{storage_height}: 300-1200 mm boxes vs. 900-3600 mm trenches) +fill their own panel; duplicate counting for \code{alpha_mode = +"duplicates"} then happens per panel and the points stay plain circles +(the strips already name the type). Requires a \code{storage_type} +column in \code{param_grid}. Without faceting, points are shaped by the +storage type (filled square = infiltration box, filled triangle = gravel +trench) whenever \code{storage_type} varies. Default \code{FALSE}.} + \item{lang}{Character. Plot language: \code{"de"} or \code{"en"}.} \item{title}{Character or \code{NULL}. Plot title. If \code{NULL}, a diff --git a/man/plot_wb_tradeoff_overflows.Rd b/man/plot_wb_tradeoff_overflows.Rd index 5ef1d63..887ca09 100644 --- a/man/plot_wb_tradeoff_overflows.Rd +++ b/man/plot_wb_tradeoff_overflows.Rd @@ -90,5 +90,13 @@ The plot language can be switched via \code{lang = "de"} or tooltip labels unless custom labels are supplied explicitly. Tooltip text additionally includes all parameters from \code{param_grid} that -vary across scenarios, excluding \code{scenario_name}. +vary across scenarios, excluding \code{scenario_name} (translated via +\code{\link{default_param_labels}}; mixed numeric / character parameters +such as \code{storage_type} are supported). + +If \code{simulation_results_optimisation} carries a \code{storage_type} +column, the points are additionally \strong{shaped by the storage type} (filled +square = infiltration box / Sickerbox, filled triangle = gravel trench / +Schotterrigol, as in the cost plots) and the tooltip names the storage +type; older single-type result sets plot exactly as before. } diff --git a/man/plotly_add_caption.Rd b/man/plotly_add_caption.Rd new file mode 100644 index 0000000..d7424bd --- /dev/null +++ b/man/plotly_add_caption.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plotly_split_legend.R +\name{plotly_add_caption} +\alias{plotly_add_caption} +\title{Add a caption annotation to a ggplotly object} +\usage{ +plotly_add_caption(pl, caption, font_size = 10) +} +\arguments{ +\item{pl}{A plotly object as returned by \code{plotly::ggplotly()}.} + +\item{caption}{Character. The caption text; \code{NULL} or \code{""} returns \code{pl} +unchanged.} + +\item{font_size}{Numeric. Caption font size in px. Default 10.} +} +\value{ +The modified plotly object. +} +\description{ +\code{plotly::ggplotly()} drops ggplot captions (and subtitles). This helper +re-adds the caption as a small grey annotation below the plot area (bottom +left, under the x-axis title) and widens the bottom margin accordingly. +\verb{\\n} line breaks are converted to \verb{
}. +} +\details{ +Used by the vignettes together with \code{\link[=cost_rates_caption]{cost_rates_caption()}} so the +interactive cost plots name the unit-cost rates they were computed with. +} +\examples{ +\dontrun{ +pl <- plotly::ggplotly(p, tooltip = "text") +pl <- plotly_add_caption(pl, cost_rates_caption("de")) +} + +} diff --git a/man/plotly_split_legend.Rd b/man/plotly_split_legend.Rd new file mode 100644 index 0000000..6307cf1 --- /dev/null +++ b/man/plotly_split_legend.Rd @@ -0,0 +1,76 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plotly_split_legend.R +\name{plotly_split_legend} +\alias{plotly_split_legend} +\title{Split the combined (colour, shape) ggplotly legend into two clean legends} +\usage{ +plotly_split_legend( + pl, + lang = c("de", "en"), + colour_title = NULL, + shape_title = NULL, + add_shape_legend = TRUE +) +} +\arguments{ +\item{pl}{A plotly object as returned by +\code{plotly::ggplotly(p, tooltip = "text")}.} + +\item{lang}{Character. \code{"de"} or \code{"en"}; sets the default legend group +titles.} + +\item{colour_title}{Character or \code{NULL}. Title of the colour legend group. +Defaults to the language-specific "Number of overflow events".} + +\item{shape_title}{Character or \code{NULL}. Title of the storage-type legend +group. Defaults to the language-specific "Storage type".} + +\item{add_shape_legend}{Logical. If \code{TRUE} (default), append the two +legend-only storage-type entries.} +} +\value{ +The modified plotly object. +} +\description{ +\code{plotly::ggplotly()} flattens a ggplot with both a colour and a shape +aesthetic into one trace per (colour, shape) combination and names the +legend entries as tuples such as \code{"(0,Sickerbox / Infiltration box)"} -- +with two storage types and the 0..x / ">x" overflow palette that yields an +unreadable legend. This helper post-processes the plotly object: +} +\details{ +\itemize{ +\item the real traces lose their legend entries; instead every overflow class +gets one legend-only key drawn as a \strong{neutral circle in the class +colour} (a coloured square or triangle would wrongly suggest one +specific storage type). The key shares its legend group with the real +traces of that class, so clicking it toggles \strong{both} storage types of +the class together; +\item two legend-only keys (\strong{neutral grey} filled square = infiltration box, +filled triangle = gravel trench) are appended under their own +\strong{storage-type group title}, so the shape encoding is explained +separately from the colours -- set \code{add_shape_legend = FALSE} to skip +them (e.g. for storage-type-faceted plots whose strips already label the +panels). The keys are \strong{clickable}: since a plotly trace can only carry +one legend group (taken by the overflow class), a small JavaScript +handler (via \code{htmlwidgets::onRender()}) toggles all traces drawn with +that marker symbol, so each storage type can be shown or hidden +individually; the key greys out to reflect the state; +\item the combined \code{"colour,shape"} legend-title annotation that ggplotly +draws over the plot title is removed; group titles take its place and +the legend moves to a vertical layout on the right, where the groups +stack cleanly. +} + +Traces whose name is not a \code{"(colour,shape)"} tuple (frontier lines, best +markers, single-aesthetic plots) are left untouched, so the helper is safe +to apply to any of the package's interactive plots. +} +\examples{ +\dontrun{ +p <- plot_cost_vs_evaporation(sim_results, param_grid, x = 5, lang = "de") +pl <- plotly::ggplotly(p, tooltip = "text") +pl <- plotly_split_legend(pl, lang = "de") +} + +} diff --git a/man/read_hdf5_connections.Rd b/man/read_hdf5_connections.Rd index 200f92a..08a7794 100644 --- a/man/read_hdf5_connections.Rd +++ b/man/read_hdf5_connections.Rd @@ -7,7 +7,7 @@ read_hdf5_connections(file) } \arguments{ -\item{file}{An \code{\link[hdf5r:H5File-class]{hdf5r::H5File}} object pointing to a \verb{*_Verschaltungen.h5} file, +\item{file}{An \code{\link[hdf5r:H5File]{hdf5r::H5File}} object pointing to a \verb{*_Verschaltungen.h5} file, already opened in read mode.} } \value{ diff --git a/man/read_hdf5_scalars.Rd b/man/read_hdf5_scalars.Rd index 4eb7a1f..15c6aac 100644 --- a/man/read_hdf5_scalars.Rd +++ b/man/read_hdf5_scalars.Rd @@ -7,7 +7,7 @@ read_hdf5_scalars(group, numeric_only = TRUE) } \arguments{ -\item{group}{An \code{\link[hdf5r:H5Group-class]{hdf5r::H5Group}} object. Direct children of this group are expected +\item{group}{An \code{\link[hdf5r:H5Group]{hdf5r::H5Group}} object. Direct children of this group are expected to be scalar datasets (i.e. \code{dataset.dims == 0}).} \item{numeric_only}{Logical (default: \code{TRUE}). If \code{TRUE}, only numeric / integer scalars are diff --git a/man/read_hdf5_timeseries.Rd b/man/read_hdf5_timeseries.Rd index 30d5f05..3b7e21d 100644 --- a/man/read_hdf5_timeseries.Rd +++ b/man/read_hdf5_timeseries.Rd @@ -24,8 +24,8 @@ columns: variable, time, value. \details{ Supported dataset layouts: \itemize{ -\item k x N (rows): \code{[1, ]} = time, \code{[2..k, ]} = values (series) -\item N x k (cols): \code{[, 1]} = time, \code{[, 2..k]} = values (series) +\item k x N (rows): \verb{[1, ]} = time, \verb{[2..k, ]} = values (series) +\item N x k (cols): \verb{[, 1]} = time, \verb{[, 2..k]} = values (series) } Special handling for names containing "deeperLayers"/"deeper_layers": diff --git a/man/read_site_timeseries.Rd b/man/read_site_timeseries.Rd new file mode 100644 index 0000000..d80aebb --- /dev/null +++ b/man/read_site_timeseries.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/read_site_timeseries.R +\name{read_site_timeseries} +\alias{read_site_timeseries} +\title{Read and prepare site rain/ET0 time series for the engine (mm/h)} +\usage{ +read_site_timeseries(path_rain, path_et, verbose = TRUE) +} +\arguments{ +\item{path_rain}{Path to the rain CSV (may be gzipped).} + +\item{path_et}{Path to the ET0 CSV (semicolon separated).} + +\item{verbose}{Print alignment messages (default TRUE).} +} +\value{ +List with data.frames \code{rain} and \code{et} (columns \code{time} = hours +since start, \code{value} = mm/h) ready for +\code{make_swale_runner(timeseries_rain = , timeseries_et = )}. +} +\description{ +Factors the time-series preparation duplicated in the Wien and Bad +Aussee workflow vignettes into one helper: reads the shipped GeoSphere +rain series (\code{rain.csv.gz}: columns \code{time} (datetime), \code{rr} (mm per +interval), \code{station}, further columns tolerated) and reference ET0 +series (\code{et.csv}: +\verb{date;value} with \code{dd.mm.yyyy}, mm per day), converts both to hours +since series start, aligns the series ends (the shorter series is +extended to the longer one's end, repeating its last value) and +converts the values to the engine's \strong{mm/h} rate convention (rain: +mm per interval / interval hours; ET0: mm per day / 24). +} +\seealso{ +\code{\link[=make_swale_runner]{make_swale_runner()}} +} diff --git a/man/run_model.Rd b/man/run_model.Rd index 6e8c74e..2a349ee 100644 --- a/man/run_model.Rd +++ b/man/run_model.Rd @@ -39,7 +39,7 @@ command output are wrapped with \code{\link[kwb.utils:catAndRun]{kwb.utils::catA } \details{ Both \code{path_exe} and \code{path_input} are converted to absolute, normalised -paths via \code{\link[fs:path_math]{fs::path_abs()}} and \code{\link[base:normalizePath]{base::normalizePath()}}. The command is +paths via \code{\link[fs:path_abs]{fs::path_abs()}} and \code{\link[base:normalizePath]{base::normalizePath()}}. The command is executed with \code{\link[base:shell]{base::shell()}}, which on Windows invokes the system shell. On non-Windows platforms, prefer \code{\link[base:system]{base::system()}} if you need full POSIX semantics. } @@ -64,5 +64,5 @@ status <- run_model(exe, input, print_output = TRUE) } \seealso{ -\code{\link[base:shell]{base::shell()}}, \code{\link[fs:path_math]{fs::path_abs()}}, \code{\link[kwb.utils:catAndRun]{kwb.utils::catAndRun()}} +\code{\link[base:shell]{base::shell()}}, \code{\link[fs:path_abs]{fs::path_abs()}}, \code{\link[kwb.utils:catAndRun]{kwb.utils::catAndRun()}} } diff --git a/man/sickerbox_level_presets.Rd b/man/sickerbox_level_presets.Rd new file mode 100644 index 0000000..43eafdb --- /dev/null +++ b/man/sickerbox_level_presets.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/stack_levels.R +\name{sickerbox_level_presets} +\alias{sickerbox_level_presets} +\title{Sickerbox storage-height presets (brute force default + manufacturers)} +\usage{ +sickerbox_level_presets(max_height = 2600) +} +\arguments{ +\item{max_height}{Maximum total stack height in mm passed to +\code{\link[=stack_levels]{stack_levels()}} (default 2600).} +} +\value{ +Named list of sorted numeric vectors (mm). +} +\description{ +Named list of storage-height level vectors (mm) for the infiltration-box +storage layer. \code{brute_force} is the default used by the workflow +vignettes (300/600/900/1200 mm -- itself a combination of several box +types). The manufacturer presets are generated with \code{\link[=stack_levels]{stack_levels()}} +from typical module heights of commercial block systems; verify against +the current data sheets before productive optimisation runs. +} diff --git a/man/stack_levels.Rd b/man/stack_levels.Rd new file mode 100644 index 0000000..82c00fa --- /dev/null +++ b/man/stack_levels.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/stack_levels.R +\name{stack_levels} +\alias{stack_levels} +\title{Achievable storage-layer stack heights from module heights} +\usage{ +stack_levels(modules, max_count = rep(7L, length(modules)), max_height = 2600) +} +\arguments{ +\item{modules}{Numeric vector of module heights in mm (e.g. \code{c(660, 350)} +for a full block plus a half block).} + +\item{max_count}{Integer vector (recycled to \code{length(modules)}): maximum +number of modules of each type in one stack. Defaults to 7 for every +module (cf. GRAF EcoBloc smart, stackable up to 7 layers).} + +\item{max_height}{Maximum total stack height in mm (default 2600).} +} +\value{ +Sorted numeric vector of achievable stack heights in mm. +} +\description{ +Enumerates all storage-layer heights that can be built by stacking +(and mixing) the given module heights, e.g. full blocks combined with +at most one half block. +} +\examples{ +stack_levels(360) # 360, 720, ..., 2520 +stack_levels(c(660, 350), max_count = c(7, 1)) # Rigofill full + half block + +} diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..3dc1a40 --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(kwb.raindrop) + +test_check("kwb.raindrop") diff --git a/tests/testthat/test-find_min_feasible.R b/tests/testthat/test-find_min_feasible.R new file mode 100644 index 0000000..e2d3a79 --- /dev/null +++ b/tests/testthat/test-find_min_feasible.R @@ -0,0 +1,108 @@ +test_that("kontinuierliche Bisektion findet die Schwelle innerhalb der Toleranz", { + calls <- 0L + f <- function(v) { + calls <<- calls + 1L + list(n_overflows = if (v >= 137.4) 0L else 10L) + } + res <- find_min_feasible(f, x_max = 0, lower = 25, upper = 200, tol = 2) + expect_identical(res$status, "ok") + expect_gte(res$value, 137.4) + expect_lte(res$value, 137.4 + 2) + # log2(175 / 2) ~ 6.5 -> hoechstens ~9 Laeufe inkl. Randtests + expect_lte(res$n_evaluations, 9) + # Memoisierung: jede Stelle nur einmal evaluiert + expect_equal(calls, res$n_evaluations) + expect_false(res$monotonicity_violation) +}) + +test_that("unterer Rand zulaessig -> at_lower_bound", { + f <- function(v) list(n_overflows = if (v >= 10) 0L else 10L) + res <- find_min_feasible(f, x_max = 0, lower = 25, upper = 200, tol = 2) + expect_identical(res$status, "at_lower_bound") + expect_equal(res$value, 25) +}) + +test_that("tief unzulaessiger Rand -> sofort infeasible (1 Lauf)", { + f <- function(v) list(n_overflows = 99L) + res <- find_min_feasible(f, x_max = 0, lower = 25, upper = 200, tol = 2) + expect_identical(res$status, "infeasible") + expect_true(is.na(res$value)) + expect_equal(res$n_evaluations, 1L) +}) + +test_that("Rand-Guard: +1-Zaehl-Wobble am oberen Rand frisst keine Loesung", { + # zulaessig in [100, 180), am oberen Rand springt der Zaehler auf x+1 + n_fun <- function(v) if (v >= 180) 2L else if (v >= 100) 1L else 50L + f <- function(v) list(n_overflows = n_fun(v)) + res <- find_min_feasible(f, x_max = 1, lower = 25, upper = 200, tol = 2) + expect_identical(res$status, "ok") + expect_gte(res$value, 100) + expect_lte(res$value, 102) +}) + +test_that("Volumen-Schiedsrichter: Warnung nur bei echter Nicht-Monotonie", { + # Zaehler springt bei 150 von 1 auf 2 -- Volumen steigt MIT: echte Verletzung + f_bad <- function(v) list( + n_overflows = if (v >= 150) 2L else if (v >= 100) 1L else 9L, + overflow_volume_m3 = if (v >= 150) 200 else if (v >= 100) 100 else 5000 + ) + expect_warning( + res_bad <- find_min_feasible(f_bad, x_max = 1, lower = 25, upper = 200, + tol = 2), + "non-monotonicity" + ) + expect_true(res_bad$monotonicity_violation) + + # gleicher Zaehler-Sprung, aber Volumen faellt weiter: harmloser Wobble + f_ok <- function(v) list( + n_overflows = if (v >= 150) 2L else if (v >= 100) 1L else 9L, + overflow_volume_m3 = 5000 - 20 * v + ) + expect_no_warning( + res_ok <- find_min_feasible(f_ok, x_max = 1, lower = 25, upper = 200, + tol = 2) + ) + expect_false(res_ok$monotonicity_violation) +}) + +test_that("split_jitter: zufaellige Suchpfade treffen dieselbe Schwelle", { + f <- function(v) list(n_overflows = if (v >= 137.4) 0L else 10L) + vals <- vapply(1:5, function(s) { + set.seed(s) + find_min_feasible(f, x_max = 0, lower = 25, upper = 200, tol = 2, + split_jitter = 0.3)$value + }, numeric(1)) + expect_true(all(vals >= 137.4 & vals <= 137.4 + 2)) + # verschiedene Seeds -> verschiedene Pfade (fast sicher versch. Werte) + expect_gt(length(unique(round(vals, 6))), 1) + + # diskrete Levels mit Jitter: identisches Ergebnis wie deterministisch + lv <- c(300, 600, 900, 1200) + g <- function(v) list(n_overflows = if (v >= 900) 0L else 7L) + set.seed(99) + expect_equal( + find_min_feasible(g, x_max = 0, levels = lv, split_jitter = 0.3)$value, + 900 + ) +}) + +test_that("diskrete Levels: Binaersuche ueber Stufen", { + lv <- c(300, 600, 900, 1200) + f <- function(v) list(n_overflows = if (v >= 900) 0L else 7L) + res <- find_min_feasible(f, x_max = 0, levels = lv) + expect_identical(res$status, "ok") + expect_equal(res$value, 900) + expect_lte(res$n_evaluations, 3) + + # schon die kleinste Stufe reicht + f2 <- function(v) list(n_overflows = 0L) + res2 <- find_min_feasible(f2, x_max = 0, levels = lv) + expect_identical(res2$status, "at_lower_bound") + expect_equal(res2$value, 300) + + # keine Stufe reicht (tief unzulaessig) + f3 <- function(v) list(n_overflows = 9L) + res3 <- find_min_feasible(f3, x_max = 0, levels = lv) + expect_identical(res3$status, "infeasible") + expect_equal(res3$n_evaluations, 1L) +}) diff --git a/tests/testthat/test-optimise_swale_design.R b/tests/testthat/test-optimise_swale_design.R new file mode 100644 index 0000000..76e7944 --- /dev/null +++ b/tests/testthat/test-optimise_swale_design.R @@ -0,0 +1,145 @@ +# Synthetisches, monotones Hydraulikmodell: Ueberlaeufe fallen mit der +# Rueckhaltekapazitaet cap = Flaeche x (Muldentiefe + Porositaet x +# Speicherhoehe). Kein Engine-Aufruf noetig -> der Optimierer laesst sich +# gegen eine Brute-Force-Referenz auf feinem Raster verifizieren. +synthetic_run_factory <- function(demand) { + porosity <- c(infiltration_box = 0.95, gravel_trench = 0.3) + function(params) { + cap <- params$mulde_area * + (params$mulde_height + + porosity[[params$storage_type]] * params$storage_height) + ratio <- demand / cap + list( + n_overflows = max(0, floor(ratio) - 3), + sum_overflows = 800 * max(0, ratio - 3), + element.WB_Evapotranspiration_ = 0.1 * params$mulde_area + ) + } +} + +test_fixed <- list(connected_area = 1000, filter_height = 300, + filter_hydraulicconductivity = 360, + bottom_hydraulicconductivity = 12) + +# Brute-Force-Referenz: guenstigstes zulaessiges Design auf feinem Raster +reference_optimum <- function(run, type, x, storage_values) { + grid <- expand.grid( + mulde_area = seq(25, 200, by = 0.5), + mulde_height = seq(100, 300, by = 5), + storage_height = storage_values, + stringsAsFactors = FALSE + ) + grid$storage_type <- type + porosity <- c(infiltration_box = 0.95, gravel_trench = 0.3) + cap <- grid$mulde_area * + (grid$mulde_height + porosity[[type]] * grid$storage_height) + grid$n <- pmax(0, floor(environment(run)$demand / cap) - 3) + grid <- grid[grid$n <= x, , drop = FALSE] + if (nrow(grid) == 0) return(NULL) + grid$filter_height <- 300 + costs <- compute_costs(grid) + costs[which.min(costs$cost_total), , drop = FALSE] +} + +test_that("Optimierer findet das Kostenminimum (Vergleich mit Brute-Force)", { + run <- synthetic_run_factory(demand = 3.6e5) + out <- optimise_swale_design(run, x_targets = 0:3, + fixed = test_fixed, verbose = FALSE) + + expect_true(all(out$status == "ok")) + expect_false(any(out$monotonicity_warning)) + # Zulaessigkeit: Ueberlaufziel eingehalten + expect_true(all(out$n_overflows <= out$x)) + + for (i in seq_len(nrow(out))) { + type <- out$storage_type[i] + stor <- if (type == "infiltration_box") c(300, 600, 900, 1200) + else seq(900, 3600, by = 25) + ref <- reference_optimum(run, type, out$x[i], stor) + expect_false(is.null(ref)) + # innerhalb 5 % des (quasi-kontinuierlichen) Brute-Force-Optimums + expect_lte(out$cost_total[i], ref$cost_total * 1.05) + } + + # Kosten-Wirksamkeits-Kurve: lockereres Ziel ist nie teurer + for (type in unique(out$storage_type)) { + cc <- out$cost_total[out$storage_type == type][order(out$x[out$storage_type == type])] + expect_true(all(diff(cc) <= 1e-9)) + } + + # Laufbudget: alle 8 Zellen zusammen deutlich unter Brute-Force-Groesse + expect_lte(attr(out, "n_runs_total"), 200) +}) + +test_that("Speicher-Eskalation greift, wenn die Flaeche am Anschlag klemmt", { + run <- synthetic_run_factory(demand = 8e5) + out <- optimise_swale_design(run, x_targets = 0, + fixed = test_fixed, verbose = FALSE) + box <- out[out$storage_type == "infiltration_box", ] + expect_identical(box$status, "ok") + # bei Minimal-Speicher 300 ist selbst 200 m2 unzulaessig -> Eskalation + expect_gt(box$storage_height, 300) + expect_lte(box$n_overflows, 0) +}) + +test_that("unloesbar innerhalb der Bounds ist ein regulaeres Ergebnis", { + run <- synthetic_run_factory(demand = 5e6) + out <- optimise_swale_design(run, x_targets = 0, + fixed = test_fixed, verbose = FALSE) + expect_true(all(out$status == "infeasible_within_bounds")) + expect_true(all(is.na(out$mulde_area))) + expect_true(all(is.na(out$cost_total))) +}) + +test_that("Warmstart aus Rasterergebnissen spart Laeufe", { + run <- synthetic_run_factory(demand = 3.6e5) + + # Prior im CSV-Schema der Workflows (kf = 360, h_m = 300, Rasterschritt 25) + prior <- expand.grid( + mulde_area = seq(25, 200, by = 25), + mulde_height = 300, + storage_type = c("infiltration_box", "gravel_trench"), + stringsAsFactors = FALSE + ) + prior$storage_height <- ifelse(prior$storage_type == "infiltration_box", + 300, 900) + prior$filter_hydraulicconductivity <- 360 + prior$n_overflows <- vapply(seq_len(nrow(prior)), function(i) { + as.numeric(run(c(as.list(prior[i, ]), test_fixed))$n_overflows) + }, numeric(1)) + + cold <- optimise_swale_design(run, x_targets = 0:3, + fixed = test_fixed, verbose = FALSE) + warm <- optimise_swale_design(run, x_targets = 0:3, + fixed = test_fixed, prior_results = prior, + verbose = FALSE) + + # identisches Ergebnis, weniger Laeufe + expect_equal(warm$cost_total, cold$cost_total, tolerance = 0.02) + expect_lt(attr(warm, "n_runs_total"), attr(cold, "n_runs_total")) +}) + +test_that("Such-MC: gejitterte Pfade treffen das deterministische Optimum", { + run <- synthetic_run_factory(demand = 3.6e5) + det <- optimise_swale_design(run, x_targets = 1, fixed = test_fixed, + verbose = FALSE) + for (s in 1:3) { + set.seed(s) + jit <- optimise_swale_design(run, x_targets = 1, fixed = test_fixed, + split_jitter = 0.3, verbose = FALSE) + expect_equal(jit$storage_height, det$storage_height) + expect_lte(max(abs(jit$mulde_area - det$mulde_area)), 2) # area_tol + expect_lte(max(abs(jit$mulde_height - det$mulde_height)), 10) # height_tol + expect_lte(max(abs(jit$cost_total - det$cost_total) / det$cost_total), + 0.03) + } +}) + +test_that("max_total_depth wirkt als analytische Nebenbedingung", { + run <- synthetic_run_factory(demand = 3.6e5) + out <- optimise_swale_design(run, x_targets = 0, fixed = test_fixed, + max_total_depth = 1200, verbose = FALSE) + ok <- out[out$status == "ok", ] + expect_true(all(ok$mulde_height + ok$filter_height + ok$storage_height + <= 1200 + 1e-9)) +}) diff --git a/tests/testthat/test-optimise_swale_design_simultaneous.R b/tests/testthat/test-optimise_swale_design_simultaneous.R new file mode 100644 index 0000000..1c7585e --- /dev/null +++ b/tests/testthat/test-optimise_swale_design_simultaneous.R @@ -0,0 +1,346 @@ +# Gleiches synthetisches, monotones Hydraulikmodell wie in +# test-optimise_swale_design.R: Ueberlaeufe fallen mit der +# Rueckhaltekapazitaet cap = Flaeche x (Muldentiefe + Porositaet x +# Speicherhoehe). Kein Engine-Aufruf noetig -> die simultane Suche laesst +# sich gegen eine Brute-Force-Referenz und gegen die Bisektion verifizieren. +sim_run_factory <- function(demand) { + porosity <- c(infiltration_box = 0.95, gravel_trench = 0.3) + function(params) { + cap <- params$mulde_area * + (params$mulde_height + + porosity[[params$storage_type]] * params$storage_height) + ratio <- demand / cap + list( + n_overflows = max(0, floor(ratio) - 3), + sum_overflows = 800 * max(0, ratio - 3), + element.WB_Evapotranspiration_ = 0.1 * params$mulde_area + ) + } +} + +sim_fixed <- list(connected_area = 1000, filter_height = 300, + filter_hydraulicconductivity = 360, + bottom_hydraulicconductivity = 12) + +# Brute-Force-Referenz: guenstigstes zulaessiges Design auf feinem Raster +sim_reference_optimum <- function(run, type, x, storage_values) { + grid <- expand.grid( + mulde_area = seq(25, 200, by = 0.5), + mulde_height = seq(100, 300, by = 5), + storage_height = storage_values, + stringsAsFactors = FALSE + ) + grid$storage_type <- type + porosity <- c(infiltration_box = 0.95, gravel_trench = 0.3) + cap <- grid$mulde_area * + (grid$mulde_height + porosity[[type]] * grid$storage_height) + grid$n <- pmax(0, floor(environment(run)$demand / cap) - 3) + grid <- grid[grid$n <= x, , drop = FALSE] + if (nrow(grid) == 0) return(NULL) + grid$filter_height <- 300 + costs <- compute_costs(grid) + costs[which.min(costs$cost_total), , drop = FALSE] +} + +test_that("Simultane Suche findet das Kostenminimum (Vergleich mit Brute-Force)", { + run <- sim_run_factory(demand = 3.6e5) + out <- optimise_swale_design_simultaneous(run, x_targets = 0:3, + fixed = sim_fixed, + verbose = FALSE) + + expect_true(all(out$status == "ok")) + expect_false(any(out$monotonicity_warning)) + # Zulaessigkeit: Ueberlaufziel eingehalten + expect_true(all(out$n_overflows <= out$x)) + + for (i in seq_len(nrow(out))) { + type <- out$storage_type[i] + stor <- if (type == "infiltration_box") c(300, 600, 900, 1200) + else seq(900, 3600, by = 25) + ref <- sim_reference_optimum(run, type, out$x[i], stor) + expect_false(is.null(ref)) + # innerhalb 5 % des (quasi-kontinuierlichen) Brute-Force-Optimums + expect_lte(out$cost_total[i], ref$cost_total * 1.05) + } + + # Kosten-Wirksamkeits-Kurve: lockereres Ziel ist nie teurer + for (type in unique(out$storage_type)) { + cc <- out$cost_total[out$storage_type == type][order(out$x[out$storage_type == type])] + expect_true(all(diff(cc) <= 1e-9)) + } +}) + +test_that("Bisektion und simultane Suche bestaetigen sich gegenseitig", { + run <- sim_run_factory(demand = 3.6e5) + cd <- optimise_swale_design(run, x_targets = 0:2, + fixed = sim_fixed, verbose = FALSE) + nm <- optimise_swale_design_simultaneous(run, x_targets = 0:2, + fixed = sim_fixed, + verbose = FALSE) + # gleiche Zellen, gleicher Status, Kosten innerhalb 5 % voneinander + expect_identical(nm$status, cd$status) + expect_true(all(abs(nm$cost_total - cd$cost_total) <= + 0.05 * pmin(nm$cost_total, cd$cost_total))) +}) + +test_that("hoher Bedarf erzwingt implizit einen groesseren Speicher", { + run <- sim_run_factory(demand = 8e5) + out <- optimise_swale_design_simultaneous(run, x_targets = 0, + fixed = sim_fixed, + verbose = FALSE) + box <- out[out$storage_type == "infiltration_box", ] + expect_identical(box$status, "ok") + # bei Minimal-Speicher 300 ist selbst 200 m2 unzulaessig + expect_gt(box$storage_height, 300) + expect_lte(box$n_overflows, 0) +}) + +test_that("unloesbar innerhalb der Bounds ist ein regulaeres Ergebnis", { + run <- sim_run_factory(demand = 5e6) + out <- optimise_swale_design_simultaneous(run, x_targets = 0, + fixed = sim_fixed, + verbose = FALSE) + expect_true(all(out$status == "infeasible_within_bounds")) + expect_true(all(is.na(out$mulde_area))) + expect_true(all(is.na(out$cost_total))) +}) + +test_that("Warmstart aus Rasterergebnissen liefert dasselbe Optimum", { + run <- sim_run_factory(demand = 3.6e5) + + # Prior im CSV-Schema der Workflows (kf = 360, h_m = 300, Rasterschritt 25) + prior <- expand.grid( + mulde_area = seq(25, 200, by = 25), + mulde_height = 300, + storage_type = c("infiltration_box", "gravel_trench"), + stringsAsFactors = FALSE + ) + prior$storage_height <- ifelse(prior$storage_type == "infiltration_box", + 300, 900) + prior$filter_hydraulicconductivity <- 360 + prior$n_overflows <- vapply(seq_len(nrow(prior)), function(i) { + as.numeric(run(c(as.list(prior[i, ]), sim_fixed))$n_overflows) + }, numeric(1)) + + cold <- optimise_swale_design_simultaneous(run, x_targets = 0:2, + fixed = sim_fixed, + verbose = FALSE) + warm <- optimise_swale_design_simultaneous(run, x_targets = 0:2, + fixed = sim_fixed, + prior_results = prior, + verbose = FALSE) + + # gleiches Optimum (innerhalb der Suchtoleranzen) + expect_equal(warm$cost_total, cold$cost_total, tolerance = 0.05) +}) + +test_that("max_total_depth wirkt als analytische Nebenbedingung", { + run <- sim_run_factory(demand = 3.6e5) + out <- optimise_swale_design_simultaneous(run, x_targets = 0, + fixed = sim_fixed, + max_total_depth = 1200, + verbose = FALSE) + ok <- out[out$status == "ok", ] + expect_true(all(ok$mulde_height + ok$filter_height + ok$storage_height + <= 1200 + 1e-9)) +}) + +test_that("leerer Suchraum (Tiefe verbietet alles) liefert Zeilen statt Fehler", { + # max_total_depth = 500 laesst mit filter_height 300 und Muldentiefe + # >= 100 keine einzige Speicherhoehe zu -> kein Engine-Lauf, aber ein + # regulaeres Ergebnis (kein Absturz beim Zusammenbau der Attribute) + run <- sim_run_factory(demand = 3.6e5) + for (f in list(optimise_swale_design, optimise_swale_design_simultaneous)) { + out <- f(run, x_targets = 0:1, fixed = sim_fixed, + max_total_depth = 500, verbose = FALSE) + expect_true(all(out$status == "infeasible_within_bounds")) + expect_identical(attr(out, "n_runs_total"), 0L) + expect_identical(nrow(attr(out, "evaluations")), 0L) + } +}) + +test_that("degenerierte Rigol-Achse (genau eine zulaessige Hoehe) ist loesbar", { + # max_total_depth = 1300: gb[2] wird auf gb[1] = 900 gedrueckt -- + # genau ein zulaessiger Speicherwert bleibt, die Zelle ist loesbar + run <- sim_run_factory(demand = 3.6e5) + out <- optimise_swale_design_simultaneous( + run, x_targets = 5, fixed = sim_fixed, + storage_spec = default_storage_spec()["gravel_trench"], + max_total_depth = 1300, verbose = FALSE + ) + expect_identical(out$status, "ok") + expect_equal(out$storage_height, 900, tolerance = 0) + expect_equal(out$mulde_height, 100, tolerance = 0) +}) + +test_that("NA-Zeilen im Prior stuerzen den Warmstart nicht ab", { + run <- sim_run_factory(demand = 3.6e5) + prior <- data.frame( + mulde_area = c(150, NA), mulde_height = c(300, NA), + storage_type = c("infiltration_box", NA), + storage_height = c(300, NA), + filter_hydraulicconductivity = c(360, NA), + n_overflows = c(0, NA) + ) + out <- optimise_swale_design_simultaneous( + run, x_targets = 0, fixed = sim_fixed, + storage_spec = default_storage_spec()["infiltration_box"], + prior_results = prior, verbose = FALSE + ) + expect_identical(out$status, "ok") +}) + +test_that("max_evals begrenzt die Suchphase messbar", { + run <- sim_run_factory(demand = 3.6e5) + small <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, + max_evals = 15, + verbose = FALSE) + large <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, + max_evals = 200, + verbose = FALSE) + expect_lt(attr(small, "n_runs_total"), attr(large, "n_runs_total")) + # das Optimum leidet nicht wesentlich unter dem kleinen Budget + expect_true(all(small$cost_total <= large$cost_total * 1.10)) +}) + +test_that("Ergebnis traegt die Attribute evaluations und n_runs_total", { + run <- sim_run_factory(demand = 3.6e5) + out <- optimise_swale_design_simultaneous( + run, x_targets = 1, fixed = sim_fixed, + storage_spec = default_storage_spec()["infiltration_box"], + verbose = FALSE + ) + ev <- attr(out, "evaluations") + expect_s3_class(ev, "tbl_df") + expect_gt(nrow(ev), 0) + expect_true(all(c("storage_type", "mulde_area", "mulde_height", + "storage_height", "n_overflows") %in% names(ev))) + expect_identical(attr(out, "n_runs_total"), nrow(ev)) +}) + +test_that("beide Optimierer folgen veraenderten Kostensaetzen", { + # Bei sehr billigem Speichermaterial liegt das Optimum bei hoher + # Speicherstufe + kleiner Flaeche. Die simultane Suche traegt die + # cost_rates in der Zielfunktion; die Bisektion leitet ihre + # Start-Speicherstufe seit dem Spezifikkosten-Proxy ebenfalls aus den + # Saetzen ab (statt stur bei der kleinsten Stufe zu starten) -- beide + # muessen die billige Ecke finden und eng beieinander liegen. + run <- sim_run_factory(demand = 3.6e5) + cheap_box <- default_cost_rates() + cheap_box$infiltration_box_eur_per_m3 <- 5 + spec <- default_storage_spec()["infiltration_box"] + + bis <- optimise_swale_design(run, x_targets = 0, fixed = sim_fixed, + storage_spec = spec, + cost_rates = cheap_box, verbose = FALSE) + sim <- optimise_swale_design_simultaneous(run, x_targets = 0, + fixed = sim_fixed, + storage_spec = spec, + cost_rates = cheap_box, + verbose = FALSE) + expect_identical(bis$status, "ok") + expect_identical(sim$status, "ok") + expect_equal(bis$storage_height, 1200, tolerance = 0) + expect_equal(sim$storage_height, 1200, tolerance = 0) + expect_lt(abs(sim$cost_total - bis$cost_total), 0.05 * bis$cost_total) + + # ohne porosity-Eintrag: Legacy-Reihenfolge (kleinste Stufe zuerst) + spec_legacy <- spec + spec_legacy$infiltration_box$porosity <- NULL + leg <- optimise_swale_design(run, x_targets = 0, fixed = sim_fixed, + storage_spec = spec_legacy, + cost_rates = cheap_box, verbose = FALSE) + expect_equal(leg$storage_height, 300, tolerance = 0) + + # unter Default-Saetzen waehlt der Proxy weiterhin die kleinste Stufe + def <- optimise_swale_design(run, x_targets = 0, fixed = sim_fixed, + storage_spec = spec, verbose = FALSE) + expect_equal(def$storage_height, 300, tolerance = 0) +}) + +test_that("alle Methoden funktionieren mit Ein-Typ-storage_spec", { + run <- sim_run_factory(demand = 3.6e5) + for (m in c("nelder_mead", "diff_evolution", "halton_search")) { + out <- optimise_swale_design_simultaneous( + run, x_targets = 1, fixed = sim_fixed, + storage_spec = default_storage_spec()["infiltration_box"], + method = m, verbose = FALSE + ) + expect_identical(out$status, "ok") + expect_identical(out$method, m) + } +}) + +test_that("alle Suchverfahren treffen das Brute-Force-Optimum", { + run <- sim_run_factory(demand = 3.6e5) + # NM ist am praezisesten, DE nah dran, Halton ist die naive Baseline + slack <- c(nelder_mead = 1.05, diff_evolution = 1.08, halton_search = 1.12) + for (m in names(slack)) { + out <- optimise_swale_design_simultaneous(run, x_targets = c(0, 2), + fixed = sim_fixed, method = m, + verbose = FALSE) + expect_true(all(out$status == "ok"), info = m) + expect_true(all(out$method == m), info = m) + expect_true(all(out$n_overflows <= out$x), info = m) + for (i in seq_len(nrow(out))) { + type <- out$storage_type[i] + stor <- if (type == "infiltration_box") c(300, 600, 900, 1200) + else seq(900, 3600, by = 25) + ref <- sim_reference_optimum(run, type, out$x[i], stor) + expect_lte(out$cost_total[i], ref$cost_total * slack[[m]]) + } + } +}) + +test_that("Differential Evolution ist deterministisch und laesst Rs RNG in Ruhe", { + run <- sim_run_factory(demand = 3.6e5) + + set.seed(4711) + rng_before <- .Random.seed + de1 <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, + method = "diff_evolution", + verbose = FALSE) + # .Random.seed unveraendert: der interne LCG ersetzt Rs Zufallsstrom + expect_identical(.Random.seed, rng_before) + + de2 <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, + method = "diff_evolution", + verbose = FALSE) + expect_equal(de1$cost_total, de2$cost_total, tolerance = 0) + expect_equal(de1$mulde_area, de2$mulde_area, tolerance = 0) + + # anderer Seed = anderer Suchpfad, aber gleiches Optimum (Toleranzen) + de3 <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, + method = "diff_evolution", + seed = 99, verbose = FALSE) + expect_equal(de3$cost_total, de1$cost_total, tolerance = 0.05) + # Kanarienvogel gegen ein ignoriertes seed-Argument: die besuchten + # Designs (Suchpfade) muessen sich unterscheiden, auch wenn die + # Optima uebereinstimmen duerfen + expect_false(identical(attr(de3, "evaluations"), attr(de1, "evaluations"))) +}) + +test_that("verschiedene Start-Konfigurationen treffen dasselbe Optimum", { + # verschiedene Suchpfade besuchen verschiedene Gitterpunkte -- die + # Optima muessen innerhalb der Suchtoleranzen uebereinstimmen + # (analog zur split_jitter-Erwartung der Bisektion) + run <- sim_run_factory(demand = 3.6e5) + few <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, n_starts = 1, + verbose = FALSE) + many <- optimise_swale_design_simultaneous(run, x_targets = 1, + fixed = sim_fixed, n_starts = 5, + max_evals = 120, + verbose = FALSE) + # Sickerbox: identische Stufe; Schotterrigol (stufenlos): eine + # Toleranzstufe (25 mm) Spielraum + tol_hs <- ifelse(many$storage_type == "infiltration_box", 0, 25) + expect_true(all(abs(many$storage_height - few$storage_height) <= tol_hs)) + expect_true(all(abs(many$cost_total - few$cost_total) <= + 0.03 * few$cost_total)) +}) diff --git a/tests/testthat/test-stack_levels.R b/tests/testthat/test-stack_levels.R new file mode 100644 index 0000000..8ebdd63 --- /dev/null +++ b/tests/testthat/test-stack_levels.R @@ -0,0 +1,22 @@ +test_that("stack_levels: Vielfache einer Modulhoehe bis zur Obergrenze", { + expect_equal(stack_levels(360), seq(360, 2520, by = 360)) + expect_equal(stack_levels(614), c(614, 1228, 1842, 2456)) + expect_true(all(stack_levels(400) <= 2600)) +}) + +test_that("stack_levels: Mischkombination Vollblock + max. 1 Halbblock", { + expect_equal( + stack_levels(c(660, 350), max_count = c(7L, 1L)), + c(350, 660, 1010, 1320, 1670, 1980, 2330) + ) +}) + +test_that("Presets und Default-Spezifikation", { + presets <- sickerbox_level_presets() + expect_equal(presets$brute_force, c(300, 600, 900, 1200)) + expect_true(all(vapply(presets, function(p) all(diff(p) > 0), logical(1)))) + + spec <- default_storage_spec() + expect_equal(spec$infiltration_box$levels, c(300, 600, 900, 1200)) + expect_equal(spec$gravel_trench$bounds, c(900, 3600)) +}) diff --git a/vignettes/example_wien_minimal.Rmd b/vignettes/example_wien_minimal.Rmd index 8321142..a40f54a 100644 --- a/vignettes/example_wien_minimal.Rmd +++ b/vignettes/example_wien_minimal.Rmd @@ -169,7 +169,7 @@ DT::datatable( param_grid, filter = "top", options = list(pageLength = 12, autoWidth = TRUE), - caption = "Twelve scenarios — fixed Daniel-reference geometry, sweep of three ET-related engine switches." + caption = "Twelve scenarios \u2014 fixed Daniel-reference geometry, sweep of three ET-related engine switches." ) psi_s_mm <- function(kf_mmh) (3.237 * (kf_mmh / 25.4)^(-0.328)) * 25.4 @@ -476,7 +476,7 @@ DT::datatable( results, filter = "top", options = list(pageLength = 12, autoWidth = TRUE), - caption = "Twelve-scenario simulation results — water balance + overflow events + construction costs (EUR). Sort by the ET share column to see which combination of `keineVerdunstungBeiRegen` / `Hoernschemeyer_aktiv` / `ET0ref_factor` brings the modelled ET share closest to the SWIMM-Urban-Eva reference of ~7%. Scenarios whose water-balance step errored (see chunk log) are present in the table but have NA in the result columns." + caption = "Twelve-scenario simulation results \u2014 water balance + overflow events + construction costs (EUR). Sort by the ET share column to see which combination of `keineVerdunstungBeiRegen` / `Hoernschemeyer_aktiv` / `ET0ref_factor` brings the modelled ET share closest to the SWIMM-Urban-Eva reference of ~7%. Scenarios whose water-balance step errored (see chunk log) are present in the table but have NA in the result columns." ) ``` diff --git a/vignettes/workflow_badaussee.Rmd b/vignettes/workflow_badaussee.Rmd index 69b87ef..89f6f27 100644 --- a/vignettes/workflow_badaussee.Rmd +++ b/vignettes/workflow_badaussee.Rmd @@ -109,14 +109,52 @@ mulde_area <- c(25, 50, 75, 100, 125, 150, 175, 200) mulde_height <- c(100, 200, 300) filter_hydraulicconductivity <- c(36, 180, 360) filter_height <- 300 -storage_height <- c(100, 500, 1000) +# Storage-layer (Speicher = 2nd Bodenschichtung layer) presets per storage type. +# Each type brings its own storage_height levels plus the Speicher soil +# parameters (theta*). They are written to the model in the run loop below: +# storage_height -> Schichtdicken[2] +# Startwerte_theta_ActualSoilMoisture -> Startwerte_theta_ActualSoilMoisture[2] +# thetaWP/thetaFC/thetaS -> //Bodenarten/Speicher/theta* +storage_type <- list( + "infiltration_box" = list( + storage_height = c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.95 + ), + "gravel_trench" = list( + storage_height = 3 * c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.3 + ) +) rain_factor <- 1 bottom_hydraulicconductivity <- 12 #c(1,5,10,20,45,90,180,270,360,1860,3600) # LAI for Mulde_Rigole only (Dach kept at H5 default). -# 8.5 = status-quo H5 default; 3.9 = grass per Hörnschemeyer et al., +# 8.5 = status-quo H5 default; 3.9 = grass per Hoernschemeyer et al., # Water 2023, 15, 2840, Tab. 6, plant type 5 (grasses/herbs). -lai <- c(3.9, 8.5) - +#lai <- c(3.9, 8.5) +lai <- 3.9 + + +# storage_height is coupled to storage_type (each type has its own levels), so +# build one row per (storage_type, storage_height) carrying the matching +# Speicher soil parameters, then cross-join with all remaining combinations. +storage_grid <- do.call(rbind, lapply(names(storage_type), function(type_name) { + spec <- storage_type[[type_name]] + data.frame( + storage_type = type_name, + storage_height = spec$storage_height, + storage_theta_start = spec$Startwerte_theta_ActualSoilMoisture, + storage_thetaWP = spec$thetaWP_MoistureAtWiltingPoint, + storage_thetaFC = spec$thetaFC_MoistureAtFieldCapacity, + storage_thetaS = spec$thetaS_MoistureAtSaturation, + stringsAsFactors = FALSE + ) +})) # Alle Kombinationen erzeugen param_grid_all_combinations <- expand.grid( @@ -125,16 +163,25 @@ param_grid_all_combinations <- expand.grid( mulde_height = mulde_height, filter_hydraulicconductivity = filter_hydraulicconductivity, filter_height = filter_height, - storage_height = storage_height, bottom_hydraulicconductivity = bottom_hydraulicconductivity, rain_factor = rain_factor, - lai = lai + lai = lai, + stringsAsFactors = FALSE ) +# Cross-join the free parameters with the coupled storage grid. +param_grid_all_combinations <- merge(param_grid_all_combinations, storage_grid, + by = NULL) + param_grid_all_combinations <- param_grid_all_combinations %>% dplyr::bind_cols(tibble::tibble(scenario_name = sprintf("s%05d", seq_len(nrow(param_grid_all_combinations))))) +# Reference = first storage type at its smallest storage_height (storage_height +# is coupled to storage_type, so both are fixed together). +ref_storage_type <- names(storage_type)[1] +ref_storage_height <- min(storage_type[[ref_storage_type]]$storage_height) + ref_scenario <- param_grid_all_combinations %>% dplyr::filter(connected_area == min(unique(param_grid_all_combinations$connected_area)), mulde_area == min(unique(param_grid_all_combinations$mulde_area)), @@ -142,7 +189,8 @@ ref_scenario <- param_grid_all_combinations %>% filter_hydraulicconductivity == min(param_grid_all_combinations$filter_hydraulicconductivity), bottom_hydraulicconductivity == min(unique(param_grid_all_combinations$bottom_hydraulicconductivity)), mulde_height == min(param_grid_all_combinations$mulde_height), - storage_height == min(param_grid_all_combinations$storage_height), + storage_type == ref_storage_type, + storage_height == ref_storage_height, lai == max(param_grid_all_combinations$lai)) %>% dplyr::pull(scenario_name) @@ -158,6 +206,20 @@ param_grid <- param_grid_all_combinations %>% dplyr::filter(scenario_name %in% scenarios_with_single_parameter_variation) param_grid <- param_grid_all_combinations +# Nutzbares Speichervolumen der Speicherschicht [m3] = Muldenflaeche x +# Speicherhoehe x nutzbare Porositaet (thetaS - thetaFC) des Speichertyps +# (Sickerbox 0.95, Schotterrigole 0.3). thetaFC statt thetaWP: nur das +# oberhalb der Feldkapazitaet entwaesserbare Porenvolumen leert sich zwischen +# den Ereignissen und steht als Retentionsvolumen erneut zur Verfuegung; +# Wasser zwischen WP und FC haelt die Schicht gegen die Schwerkraft (in den +# Presets sind thetaFC = thetaWP = 0, beide Definitionen also identisch). +# Erscheint in der Grid-Tabelle, den Ergebnis-CSVs und im Plot-Tooltip. +param_grid <- param_grid %>% + dplyr::mutate( + storage_volume_m3 = mulde_area * storage_height / 1000 * + (storage_thetaS - storage_thetaFC) + ) + DT::datatable(param_grid, filter = "top", options = list(pageLength = 25, @@ -239,7 +301,7 @@ timeseries_rain <- if(max(timeseries_et$time) > max(timeseries_rain$time)) { timeseries_rain } -txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Regenmenge: %f mm/a)\n", +txt <- sprintf("F\u00fcr den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Regenmenge: %f mm/a)\n", paths$path_rain, 100*sum(is.na(timeseries_rain$value))/nrow(timeseries_rain), 100*sum(timeseries_rain$value == 0, na.rm = TRUE)/nrow(timeseries_rain), @@ -247,7 +309,7 @@ txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Wer message(txt) -txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Verdunstungs: %f mm/a)\n", +txt <- sprintf("F\u00fcr den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Evapotranspirations: %f mm/a)\n", paths$path_et, 100*sum(is.na(timeseries_et$value))/nrow(timeseries_et), 100*sum(timeseries_et$value == 0, na.rm = TRUE)/nrow(timeseries_et), @@ -260,6 +322,12 @@ message(txt) period <- c(diff(timeseries_rain$time), mean(diff(timeseries_rain$time))) timeseries_rain$value <- timeseries_rain$value / period +### Convert ET0 from mm/day to mm/h (the engine reads //Kurven/ET0 as a mm/h +### rate, exactly like rain; daily values must be divided by their interval in +### hours = 24, otherwise ET0 is integrated 24x too high) +period_et <- c(diff(timeseries_et$time), mean(diff(timeseries_et$time))) +timeseries_et$value <- timeseries_et$value / period_et + #openxlsx::write.xlsx(list(regen = timeseries_rain, et = timeseries_et), "timeseries.xlsx") @@ -308,9 +376,14 @@ run_one <- function(i, vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Regen-Skalierungsfaktor` <- 1 vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` <- param_grid_tmp$mulde_area vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` <- param_grid_tmp$mulde_height - vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, 0) + vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, param_grid_tmp$storage_theta_start) vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken` <- c(param_grid_tmp$filter_height, param_grid_tmp$storage_height) + # Speicher (2nd Bodenschichtung layer) soil parameters depend on the + # storage type (infiltration_box vs. gravel_trench); see `storage_type`. + vals$`//Bodenarten/Speicher/thetaWP_MoistureAtWiltingPoint` <- param_grid_tmp$storage_thetaWP + vals$`//Bodenarten/Speicher/thetaFC_MoistureAtFieldCapacity` <- param_grid_tmp$storage_thetaFC + vals$`//Bodenarten/Speicher/thetaS_MoistureAtSaturation` <- param_grid_tmp$storage_thetaS vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate` <- param_grid_tmp$bottom_hydraulicconductivity vals$`//Bodenarten/Bodenfilter/Ks_HydraulicConductivity` <- param_grid_tmp$filter_hydraulicconductivity @@ -332,13 +405,33 @@ run_one <- function(i, path_input = paths$path_target_input, debug = debug) - invisible(NULL) + # Thin immediately: read only this run's results (lean = water balance + + # overflow rates, no states/meta/connected-area rates) and reduce to the + # single optimisation row. This way we never hold all scenarios' full + # time series in memory at once; the full result HDF5 stays on disk for + # ad-hoc inspection. + sim_one <- kwb.raindrop::get_simulation_results_optim( + paths = paths, + path_list = path_list, + simulation_names = param_grid_tmp$scenario_name, + debug = debug, + lean = TRUE + ) + + kwb.raindrop::add_overflow_events_and_waterbalance( + simulation_results = sim_one, + event_separation_hours = 4, + canonical_variables = kwb.raindrop::default_canonical_wb_variables() + ) } n_cores <- parallel::detectCores() +# run_one() now returns the thinned per-run optimisation row, so run_scenarios() +# yields a list of one-row tibbles we simply bind below. +scenario_rows <- NULL system.time(expr = { -kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), +scenario_rows <- kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), run_one_scenario = run_one, timestep_hours = 0.1, debug = FALSE, @@ -368,21 +461,11 @@ x$Fehlerbeschreibung ### Analyse Results ```{r analyse_results, eval = data_available && is_windows && !is_ghactions} -system.time( -simulation_results <- kwb.raindrop::get_simulation_results_optim_parallel( - paths = paths, - path_list = path_list, - simulation_names = param_grid$scenario_name, - debug = FALSE) -) - -system.time( -simulation_results_optimisation <- kwb.raindrop::add_overflow_events_and_waterbalance( - simulation_results = simulation_results, - event_separation_hours = 4, - canonical_variables = kwb.raindrop::default_canonical_wb_variables() - ) -) +# Each run was already thinned to its optimisation row inside run_one(), so we +# just bind the per-run rows here instead of re-reading every run's full +# results into memory. (The previous get_simulation_results_optim_parallel() + +# add_overflow_events_and_waterbalance() pass loaded all runs at once.) +simulation_results_optimisation <- dplyr::bind_rows(scenario_rows) simulation_results_optimisation <- param_grid %>% dplyr::left_join(simulation_results_optimisation, @@ -407,6 +490,16 @@ htmlwidgets::saveWidget(DT::datatable(simulation_results_optimisation, ### Plot results +# Fuer Plots/Tooltips: die an storage_type gekoppelten Speicher-Bodenparameter +# (storage_theta*) sind durch den Typ bestimmt, also redundant - sie wuerden +# nur jeden Tooltip aufblaehen. Fuer den Modelllauf oben werden sie gebraucht, +# ab hier nicht mehr. storage_volume_m3 bekommt im Tooltip eine eigene Zeile +# (aus den Ergebnisdaten) und fliegt hier ebenfalls raus, sonst stuende es +# doppelt unter "Variierende Parameter". +param_grid <- param_grid %>% + dplyr::select(-dplyr::starts_with("storage_theta"), + -dplyr::any_of("storage_volume_m3")) + params <- c( #"connected_area", "mulde_area", @@ -416,12 +509,18 @@ params <- c( "storage_height", #"bottom_hydraulicconductivity", #"rain_factor", - "lai" + "lai", + "storage_type" ) lang <- "de" max_n_overflows <- 5 +# Kostensaetze-Caption fuer die interaktiven Kostenplots: ggplotly verwirft +# ggplot-Captions, daher wird sie dort per plotly_add_caption() nachgeruestet; +# die PDFs bekommen sie automatisch ueber den caption-Default der Funktionen. +cost_caption <- kwb.raindrop::cost_rates_caption(lang) + pdff <- sprintf("simulation_results_optimisation_%s_main-effects.pdf", paths$modelname) @@ -458,6 +557,7 @@ grDevices::pdf(pdff, width = 9, height = 4, onefile = TRUE) alpha_max = 1, drop_overflow_gt_valid_max = FALSE, keep_param_grid_limits = TRUE, + facet_storage_type = TRUE, lang = lang, subtitle = "" ) @@ -498,11 +598,15 @@ for (y in c("mulde_height", "filter_hydraulicconductivity", "storage_height")) { alpha_min = 0.25, alpha_max = 1, drop_overflow_gt_valid_max = FALSE, - keep_param_grid_limits = TRUE + keep_param_grid_limits = TRUE, + facet_storage_type = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; Farb-Legende je Ueberlaufklasse statt + # (Farbe, Form)-Tupeln - die Formen erklaeren die Panel-Beschriftungen. plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang, + add_shape_legend = FALSE) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_design-space_mulde-area_vs_%s.html", @@ -532,8 +636,10 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( use_jitter = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) + # statt der (Farbe, Form)-Tupel von ggplotly plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_water-balance.html", @@ -544,6 +650,163 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( ) # statisch ins PDF (WICHTIG!) - suppressWarnings(print(p)) + suppressWarnings(print(p)) +dev.off() + +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_overflow_volume( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. overflow volume", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + + +# Drei Kosten-Boxplots mit unterschiedlichem Optimierungsziel je Kategorie +# (Kosten als Tie-Break): (i) guenstigste; (ii) geringstes Ueberlaufvolumen +# (Label m3 + %); (iii) hoechste Evapotranspiration (Label %, Punktgroesse = +# Evapotranspiration). x = max_n_overflows, Linie ueber alle Klassen. Die beiden +# Speichertypen liegen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); die Punkte bleiben Kreise, da die Panel-Streifen +# den Typ bereits benennen. +cost_boxplots <- list( + list(suffix = "cheapest", best_by = "min_cost", + size_by = "overflow_volume", label_best = FALSE), + list(suffix = "min-overflow", best_by = "min_overflow", + size_by = "overflow_volume", label_best = TRUE), + list(suffix = "max-evap", best_by = "max_evapotranspiration", + size_by = "evapotranspiration", label_best = TRUE) +) +for (cb in cost_boxplots) { + pdff <- sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.pdf", + paths$modelname, cb$suffix) + kwb.utils::preparePdf(pdfFile = pdff) + + p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + facet_storage_type = TRUE, + size_by = cb$size_by, + best_by = cb$best_by, + label_best = cb$label_best + ) + + # interaktiv als HTML + plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) + htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.html", + paths$modelname, cb$suffix), + selfcontained = TRUE, + title = sprintf("'%s' - Cost boxplot (%s)", paths$modelname, cb$suffix) + ) + + # statisch ins PDF (WICHTIG!) + suppressWarnings(print(p)) + dev.off() +} + + +# Kosten vs. Evapotranspiration: Streudiagramm ueber den Design-Raum, Punktform +# kodiert den Speichertyp (Viereck = Sickerbox, Dreieck = Schotterrigol), +# Farbe die Anzahl Ueberlaufereignisse. +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_evaporation( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. evapotranspiration", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + + +# Kosten je Prozent Evapotranspiration [EUR/%]: Boxplot je Ueberlaufklasse mit den +# beiden Speichertypen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); guenstigstes Szenario je Box markiert (Label EUR/%), +# Punktgroesse = Evapotranspiration. +pdff <- sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + y_var = "cost_per_evap_pct", + facet_storage_type = TRUE, + size_by = "evapotranspiration", + best_by = "min_cost", + label_best = TRUE +) + +# interaktiv als HTML +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost per %% evapotranspiration", paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) dev.off() ``` \ No newline at end of file diff --git a/vignettes/workflow_eisenstadt-2005.Rmd b/vignettes/workflow_eisenstadt-2005.Rmd index 818fc01..b21d339 100644 --- a/vignettes/workflow_eisenstadt-2005.Rmd +++ b/vignettes/workflow_eisenstadt-2005.Rmd @@ -98,11 +98,48 @@ mulde_area <- c(25, 50, 75, 100, 125, 150, 175, 200) mulde_height <- c(100, 200, 300) filter_hydraulicconductivity <- c(36, 180, 360) filter_height <- 300 -storage_height <- c(100, 500, 1000) +# Storage-layer (Speicher = 2nd Bodenschichtung layer) presets per storage type. +# Each type brings its own storage_height levels plus the Speicher soil +# parameters (theta*). They are written to the model in the run loop below: +# storage_height -> Schichtdicken[2] +# Startwerte_theta_ActualSoilMoisture -> Startwerte_theta_ActualSoilMoisture[2] +# thetaWP/thetaFC/thetaS -> //Bodenarten/Speicher/theta* +storage_type <- list( + "infiltration_box" = list( + storage_height = c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.95 + ), + "gravel_trench" = list( + storage_height = 3 * c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.3 + ) +) rain_factor <- 1 bottom_hydraulicconductivity <- 12 #c(1,5,10,20,45,90,180,270,360,1860,3600) +# storage_height is coupled to storage_type (each type has its own levels), so +# build one row per (storage_type, storage_height) carrying the matching +# Speicher soil parameters, then cross-join with all remaining combinations. +storage_grid <- do.call(rbind, lapply(names(storage_type), function(type_name) { + spec <- storage_type[[type_name]] + data.frame( + storage_type = type_name, + storage_height = spec$storage_height, + storage_theta_start = spec$Startwerte_theta_ActualSoilMoisture, + storage_thetaWP = spec$thetaWP_MoistureAtWiltingPoint, + storage_thetaFC = spec$thetaFC_MoistureAtFieldCapacity, + storage_thetaS = spec$thetaS_MoistureAtSaturation, + stringsAsFactors = FALSE + ) +})) + # Alle Kombinationen erzeugen param_grid_all_combinations <- expand.grid( connected_area = connected_area, @@ -110,11 +147,20 @@ param_grid_all_combinations <- expand.grid( mulde_height = mulde_height, filter_hydraulicconductivity = filter_hydraulicconductivity, filter_height = filter_height, - storage_height = storage_height, bottom_hydraulicconductivity = bottom_hydraulicconductivity, - rain_factor = rain_factor + rain_factor = rain_factor, + stringsAsFactors = FALSE ) +# Cross-join the free parameters with the coupled storage grid. +param_grid_all_combinations <- merge(param_grid_all_combinations, storage_grid, + by = NULL) + +# Reference = first storage type at its smallest storage_height (storage_height +# is coupled to storage_type, so both are fixed together). +ref_storage_type <- names(storage_type)[1] +ref_storage_height <- min(storage_type[[ref_storage_type]]$storage_height) + param_grid_all_combinations <- param_grid_all_combinations %>% dplyr::bind_cols(tibble::tibble(scenario_name = sprintf("s%05d", seq_len(nrow(param_grid_all_combinations))))) @@ -126,7 +172,8 @@ ref_scenario <- param_grid_all_combinations %>% filter_hydraulicconductivity == min(param_grid_all_combinations$filter_hydraulicconductivity), bottom_hydraulicconductivity == min(unique(param_grid_all_combinations$bottom_hydraulicconductivity)), mulde_height == min(param_grid_all_combinations$mulde_height), - storage_height == min(param_grid_all_combinations$storage_height)) %>% + storage_type == ref_storage_type, + storage_height == ref_storage_height) %>% dplyr::pull(scenario_name) stopifnot(length(ref_scenario)==1) @@ -141,6 +188,20 @@ scenarios_with_single_parameter_variation <- kwb.raindrop::find_single_param_var # dplyr::filter(scenario_name %in% scenarios_with_single_parameter_variation) param_grid <- param_grid_all_combinations +# Nutzbares Speichervolumen der Speicherschicht [m3] = Muldenflaeche x +# Speicherhoehe x nutzbare Porositaet (thetaS - thetaFC) des Speichertyps +# (Sickerbox 0.95, Schotterrigole 0.3). thetaFC statt thetaWP: nur das +# oberhalb der Feldkapazitaet entwaesserbare Porenvolumen leert sich zwischen +# den Ereignissen und steht als Retentionsvolumen erneut zur Verfuegung; +# Wasser zwischen WP und FC haelt die Schicht gegen die Schwerkraft (in den +# Presets sind thetaFC = thetaWP = 0, beide Definitionen also identisch). +# Erscheint in der Grid-Tabelle, den Ergebnis-CSVs und im Plot-Tooltip. +param_grid <- param_grid %>% + dplyr::mutate( + storage_volume_m3 = mulde_area * storage_height / 1000 * + (storage_thetaS - storage_thetaFC) + ) + DT::datatable(param_grid, filter = "top", options = list(pageLength = 25, @@ -206,15 +267,25 @@ run_one <- function(i, vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Regen-Skalierungsfaktor` <- 1 vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` <- param_grid_tmp$mulde_area vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` <- param_grid_tmp$mulde_height - vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, 0) + vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, param_grid_tmp$storage_theta_start) vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken` <- c(param_grid_tmp$filter_height, param_grid_tmp$storage_height) + # Speicher (2nd Bodenschichtung layer) soil parameters depend on the + # storage type (infiltration_box vs. gravel_trench); see `storage_type`. + vals$`//Bodenarten/Speicher/thetaWP_MoistureAtWiltingPoint` <- param_grid_tmp$storage_thetaWP + vals$`//Bodenarten/Speicher/thetaFC_MoistureAtFieldCapacity` <- param_grid_tmp$storage_thetaFC + vals$`//Bodenarten/Speicher/thetaS_MoistureAtSaturation` <- param_grid_tmp$storage_thetaS vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate` <- param_grid_tmp$bottom_hydraulicconductivity - + # Pin LAI to the grass value from Hoernschemeyer et al. (Water 2023, + # 15, 2840, Tab. 6, plant type 5 = grasses/herbs); base.h5 ships 8.5. + # Wien and Bad Aussee already use 3.9 (Wien as one of the sweep + # levels); this line keeps Eisenstadt 2005 consistent. + vals$`//Massnahmenelemente/Mulde_Rigole/Parameter_Evapotranspiration/LAI_LeafAreaIndex` <- 3.9 + vals$`//Bodenarten/Bodenfilter/Ks_HydraulicConductivity` <- param_grid_tmp$filter_hydraulicconductivity vals$`//Bodenarten/Bodenfilter/Psi_Saugspannung_CapillarySuction` <- psi_s_mm(param_grid_tmp$filter_hydraulicconductivity) -# Timeseries (2×N) als tibble? +# Timeseries (2xN) als tibble? if (is.data.frame(vals[["//Kurven/Regen"]])) { vals[["//Kurven/Regen"]]$value <- vals[["//Kurven/Regen"]]$value * param_grid_tmp$rain_factor } @@ -228,13 +299,33 @@ if (is.data.frame(vals[["//Kurven/Regen"]])) { path_input = paths$path_target_input, debug = debug) - invisible(NULL) + # Thin immediately: read only this run's results (lean = water balance + + # overflow rates, no states/meta/connected-area rates) and reduce to the + # single optimisation row. This way we never hold all scenarios' full + # time series in memory at once; the full result HDF5 stays on disk for + # ad-hoc inspection. + sim_one <- kwb.raindrop::get_simulation_results_optim( + paths = paths, + path_list = path_list, + simulation_names = param_grid_tmp$scenario_name, + debug = debug, + lean = TRUE + ) + + kwb.raindrop::add_overflow_events_and_waterbalance( + simulation_results = sim_one, + event_separation_hours = 4, + canonical_variables = kwb.raindrop::default_canonical_wb_variables() + ) } n_cores <- parallel::detectCores() +# run_one() now returns the thinned per-run optimisation row, so run_scenarios() +# yields a list of one-row tibbles we simply bind below. +scenario_rows <- NULL system.time(expr = { -kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), +scenario_rows <- kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), run_one_scenario = run_one, timestep_hours = 0.1, debug = FALSE, @@ -264,26 +355,17 @@ x$Fehlerbeschreibung ### Analyse Results ```{r analyse_results, eval = data_available && is_windows && !is_ghactions} -system.time( -simulation_results <- kwb.raindrop::get_simulation_results_optim_parallel( - paths = paths, - path_list = path_list, - simulation_names = param_grid$scenario_name, - debug = FALSE) -) +# Each run was already thinned to its optimisation row inside run_one(), so we +# just bind the per-run rows here instead of re-reading every run's full +# results into memory. (The previous get_simulation_results_optim_parallel() + +# add_overflow_events_and_waterbalance() pass loaded all runs at once.) +simulation_results_optimisation <- dplyr::bind_rows(scenario_rows) -system.time( -simulation_results_optimisation <- kwb.raindrop::add_overflow_events_and_waterbalance( - simulation_results = simulation_results, - event_separation_hours = 4, - canonical_variables = kwb.raindrop::default_canonical_wb_variables() - ) -) - -simulation_results_optimisation <- param_grid %>% +simulation_results_optimisation <- param_grid %>% dplyr::left_join(simulation_results_optimisation, - by = c("scenario_name" = "s_name")) %>% - dplyr::relocate(scenario_name, .before = connected_area) + by = c("scenario_name" = "s_name")) %>% + dplyr::relocate(scenario_name, .before = connected_area) %>% + kwb.raindrop::compute_costs() readr::write_csv(simulation_results_optimisation, file = sprintf("simulation_results_optimisation_%s.csv", @@ -301,20 +383,36 @@ htmlwidgets::saveWidget(DT::datatable(simulation_results_optimisation, ) ### Plot results +# Fuer Plots/Tooltips: die an storage_type gekoppelten Speicher-Bodenparameter +# (storage_theta*) sind durch den Typ bestimmt, also redundant - sie wuerden +# nur jeden Tooltip aufblaehen. Fuer den Modelllauf oben werden sie gebraucht, +# ab hier nicht mehr. storage_volume_m3 bekommt im Tooltip eine eigene Zeile +# (aus den Ergebnisdaten) und fliegt hier ebenfalls raus, sonst stuende es +# doppelt unter "Variierende Parameter". +param_grid <- param_grid %>% + dplyr::select(-dplyr::starts_with("storage_theta"), + -dplyr::any_of("storage_volume_m3")) + params <- c( #"connected_area", "mulde_area", "mulde_height", "filter_hydraulicconductivity", #"filter_height", - "storage_height" + "storage_height", #"bottom_hydraulicconductivity", - #"rain_factor" + #"rain_factor", + "storage_type" ) lang <- "de" max_n_overflows <- 1 +# Kostensaetze-Caption fuer die interaktiven Kostenplots: ggplotly verwirft +# ggplot-Captions, daher wird sie dort per plotly_add_caption() nachgeruestet; +# die PDFs bekommen sie automatisch ueber den caption-Default der Funktionen. +cost_caption <- kwb.raindrop::cost_rates_caption(lang) + pdff <- sprintf("simulation_results_optimisation_%s_main-effects.pdf", paths$modelname) @@ -351,6 +449,7 @@ grDevices::pdf(pdff, width = 9, height = 4, onefile = TRUE) alpha_max = 1, drop_overflow_gt_valid_max = FALSE, keep_param_grid_limits = TRUE, + facet_storage_type = TRUE, lang = lang, subtitle = "" ) @@ -391,11 +490,15 @@ for (y in c("mulde_height", "filter_hydraulicconductivity", "storage_height")) { alpha_min = 0.25, alpha_max = 1, drop_overflow_gt_valid_max = TRUE, - keep_param_grid_limits = TRUE + keep_param_grid_limits = TRUE, + facet_storage_type = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; Farb-Legende je Ueberlaufklasse statt + # (Farbe, Form)-Tupeln - die Formen erklaeren die Panel-Beschriftungen. plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang, + add_shape_legend = FALSE) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_design-space_mulde-area_vs_%s.html", @@ -425,8 +528,10 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( use_jitter = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) + # statt der (Farbe, Form)-Tupel von ggplotly plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_water-balance.html", @@ -437,8 +542,165 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( ) # statisch ins PDF (WICHTIG!) - suppressWarnings(print(p)) + suppressWarnings(print(p)) dev.off() #kwb.utils::finishAndShowPdf(pdff) +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_overflow_volume( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. overflow volume", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + + +# Drei Kosten-Boxplots mit unterschiedlichem Optimierungsziel je Kategorie +# (Kosten als Tie-Break): (i) guenstigste; (ii) geringstes Ueberlaufvolumen +# (Label m3 + %); (iii) hoechste Evapotranspiration (Label %, Punktgroesse = +# Evapotranspiration). x = max_n_overflows, Linie ueber alle Klassen. Die beiden +# Speichertypen liegen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); die Punkte bleiben Kreise, da die Panel-Streifen +# den Typ bereits benennen. +cost_boxplots <- list( + list(suffix = "cheapest", best_by = "min_cost", + size_by = "overflow_volume", label_best = FALSE), + list(suffix = "min-overflow", best_by = "min_overflow", + size_by = "overflow_volume", label_best = TRUE), + list(suffix = "max-evap", best_by = "max_evapotranspiration", + size_by = "evapotranspiration", label_best = TRUE) +) +for (cb in cost_boxplots) { + pdff <- sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.pdf", + paths$modelname, cb$suffix) + kwb.utils::preparePdf(pdfFile = pdff) + + p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + facet_storage_type = TRUE, + size_by = cb$size_by, + best_by = cb$best_by, + label_best = cb$label_best + ) + + # interaktiv als HTML + plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) + htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.html", + paths$modelname, cb$suffix), + selfcontained = TRUE, + title = sprintf("'%s' - Cost boxplot (%s)", paths$modelname, cb$suffix) + ) + + # statisch ins PDF (WICHTIG!) + suppressWarnings(print(p)) + dev.off() +} + + +# Kosten vs. Evapotranspiration: Streudiagramm ueber den Design-Raum, Punktform +# kodiert den Speichertyp (Viereck = Sickerbox, Dreieck = Schotterrigol), +# Farbe die Anzahl Ueberlaufereignisse. +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_evaporation( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. evapotranspiration", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + + +# Kosten je Prozent Evapotranspiration [EUR/%]: Boxplot je Ueberlaufklasse mit den +# beiden Speichertypen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); guenstigstes Szenario je Box markiert (Label EUR/%), +# Punktgroesse = Evapotranspiration. +pdff <- sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + y_var = "cost_per_evap_pct", + facet_storage_type = TRUE, + size_by = "evapotranspiration", + best_by = "min_cost", + label_best = TRUE +) + +# interaktiv als HTML +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost per %% evapotranspiration", paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + ``` \ No newline at end of file diff --git a/vignettes/workflow_eisenstadt-2005_neu.Rmd b/vignettes/workflow_eisenstadt-2005_neu.Rmd deleted file mode 100644 index 5c74100..0000000 --- a/vignettes/workflow_eisenstadt-2005_neu.Rmd +++ /dev/null @@ -1,408 +0,0 @@ ---- -title: "Workflow Eisenstadt (2005, neuer Rechenkern 2026-01-22)" -output: rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Workflow Eisenstadt (2005, neuer Rechenkern 2026-01-22)} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- - -```{r, include = FALSE, eval=TRUE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -is_ghactions <- tolower(Sys.getenv("GITHUB_ACTIONS")) == "true" || - tolower(Sys.getenv("CI")) %in% c("true", "1", "yes") - -# base.h5 ships in inst/extdata/models/eisenstadt-2005/. The vignette only -# renders if that file is present; the engine .exe is fetched from the -# kwb.raindrop.binaries Release on demand and only runs on Windows. -# This vignette pins engine 2026-01-22 — that release must exist in the -# binaries repo for the run_model chunk to succeed locally on Windows. -# On CI the engine is never downloaded (preparation builds path_exe via -# the is_windows && !is_ghactions guard, and run_model itself is gated -# off with !is_ghactions). -path_base <- system.file("extdata/models/eisenstadt-2005/base.h5", package = "kwb.raindrop") -data_available <- nzchar(path_base) && file.exists(path_base) -is_windows <- Sys.info()[["sysname"]] == "Windows" -engine_version <- "2026-01-22" -``` - -### Input data - -The HDF5 model template (`base.h5`) ships with the package under -`inst/extdata/models/eisenstadt-2005/` and is produced with the -Tandler "Regenwasserbewirtschaftung" calculation engine. This vignette -pins engine version `2026-01-22` (newer Rechenkern); the engine is -downloaded from the `KWB-R/kwb.raindrop.binaries` GitHub Release on -demand via `kwb.raindrop::download_engine("2026-01-22")`. - -### Define Paths and Scenarios - -```{r preparation, eval = data_available} -library(kwb.raindrop) - -path_list <- list( - modelname = "Eisenstadt_2005", - root_path = file.path(tempdir(), "raindrop_eisenstadt_2005_neu"), - dir_input = "/models//input", - dir_output = "/models//output", - dir_target_output = "/", - file_errors_hdf5 = "Fehlerprotokoll.h5", - file_results_hdf5_element = "Mulde_Rigole.h5", - file_results_hdf5_flaeche = "Dach.h5", - file_results_hdf5_verschaltungen = "_Verschaltungen.h5", - file_results_txt = "Mulde_Rigole_RAINDROP.txt", - file_results_txt_multilayer = "Mulde_Rigole_RAINDROP_multi_layer.txt", - file_target = ".h5", - path_base = system.file("extdata/models/eisenstadt-2005/base.h5", package = "kwb.raindrop"), - path_exe = if (is_windows && !is_ghactions) kwb.raindrop::download_engine(engine_version) else NA_character_, - path_errors_hdf5 = "/", - path_results_hdf5_element = "/", - path_results_hdf5_flaeche = "/", - path_results_hdf5_verschaltungen = "/", - path_results_txt = "/", - path_results_txt_multilayer = "/", - path_target_input = "/" -) - - -parameters <- tibble::tibble( - para_nama_short = c( - "connected_area", - "mulde_area", - "mulde_height", - "filter_hydraulicconductivity", - # "filter_height", - "storage_height"#, - # "bottom_hydraulicconductivity" - ), - para_name_long = c( - "/Massnahmenelemente/Dach/Allgemein/Flaeche", - "/Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche", - "/Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe", - "Bodenarten/Bodenfilter/Ks_HydraulicConductivity", - #"/Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken", - "/Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken"#, - # "/Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate" - ), - index = c(1L, - 1L, - 1L, - 1L, - 2) -) - -DT::datatable(parameters, - filter = "top", - options = list(pageLength = 25, - autoWidth = TRUE)) - - -connected_area <- 1000 -mulde_area <- c(25, 50, 75, 100, 125, 150, 175, 200) -mulde_height <- c(100, 200, 300) -filter_hydraulicconductivity <- c(36, 180, 360) -filter_height <- 300 -storage_height <- c(100, 500, 1000) -rain_factor <- 1 -bottom_hydraulicconductivity <- 12 #c(1,5,10,20,45,90,180,270,360,1860,3600) - - -# Alle Kombinationen erzeugen -param_grid_all_combinations <- expand.grid( - connected_area = connected_area, - mulde_area = mulde_area, - mulde_height = mulde_height, - filter_hydraulicconductivity = filter_hydraulicconductivity, - filter_height = filter_height, - storage_height = storage_height, - bottom_hydraulicconductivity = bottom_hydraulicconductivity, - rain_factor = rain_factor -) - -param_grid_all_combinations <- param_grid_all_combinations %>% - dplyr::bind_cols(tibble::tibble(scenario_name = sprintf("s%05d", - seq_len(nrow(param_grid_all_combinations))))) - -ref_scenario <- param_grid_all_combinations %>% - dplyr::filter(connected_area == min(unique(param_grid_all_combinations$connected_area)), - mulde_area == min(unique(param_grid_all_combinations$mulde_area)), - filter_height == min(filter_height), - filter_hydraulicconductivity == min(param_grid_all_combinations$filter_hydraulicconductivity), - bottom_hydraulicconductivity == min(unique(param_grid_all_combinations$bottom_hydraulicconductivity)), - mulde_height == min(param_grid_all_combinations$mulde_height), - storage_height == min(param_grid_all_combinations$storage_height)) %>% - dplyr::pull(scenario_name) - -stopifnot(length(ref_scenario)==1) - -scenarios_with_single_parameter_variation <- kwb.raindrop::find_single_param_variations( - data = param_grid_all_combinations, - ref_scenario = ref_scenario - ) %>% - dplyr::pull(scenario_name) %>% unique() - -param_grid <- param_grid_all_combinations %>% - dplyr::filter(scenario_name %in% scenarios_with_single_parameter_variation) -param_grid <- param_grid_all_combinations - -DT::datatable(param_grid, - filter = "top", - options = list(pageLength = 25, - autoWidth = TRUE)) - -htmlwidgets::saveWidget(DT::datatable(parameters, - filter = "top", - options = list(pageLength = 25, - autoWidth = TRUE)), "parameters.html") -htmlwidgets::saveWidget(DT::datatable(param_grid, - filter = "top", - options = list(pageLength = 25, - autoWidth = TRUE)), "param_grid.html") - - -psi_s_mm <- function(kf_mmh) (3.237 * (kf_mmh/25.4)^(-0.328)) * 25.4 - -paths <- kwb.utils::resolve(path_list) -``` - -### Run Model - -```{r run_model, eval = data_available && is_windows && !is_ghactions} -run_one <- function(i, - timestep_hours, - debug = FALSE, - ...) { - - param_grid_tmp <- param_grid[i, ] - - paths <- kwb.utils::resolve(path_list, - dir_target = param_grid_tmp$scenario_name) - - fs::dir_create(paths$dir_input, recurse = TRUE) - fs::dir_create(paths$dir_output, recurse = TRUE) - fs::dir_create(paths$dir_target_output, recurse = TRUE) - - fs::file_copy(path = paths$path_base, - new_path = paths$path_target_input, - overwrite = TRUE) - - h5 <- hdf5r::H5File$new(paths$path_target_input, mode = "a") - - new_path <- stringr::str_c(normalizePath(fs::path_abs(paths$dir_target_output)), - "\\") - - vals <- kwb.raindrop::h5_read_values(h5) - - vals$`//Berechnungsparameter/Ergebnispfad` <- new_path - vals$`//Berechnungsparameter/Zeitschritt_Infiltration` <- timestep_hours - vals$`//Berechnungsparameter/Zeitschritt_ET` <- timestep_hours - vals$`//Berechnungsparameter/Zeitschritt_Verschaltungen` <- timestep_hours - vals$`//Berechnungsparameter/R-Plots` <- 0 - vals$`//Berechnungsparameter/Ausgabemodus` <- "Optimierung" - vals$`//Berechnungsparameter/Evapotranspiration_aktiv` <- 1 - - vals$`//Massnahmenelemente/Dach/Berechnungsparameter/Evapotranspiration_aktiv` <- 1 - vals$`//Massnahmenelemente/Dach/Allgemein/Flaeche` <- param_grid_tmp$connected_area - - vals$`//Massnahmenelemente/Mulde_Rigole/Berechnungsparameter/Evapotranspiration_aktiv` <- 1 - vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Regen-Skalierungsfaktor` <- 1 - vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` <- param_grid_tmp$mulde_area - vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` <- param_grid_tmp$mulde_height - vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, 0) - vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken` <- c(param_grid_tmp$filter_height, - param_grid_tmp$storage_height) - vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate` <- param_grid_tmp$bottom_hydraulicconductivity - - vals$`//Bodenarten/Bodenfilter/Ks_HydraulicConductivity` <- param_grid_tmp$filter_hydraulicconductivity - vals$`//Bodenarten/Bodenfilter/Psi_Saugspannung_CapillarySuction` <- psi_s_mm(param_grid_tmp$filter_hydraulicconductivity) - - kwb.raindrop::h5_write_values(h5, vals, resize = TRUE, - scalar_strategy = "error", - verbose = FALSE) - h5$close_all() - - kwb.raindrop::run_model(path_exe = paths$path_exe, - path_input = paths$path_target_input, - debug = debug) - - invisible(NULL) -} - -n_cores <- parallel::detectCores() - -system.time(expr = { -kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), - run_one_scenario = run_one, - timestep_hours = 0.1, - debug = FALSE, - parallel = TRUE, - workers = n_cores, - show_progress = TRUE - ) -} -) - -### Read results for first run -if(FALSE) { -paths <- kwb.utils::resolve(path_list, - dir_target = sprintf("s%05d", i = 1)) - -#simulation_names <- basename(fs::dir_ls(paths$dir_output)) -simulation_names <- scenarios_with_single_parameter_variation -simulation_names <- param_grid$scenario_nam -simulation_names <- simulation_names[1:8] - -debug <- TRUEn_cores -errors_df <- kwb.raindrop::read_raindrop_errors(simulation_names, path_list) -x <- tidyr::unnest(errors_df, errors) -x$Fehlerbeschreibung -} - -``` - -### Analyse Results - -```{r analyse_results, eval = data_available && is_windows && !is_ghactions} -system.time( -simulation_results <- kwb.raindrop::get_simulation_results_optim_parallel( - paths = paths, - path_list = path_list, - simulation_names = param_grid$scenario_name, - debug = FALSE) -) - -system.time( -simulation_results_optimisation <- kwb.raindrop::add_overflow_events_and_waterbalance( - simulation_results = simulation_results, - event_separation_hours = 4, - canonical_variables = kwb.raindrop::default_canonical_wb_variables() - ) -) - -simulation_results_optimisation <- param_grid %>% - dplyr::left_join(simulation_results_optimisation, - by = c("scenario_name" = "s_name")) %>% - dplyr::relocate(scenario_name, .before = connected_area) - -readr::write_csv(simulation_results_optimisation, - file = sprintf("simulation_results_optimisation_%s.csv", - paths$modelname) - ) - -htmlwidgets::saveWidget(DT::datatable(simulation_results_optimisation, - filter = "top", - options = list(pageLength = 25, - autoWidth = TRUE)), - file = sprintf("simulation_results_optimisation_%s.html", - paths$modelname), - title = "RAINDROP - Solution Space") - -### Plot results - - -params <- c( - #"connected_area", - "mulde_area", - "mulde_height", - "filter_hydraulicconductivity", - #"filter_height", - "storage_height"#, - #"bottom_hydraulicconductivity", - #"rain_factor" -) - -pdff <- sprintf("simulation_results_optimisation_%s_main-effects.pdf", - paths$modelname) - -gg <- kwb.raindrop::plot_main_effects( - df = simulation_results_optimisation, - y = "n_overflows", - params = params -) - -# --- 1) Statisch ins PDF: kein Plotly dazwischen! -kwb.utils::preparePdf(pdfFile = pdff) -print(gg) -dev.off() -#kwb.utils::finishAndShowPdf(pdff) - -# --- 2) Interaktiv als HTML: nach dem PDF -plotly_gg <- plotly::ggplotly(gg) - -htmlwidgets::saveWidget( - widget = plotly_gg, - file = sprintf("simulation_results_optimisation_%s_main-effects.html", - paths$modelname), - selfcontained = TRUE, - title = "RAINDROP - Main Effects" -) - - - -pdff <- sprintf("simulation_results_optimisation_%s_design-space_mulde-area_vs_parameters.pdf", - paths$modelname) -kwb.utils::preparePdf(pdfFile = pdff) - -for (y in c("mulde_height", "filter_hydraulicconductivity", "storage_height")) { - - p <- kwb.raindrop::plot_valid_design_space( - param_grid = param_grid, - sim_results = simulation_results_optimisation, - x = "mulde_area", - y = y, - valid_max = 1, - jitter = TRUE, - alpha_mode = "duplicates", - alpha_min = 0.25, - alpha_max = 1, - drop_overflow_gt_valid_max = TRUE, - keep_param_grid_limits = TRUE - ) - - # interaktiv als HTML - plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) - htmlwidgets::saveWidget( - widget = plotly_p, - file = sprintf("simulation_results_optimisation_%s_design-space_mulde-area_vs_%s.html", - paths$modelname, - y), - selfcontained = TRUE, - title = sprintf("Design Space: mulde_area vs. %s", y) - ) - - # statisch ins PDF (WICHTIG!) - suppressWarnings(print(p)) -} -dev.off() -#kwb.utils::finishAndShowPdf(pdff) - -pdff <- sprintf("simulation_results_optimisation_%s_water-balance.pdf", - paths$modelname) -kwb.utils::preparePdf(pdfFile = pdff) - -p <- kwb.raindrop::plot_wb_tradeoff_overflows( - simulation_results_optimisation = simulation_results_optimisation, - param_grid = param_grid, - filter_n_gt1 = TRUE, - use_jitter = TRUE - ) - - # interaktiv als HTML - plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) - htmlwidgets::saveWidget( - widget = plotly_p, - file = sprintf("simulation_results_optimisation_%s_water-balance.html", - paths$modelname), - selfcontained = TRUE, - title = "Water balance vs overflows" - ) - - # statisch ins PDF (WICHTIG!) - suppressWarnings(print(p)) -dev.off() -#kwb.utils::finishAndShowPdf(pdff) - -``` diff --git a/vignettes/workflow_optimisation.Rmd b/vignettes/workflow_optimisation.Rmd new file mode 100644 index 0000000..fbbd302 --- /dev/null +++ b/vignettes/workflow_optimisation.Rmd @@ -0,0 +1,507 @@ +--- +title: "Workflow Optimierung (Eisenstadt · Wien · Bad Aussee)" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Workflow Optimierung (Eisenstadt · Wien · Bad Aussee)} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE, eval = TRUE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + fig.width = 7.5, + fig.height = 3.2 +) +is_ghactions <- tolower(Sys.getenv("GITHUB_ACTIONS")) == "true" || + tolower(Sys.getenv("CI")) %in% c("true", "1", "yes") + +# extdata robust aufloesen: zuerst installiertes Paket, sonst Quellbaum +# (Knit direkt im Repo, auch ohne installiertes kwb.raindrop) +extdata_path <- function(...) { + p <- system.file("extdata", ..., package = "kwb.raindrop") + if (nzchar(p)) return(p) + src <- file.path("..", "inst", "extdata", ...) + if (file.exists(src)) normalizePath(src) else "" +} + +path_base <- extdata_path("models", "eisenstadt-2005", "base.h5") +data_available <- nzchar(path_base) && file.exists(path_base) +is_windows <- Sys.info()[["sysname"]] == "Windows" +can_run <- data_available && is_windows && !is_ghactions + +t_vignette_start <- Sys.time() +``` + +## Ziel + +Dieser Workflow findet für **alle drei Standorte** (Eisenstadt 2005, +Wien, Bad Aussee) die **günstigste Muldenkonfiguration je Überlaufziel** +x = 0…5 — pro Speichertyp (Sickerbox / Schotterrigol) — mit +`optimise_swale_design()` statt eines Brute-Force-Rasters. Das Verfahren +ist reine Bisektion ("Zahlenraten"): Fläche schrumpfen, dann Muldentiefe, +Speicher nur erhöhen, wenn die Fläche am Anschlag klemmt. Voraussetzung +ist die in der +[Monotonie-Analyse](https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/) +belegte Monotonie +("größer = nie mehr Überläufe"); die dort abgeleiteten Absicherungen +(Rand-Guard, Volumen-Schiedsrichter) sind in `find_min_feasible()` +eingebaut. Eine unabhängige **Gegenprobe ohne Monotonie-Annahme** — +simultane Optimierung aller Parameter, plus weitere Suchverfahren zum +Vergleich — führt die separate Vignette +`workflow_optimisation_simultaneous` durch; sie liest die hier +exportierte CSV als Vergleichsbasis. + +Die Filterdurchlässigkeit wird fest auf das Maximum gesetzt (kostenfrei +dominant: gleiche Verdunstung, nie mehr Überläufe), Fläche und +Muldentiefe werden stufenlos gesucht, die Sickerbox in den +Default-Stufen 300/600/900/1200 mm (`sickerbox_level_presets()` bietet +Hersteller-Alternativen), der Schotterrigol stufenlos im 3-fachen +Box-Bereich. + +**Laufzeit:** Ein Engine-Lauf dauert ~2 s (Eisenstadt, 1 Jahr) bzw. +~15 s (Wien / Bad Aussee, 15-Jahres-Serien). Die 6 Tasks +(Standort × Speichertyp) laufen parallel; die Gesamtdauer entspricht +dem längsten Einzeltask — ca. 15–20 Minuten. `make_swale_runner()` +löscht die Szenario-Dateien jedes Laufs standardmäßig direkt nach dem +Einlesen (`cleanup = TRUE`), damit lange Suchen das Temp-Laufwerk nicht +füllen. + +```{r availability_note, echo = FALSE, results = 'asis', eval = !can_run} +cat(sprintf(paste0( + "> **Hinweis:** Die Rechen-Chunks wurden übersprungen. Prüfungen: ", + "Windows: %s · CI/GitHub Actions: %s · base.h5 gefunden: %s (%s). ", + "Auf einem lokalen Windows-Rechner sollten alle drei Bedingungen ", + "erfüllt sein — falls base.h5 fehlt: Vignette aus dem Paket-Repo ", + "heraus rendern oder das Paket installieren.\n"), + is_windows, is_ghactions, data_available, + if (nzchar(path_base)) path_base else "weder installiert noch ../inst/extdata" +)) +``` + +## Standort-Konfiguration + +Die Standorte unterscheiden sich nur in zwei Punkten: dem +Modell-Template (`base.h5`) und der Frage, ob eigene Regen-/ET0-Zeitreihen +in die Engine geschrieben werden (Wien und Bad Aussee) oder die Kurven +aus `base.h5` verwendet werden (Eisenstadt). Beides kapselt +`make_swale_runner()`; die Zeitreihen-Aufbereitung (mm/h-Konvention, +Serien-Angleichung) übernimmt `read_site_timeseries()`. + +```{r site_config, eval = can_run} +# Im Quellbaum die Entwicklungsversion laden (immer aktuell, auch wenn +# das installierte Paket aelter ist); ausserhalb: installiertes Paket. +if (file.exists("../DESCRIPTION") && + requireNamespace("pkgload", quietly = TRUE)) { + pkgload::load_all("..", quiet = TRUE) +} else { + library(kwb.raindrop) +} + +sites <- list( + Eisenstadt_2005 = list(dir = "eisenstadt-2005", timeseries = FALSE, + prior = "simulation_results_optimisation_Eisenstadt_2005.csv"), + Wien = list(dir = "wien", timeseries = TRUE, + prior = "simulation_results_optimisation_Wien.csv"), + BadAussee = list(dir = "badaussee", timeseries = TRUE, + prior = "simulation_results_optimisation_BadAussee.csv") +) + +fixed <- list(connected_area = 1000, + filter_height = 300, + filter_hydraulicconductivity = 360, # Rastermaximum, gratis + bottom_hydraulicconductivity = 12) + +make_path_list <- function(modelname, model_dir) { + list( + modelname = modelname, + root_path = file.path(tempdir(), paste0("raindrop_opt_", model_dir)), + dir_input = "/models//input", + dir_output = "/models//output", + dir_target_output = "/", + file_errors_hdf5 = "Fehlerprotokoll.h5", + file_results_hdf5_element = "Mulde_Rigole.h5", + file_results_hdf5_flaeche = "Dach.h5", + file_results_hdf5_verschaltungen = "_Verschaltungen.h5", + file_results_txt = "Mulde_Rigole_RAINDROP.txt", + file_results_txt_multilayer = "Mulde_Rigole_RAINDROP_multi_layer.txt", + file_target = ".h5", + path_base = extdata_path("models", model_dir, "base.h5"), + path_exe = download_engine(), + path_errors_hdf5 = "/", + path_results_hdf5_element = "/", + path_results_hdf5_flaeche = "/", + path_results_hdf5_verschaltungen = "/", + path_results_txt = "/", + path_results_txt_multilayer = "/", + path_target_input = "/" + ) +} +``` + +## Suchraum und Kostensätze + +Die Suchbereiche der variablen Parameter sind die Default-Argumente von +`optimise_swale_design()` — bewusst identisch mit den min/max-Bereichen +des Brute-Force-Rasters, denn nur dort ist die Monotonie geprüft. Hier +stehen sie explizit, damit sie sichtbar und anpassbar sind: + +```{r search_space, eval = can_run} +area_bounds <- c(25, 200) # Muldenflaeche [m2], stufenlos +area_tol <- 2 # Aufloesung der Flaechensuche [m2]; + # jede Halbierung kostet nur 1 Lauf je + # Flaechensuche und halbiert den + # toleranzbedingten Kostenueberschuss +height_bounds <- c(100, 300) # Muldentiefe [mm], stufenlos +height_tol <- 10 # Aufloesung der Tiefensuche [mm] +storage_spec <- default_storage_spec() +# Alternativ Hersteller-Stufen, z. B.: +# storage_spec <- default_storage_spec( +# levels = sickerbox_level_presets()$graf_ecobloc_smart) + +knitr::kable(tibble::tibble( + Parameter = c("mulde_area [m2]", "mulde_height [mm]", + "storage_height Sickerbox [mm]", + "storage_height Schotterrigol [mm]", + "filter_hydraulicconductivity [mm/h]", + "filter_height [mm]"), + Suchraum = c( + sprintf("stufenlos %g bis %g (Toleranz %g)", + area_bounds[1], area_bounds[2], area_tol), + sprintf("stufenlos %g bis %g (Toleranz %g)", + height_bounds[1], height_bounds[2], height_tol), + paste("Stufen:", + paste(storage_spec$infiltration_box$levels, collapse = " / ")), + sprintf("stufenlos %g bis %g (Toleranz %g)", + storage_spec$gravel_trench$bounds[1], + storage_spec$gravel_trench$bounds[2], + storage_spec$gravel_trench$tol), + "fix = 360 (Rastermaximum; kostenfrei dominant)", + "fix = 300" + ) +)) +``` + +Die Kostensätze sind die Defaults nach Leimgruber (2026-03-27, +`default_cost_rates()`); einzelne Sätze lassen sich hier überschreiben — +gerechnet wird zunächst mit den Defaults. Die Suchreihenfolge der +Bisektion wird dabei **aus den Sätzen hergeleitet** (Spezifikkosten-Proxy: +€ je mm Speicherkapazität, Kapazitätsmodell V ≈ Fläche × (Tiefe + +Porosität × Speicher); die Porosität liefert `default_storage_spec()`): +Die Muldentiefe zu maximieren ist unter diesem Kostenmodell für *jeden* +Satz optimal, die Start-Speicherstufe wählt der Proxy — kleinste Stufe +bei den Default-Sätzen, hohe Stufe z. B. bei billigem Speichermaterial. +Der Proxy ist eine Näherung erster Ordnung (kapazitäts-additive Hebel); +die annahmefreie Gegenprobe — und das Mittel der Wahl bei nichtlinearen +Hebeln — bleibt die Vignette `workflow_optimisation_simultaneous`: + +```{r cost_rates, eval = can_run} +cost_rates <- default_cost_rates() +# Beispiel fuer eine Anpassung (auskommentiert): +# cost_rates$excavation_eur_per_m3 <- 85 +# cost_rates$infiltration_box_eur_per_m3 <- 400 + +knitr::kable( + tibble::tibble(Kostensatz = names(cost_rates), + `EUR je m2/m3` = unlist(cost_rates)), + caption = "Kostensaetze (inkl. Einbau)" +) +``` + +## Optimierung aller Standorte (parallel) + +Parallelisiert wird über **Standort × Speichertyp = 6 unabhängige +Tasks**: Die Bisektion innerhalb einer Suche ist prinzipbedingt +sequentiell, und die x-Ziele eines Speichertyps teilen sich den +Evaluations-Cache — aber Box- und Rigol-Läufe teilen keinen einzigen +Engine-Lauf (der Cache-Schlüssel enthält den Typ). Jeder Worker ist ein +eigener R-Prozess mit eigenem `tempdir()`, Kollisionen sind damit +ausgeschlossen. Die Wall-Time entspricht dem längsten Einzeltask +(Wien/Rigol, ~15–20 min) statt der Summe aller Tasks (~65 min). + +Liegen die Rasterergebnisse der Workflow-Vignetten als CSV neben dieser +Vignette, verengen sie als Warmstart die erste Flächensuche auf einen +25-m²-Rasterschritt. + +```{r optimise_all, eval = can_run} +t_opt_start <- Sys.time() + +tasks <- expand.grid(site = names(sites), type = names(storage_spec), + stringsAsFactors = FALSE) + +future::plan(future::multisession, + workers = min(nrow(tasks), + max(1, parallel::detectCores() - 1))) + +opt_list <- future.apply::future_lapply(seq_len(nrow(tasks)), function(i) { + site <- tasks$site[i] + type <- tasks$type[i] + + # Worker = eigener Prozess: Paket dort genauso laden wie im Hauptprozess + if (file.exists("../DESCRIPTION") && + requireNamespace("pkgload", quietly = TRUE)) { + pkgload::load_all("..", quiet = TRUE) + } else { + library(kwb.raindrop) + } + + cfg <- sites[[site]] + ts <- if (cfg$timeseries) { + read_site_timeseries( + extdata_path("models", cfg$dir, "rain.csv.gz"), + extdata_path("models", cfg$dir, "et.csv"), + verbose = FALSE + ) + } else { + NULL + } + + run_fn <- make_swale_runner(make_path_list(site, cfg$dir), + timeseries_rain = ts$rain, + timeseries_et = ts$et) + + prior <- if (file.exists(cfg$prior)) { + readr::read_csv(cfg$prior, show_col_types = FALSE) + } else { + NULL + } + + t0 <- Sys.time() + opt <- optimise_swale_design(run_fn, x_targets = 0:5, + area_bounds = area_bounds, + area_tol = area_tol, + height_bounds = height_bounds, + height_tol = height_tol, + storage_spec = storage_spec[type], + fixed = fixed, + prior_results = prior, + cost_rates = cost_rates, + verbose = FALSE) + opt$site <- site + opt$n_runs_task <- attr(opt, "n_runs_total") + opt$minutes_task <- round(as.numeric( + difftime(Sys.time(), t0, units = "mins")), 1) + opt +}, future.seed = TRUE) + +future::plan(future::sequential) +opt_all <- dplyr::bind_rows(opt_list) + +t_opt_end <- Sys.time() +``` + +## Ergebnis: günstigstes Design je Standort und Überlaufziel + +```{r results_table, eval = can_run} +knitr::kable( + opt_all[, c("site", "x", "storage_type", "status", "mulde_area", + "mulde_height", "storage_height", "n_overflows", + "overflow_volume_m3", "et_pct", "cost_total")], + digits = c(NA, 0, NA, NA, 1, 0, 0, 0, 1, 1, 0) +) +``` + +```{r cost_curves, eval = can_run, fig.height = 3.4} +library(ggplot2) + +ok <- opt_all[opt_all$status == "ok", ] +ggplot(ok, aes(x, cost_total / 1000, colour = storage_type)) + + geom_line() + + geom_point(size = 2) + + facet_wrap(~ site, scales = "free_y") + + scale_x_continuous(breaks = 0:5) + + labs(title = "Kosten-Wirksamkeits-Kurven: Was kostet ein Ueberlauf weniger?", + x = "Ueberlaufziel x (zulaessige Ereignisse)", + y = "Kosten Optimum [Tsd. EUR]", + colour = "Speichertyp", + caption = cost_rates_caption("de", cost_rates)) + + theme_bw() +``` + +```{r export, eval = can_run} +readr::write_csv(opt_all, "optimisation_results_all_sites.csv") +for (site in unique(opt_all$site)) { + readr::write_csv(opt_all[opt_all$site == site, ], + sprintf("optimisation_results_%s.csv", site)) +} + +task_stats <- unique(opt_all[, c("site", "storage_type", "n_runs_task", + "minutes_task")]) +knitr::kable( + task_stats, + col.names = c("Standort", "Speichertyp", "Engine-Laeufe", "Minuten") +) +``` + +```{r runtime_total, echo = FALSE, results = 'asis', eval = can_run} +opt_wall_min <- as.numeric(difftime(t_opt_end, t_opt_start, units = "mins")) +cat(sprintf(paste0( + "**Gesamtlaufzeit Optimierung:** %d Engine-Läufe · Summe der ", + "Task-Zeiten %.1f min · tatsächliche Laufzeit %.1f min ", + "(paralleler Speedup %.1f×).\n"), + sum(task_stats$n_runs_task), sum(task_stats$minutes_task), + opt_wall_min, sum(task_stats$minutes_task) / max(opt_wall_min, 0.1) +)) +``` + +## Monte-Carlo-Analyse: Wie robust ist die Suche selbst? + +Die Bisektion ist deterministisch: gleiche Eingaben, gleicher Pfad, +gleiches Ergebnis. Die Monte-Carlo-Frage lautet deshalb: **Hängt das +gefundene Optimum vom Suchpfad ab?** Dazu wird der Teilungspunkt jeder +Bisektion zufällig verschoben (`split_jitter = 0.3`: Teilung zufällig +zwischen 20 % und 80 % des Intervalls statt exakt mittig) und die +Optimierung `n_mc`-mal mit unterschiedlichen Seeds wiederholt — **Regen, +Kostensätze und alle übrigen Eingaben bleiben unverändert** (x = 1, ohne +Warmstart, damit jede Wiederholung den vollen Suchraum durchläuft). +Gerechnet wird der volle Pool **3 Standorte × 2 Speichertypen × +`n_mc` Wiederholungen = 60 unabhängige Tasks**, alle parallel (je Task +eine komplette Neu-Optimierung mit ~15 Engine-Läufen; Wall-Time je nach +Kernzahl ~20–35 min). Erwartung, wenn das Optimum eine +Eigenschaft des Problems ist — und nicht des Wegs, den die Suche +genommen hat: **identische Speicherstufe, Flächen-Spanne ≤ +2 × `area_tol`, Kostenspanne von wenigen Prozent**. Die Muldentiefe +darf etwas weiter streuen als 2 × `height_tol`: Sie ist über die +Hydraulik an die gefundene Fläche gekoppelt (eine um `area_tol` größere +Fläche erlaubt eine entsprechend geringere Tiefe), sodass sich dort die +Toleranzen beider Suchen addieren. + +```{r mc_config, eval = can_run} +n_mc <- 10 +mc_seeds <- 1:n_mc +``` + +```{r mc_search, eval = can_run} +t_mc_start <- Sys.time() + +mc_tasks <- expand.grid(site = names(sites), type = names(storage_spec), + rep = seq_len(n_mc), stringsAsFactors = FALSE) + +future::plan(future::multisession, + workers = min(nrow(mc_tasks), + max(1, parallel::detectCores() - 1))) + +mc_search <- future.apply::future_lapply(seq_len(nrow(mc_tasks)), function(i) { + site <- mc_tasks$site[i] + type <- mc_tasks$type[i] + rep <- mc_tasks$rep[i] + + if (file.exists("../DESCRIPTION") && + requireNamespace("pkgload", quietly = TRUE)) { + pkgload::load_all("..", quiet = TRUE) + } else { + library(kwb.raindrop) + } + set.seed(mc_seeds[rep]) + + cfg <- sites[[site]] + ts <- if (cfg$timeseries) { + read_site_timeseries( + extdata_path("models", cfg$dir, "rain.csv.gz"), + extdata_path("models", cfg$dir, "et.csv"), + verbose = FALSE + ) + } else { + NULL + } + run_fn <- make_swale_runner(make_path_list(paste0(site, "_MC"), cfg$dir), + timeseries_rain = ts$rain, + timeseries_et = ts$et) + + opt <- optimise_swale_design( + run_fn, x_targets = 1, + area_bounds = area_bounds, area_tol = area_tol, + height_bounds = height_bounds, height_tol = height_tol, + storage_spec = storage_spec[type], + fixed = fixed, + split_jitter = 0.3, + cost_rates = cost_rates, verbose = FALSE + ) + opt$site <- site + opt$rep <- rep + opt$n_runs_rep <- attr(opt, "n_runs_total") + opt +}, future.seed = TRUE) + +future::plan(future::sequential) +mc_search <- dplyr::bind_rows(mc_search) + +t_mc_end <- Sys.time() +``` + +```{r mc_search_summary, eval = can_run, fig.height = 3.4} +ok_mc <- mc_search[mc_search$status == "ok", ] + +ggplot(ok_mc, aes(rep, cost_total / 1000, colour = storage_type)) + + geom_point(size = 2) + + facet_wrap(~ site, scales = "free_y") + + scale_x_continuous(breaks = seq_len(n_mc)) + + labs(title = sprintf( + "Such-Monte-Carlo (x = 1, %d zufaellige Suchpfade je Standort und Typ)", + n_mc), + x = "Wiederholung (Seed)", + y = "Kosten Optimum [Tsd. EUR]", + colour = "Speichertyp") + + theme_bw() + +knitr::kable( + ok_mc %>% + dplyr::group_by(site, storage_type) %>% + dplyr::summarise( + flaeche_spanne_m2 = max(mulde_area) - min(mulde_area), + tiefe_spanne_mm = max(mulde_height) - min(mulde_height), + speicher_identisch = dplyr::n_distinct(storage_height) == 1, + kosten_min = min(cost_total), + kosten_max = max(cost_total), + kosten_spanne_pct = round(100 * (max(cost_total) - min(cost_total)) / + min(cost_total), 2), + .groups = "drop" + ), + digits = 1, + caption = paste("Streuung ueber die Suchpfade -- erwartet: identische", + "Speicherstufe, Flaechen-Spanne <= 2 x area_tol,", + "Kostenspanne wenige Prozent; die Tiefe streut wegen", + "der Flaechen-Kopplung weiter") +) +``` + +```{r mc_runtime, echo = FALSE, results = 'asis', eval = can_run} +cat(sprintf(paste0( + "**Laufzeit Such-Monte-Carlo:** %d Engine-Läufe in %.1f min ", + "(parallel über %d Tasks: %d Standorte × %d Speichertypen × ", + "%d Wiederholungen).\n"), + sum(mc_search$n_runs_rep), + as.numeric(difftime(t_mc_end, t_mc_start, units = "mins")), + nrow(mc_tasks), length(sites), length(storage_spec), n_mc +)) +``` + +## Einordnung + +- **Plausibilität:** Die Optima müssen auf oder knapp unter den + günstigsten zulässigen Rasterzellen liegen (Warmstart-Tabelle der + Monotonie-Analyse); die Kostenkurven müssen monoton fallen. + `monotonicity_warning = TRUE` in einer Zeile hieße: Zähler *und* + Volumen sind gemeinsam gestiegen — dann Branch prüfen. +- **Wien x = 0** ist der Stresstest: Im Raster war das Ziel mit + minimalem Speicher teils unerreichbar — hier greift die + Speicher-Eskalation automatisch; "infeasible_within_bounds" wäre ein + reguläres Ergebnis, kein Fehler. +- **Bad Aussee x = 1** trägt den bekannten +1-Zählwobble am Rasterrand; + der Rand-Guard in `find_min_feasible()` deckt ihn ab. +- **Nebenbedingung Tiefe:** `max_total_depth` (mm) begrenzt + Muldentiefe + Filter + Speicher analytisch (DWA-A 138 / Überdeckung), + ohne zusätzliche Läufe. + +```{r vignette_runtime, echo = FALSE, results = 'asis', eval = can_run} +cat(sprintf(paste0( + "---\n\n**Gesamtlaufzeit dieser Vignette:** %.1f Minuten ", + "(Optimierung %.1f min · Such-Monte-Carlo %.1f min · ", + "Rest: Setup und Rendern).\n"), + as.numeric(difftime(Sys.time(), t_vignette_start, units = "mins")), + opt_wall_min, + as.numeric(difftime(t_mc_end, t_mc_start, units = "mins")) +)) +``` + diff --git a/vignettes/workflow_optimisation_simultaneous.Rmd b/vignettes/workflow_optimisation_simultaneous.Rmd new file mode 100644 index 0000000..7ab14dd --- /dev/null +++ b/vignettes/workflow_optimisation_simultaneous.Rmd @@ -0,0 +1,507 @@ +--- +title: "Workflow Optimierung — Simultane Suche (alle Parameter gleichzeitig)" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Workflow Optimierung — Simultane Suche (alle Parameter gleichzeitig)} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE, eval = TRUE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + fig.width = 7.5, + fig.height = 3.2 +) +is_ghactions <- tolower(Sys.getenv("GITHUB_ACTIONS")) == "true" || + tolower(Sys.getenv("CI")) %in% c("true", "1", "yes") + +# extdata robust aufloesen: zuerst installiertes Paket, sonst Quellbaum +# (Knit direkt im Repo, auch ohne installiertes kwb.raindrop) +extdata_path <- function(...) { + p <- system.file("extdata", ..., package = "kwb.raindrop") + if (nzchar(p)) return(p) + src <- file.path("..", "inst", "extdata", ...) + if (file.exists(src)) normalizePath(src) else "" +} + +path_base <- extdata_path("models", "eisenstadt-2005", "base.h5") +data_available <- nzchar(path_base) && file.exists(path_base) +is_windows <- Sys.info()[["sysname"]] == "Windows" +can_run <- data_available && is_windows && !is_ghactions + +t_vignette_start <- Sys.time() +``` + +## Ziel + +Die Vignette `workflow_optimisation` findet die günstigste +Muldenkonfiguration je Überlaufziel per **Bisektion**: Parameter +nacheinander, gestützt auf die Monotonie je Parameter +([Monotonie-Analyse](https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/)). +Diese Vignette ist die **unabhängige +Gegenprobe**: `optimise_swale_design_simultaneous()` optimiert **alle +Parameter gleichzeitig** — Fläche, Muldentiefe und Speicherhöhe in einem +Zug — und kommt dabei *ohne* die Monotonie-Annahme aus. + +Warum das funktioniert: Unzulässige Designs (`n_overflows > x`) werden +nicht ausgeschlossen, sondern **bestraft** — jedes unzulässige Design ist +teurer als jedes zulässige, und überzählige Überlaufereignisse staffeln +die Strafe. Das Optimum liegt (Kosten steigen monoton mit jedem +Parameter) genau *auf* der Zulässigkeitsgrenze; die Straffunktion erlaubt +der Suche, diese Grenze zu überqueren, auf beiden Seiten Information zu +sammeln und die Parameter in einem einzigen Schritt gegeneinander zu +tauschen (z. B. weniger Fläche gegen mehr Speicher). Ein Ausschluss +unzulässiger Punkte würde der Suche jenseits der Grenze jede +Richtungsinformation nehmen — sie würde blind an der Grenze abprallen, +statt an ihr entlangzuwandern. + +Drei Suchverfahren teilen sich dieselbe Infrastruktur (Straf-Ziel, +Evaluations-Cache, Toleranz-Rasterung, Multi-Tal-Feinschliff) und +unterscheiden sich nur darin, wie sie Kandidaten vorschlagen +(`method`-Argument): + +* **`nelder_mead`** (Default, Empfehlung): Multistart-Simplex + (`stats::optim()`) — Warmstart, Optimum des vorigen x-Ziels, je ein + Anker-Start pro Speicherstufe, Raumfüller; jeder Start erhält einen + gleichen Anteil am Laufbudget (`max_evals`). +* **`diff_evolution`**: kompakte Differential Evolution (DE/rand/1/bin) + — Vergleichsverfahren; deterministisch über einen internen + Park-Miller-Generator (`seed`-Argument), Rs globaler Zufallsstrom + (`.Random.seed`) bleibt unangetastet. +* **`halton_search`**: quasi-zufällige, raumfüllende Halton-Stichprobe — + bewusst naive Baseline, die zeigt, was die strukturierten Verfahren + schlagen müssen. + +**Laufzeit:** Die simultane Suche braucht je (Speichertyp, x)-Zelle mehr +Engine-Läufe als die Bisektion (typisch 60–120 statt ~15; Suchphase plus +Feinschliff). Über 6 Überlaufziele summiert sich das auf ~400–700 +Engine-Läufe **je Task**; bei ~15 s je Lauf (Wien / Bad Aussee) sind das +**2–3 h je Task** — und wenn weniger freie Kerne als 6 Tasks verfügbar +sind, entsprechend mehr Wandzeit (realistisch **2–5 h** für den +Nelder-Mead-Sweep). Eisenstadt (~2 s je Lauf) bleibt bei Minuten. Beide +Rechen-Chunks zeigen deshalb einen **Live-Fortschrittsbalken** (ein Tick +je Engine-Lauf, über `progressr` aus den Worker-Prozessen heraus) — ein +über Minuten stehender Balken wäre ein echter Hänger, ein langsam +wandernder ist Normalbetrieb. Der Methodenvergleich am Ende rechnet nur +eine Zelle (x = 1) je Standort und Speichertyp. Wer zuerst einen +schnellen Funktionstest will, rechnet nur Eisenstadt (Kommentar im +Chunk `site_config`); der Laufzeit-Hebel für den vollen Sweep ist +`max_evals` (Kommentar im selben Chunk). + +**Plattenplatz:** Jeder Engine-Lauf legt ein eigenes Szenario an (Kopie +der `base.h5` plus Output-HDF5s). `make_swale_runner()` löscht diese +Dateien standardmäßig direkt nach dem Einlesen der dünnen Ergebniszeile +(`cleanup = TRUE`) — ohne dieses Aufräumen füllen mehrere hundert Läufe +je Task das Temp-Laufwerk und die Engine bricht mit `No space left on +device` ab. Vor einem Neustart nach einem solchen Abbruch die +`raindrop_sim_*`-Verzeichnisse unter `tempdir()` bzw. +`%LOCALAPPDATA%\Temp` (`Rtmp*`) löschen. + +```{r availability_note, echo = FALSE, results = 'asis', eval = !can_run} +cat(sprintf(paste0( + "> **Hinweis:** Die Rechen-Chunks wurden übersprungen. Prüfungen: ", + "Windows: %s · CI/GitHub Actions: %s · base.h5 gefunden: %s (%s). ", + "Auf einem lokalen Windows-Rechner sollten alle drei Bedingungen ", + "erfüllt sein — falls base.h5 fehlt: Vignette aus dem Paket-Repo ", + "heraus rendern oder das Paket installieren.\n"), + is_windows, is_ghactions, data_available, + if (nzchar(path_base)) path_base else "weder installiert noch ../inst/extdata" +)) +``` + +## Standort-Konfiguration + +Identisch mit der Vignette `workflow_optimisation` (gleiche Standorte, +gleiche Suchräume, gleiche Kostensätze — nur so ist der Vergleich +aussagekräftig): + +```{r site_config, eval = can_run} +# Im Quellbaum die Entwicklungsversion laden (immer aktuell, auch wenn +# das installierte Paket aelter ist); ausserhalb: installiertes Paket. +if (file.exists("../DESCRIPTION") && + requireNamespace("pkgload", quietly = TRUE)) { + pkgload::load_all("..", quiet = TRUE) +} else { + library(kwb.raindrop) +} + +sites <- list( + Eisenstadt_2005 = list(dir = "eisenstadt-2005", timeseries = FALSE, + prior = "simulation_results_optimisation_Eisenstadt_2005.csv"), + Wien = list(dir = "wien", timeseries = TRUE, + prior = "simulation_results_optimisation_Wien.csv"), + BadAussee = list(dir = "badaussee", timeseries = TRUE, + prior = "simulation_results_optimisation_BadAussee.csv") +) +# Schneller Funktionstest (~15-30 min statt Stunden): nur Eisenstadt -- +# 1-Jahres-Modell, ~2 s je Engine-Lauf. Zusaetzlich beschleunigt ein +# reduziertes Budget (z. B. max_evals <- 50) und, auf Windows deutlich, +# eine Virenscanner-Ausnahme fuer %LOCALAPPDATA%\Temp (jede Szenario- +# Datei und jeder Engine-Start wird sonst einzeln gescannt): +sites <- sites["Eisenstadt_2005"] + +fixed <- list(connected_area = 1000, + filter_height = 300, + filter_hydraulicconductivity = 360, # Rastermaximum, gratis + bottom_hydraulicconductivity = 12) + +area_bounds <- c(25, 200) # Muldenflaeche [m2], stufenlos +area_tol <- 2 # Aufloesung der Flaechensuche [m2] +height_bounds <- c(100, 300) # Muldentiefe [mm], stufenlos +height_tol <- 10 # Aufloesung der Tiefensuche [mm] +storage_spec <- default_storage_spec() +cost_rates <- default_cost_rates() +max_evals <- 80 # Laufzeit-Hebel: Budget an frischen + # Engine-Laeufen je Zelle (Suchphase; + # der Feinschliff kommt obendrauf) + +make_path_list <- function(modelname, model_dir) { + list( + modelname = modelname, + root_path = file.path(tempdir(), paste0("raindrop_sim_", model_dir)), + dir_input = "/models//input", + dir_output = "/models//output", + dir_target_output = "/", + file_errors_hdf5 = "Fehlerprotokoll.h5", + file_results_hdf5_element = "Mulde_Rigole.h5", + file_results_hdf5_flaeche = "Dach.h5", + file_results_hdf5_verschaltungen = "_Verschaltungen.h5", + file_results_txt = "Mulde_Rigole_RAINDROP.txt", + file_results_txt_multilayer = "Mulde_Rigole_RAINDROP_multi_layer.txt", + file_target = ".h5", + path_base = extdata_path("models", model_dir, "base.h5"), + path_exe = download_engine(), + path_errors_hdf5 = "/", + path_results_hdf5_element = "/", + path_results_hdf5_flaeche = "/", + path_results_hdf5_verschaltungen = "/", + path_results_txt = "/", + path_results_txt_multilayer = "/", + path_target_input = "/" + ) +} + +# Ein Task = eine komplette Optimierung (Standort x Speichertyp x +# Methode); gekapselt, damit Haupt-Sweep und Methodenvergleich denselben +# Code nutzen. tick/tick_cap: progressr-Fortschritt ueber die +# Worker-Grenze hinweg -- ein Tick je Engine-Lauf, am Task-Ende wird +# der Rest des Task-Kontingents aufgefuellt, damit der Balken exakt +# bei 100 % endet. +run_simultaneous_task <- function(site, type, method, x_targets, + model_suffix, + tick = NULL, tick_cap = Inf) { + if (file.exists("../DESCRIPTION") && + requireNamespace("pkgload", quietly = TRUE)) { + pkgload::load_all("..", quiet = TRUE) + } else { + library(kwb.raindrop) + } + + cfg <- sites[[site]] + ts <- if (cfg$timeseries) { + read_site_timeseries( + extdata_path("models", cfg$dir, "rain.csv.gz"), + extdata_path("models", cfg$dir, "et.csv"), + verbose = FALSE + ) + } else { + NULL + } + + run_fn <- make_swale_runner( + make_path_list(paste0(site, "_", model_suffix), cfg$dir), + timeseries_rain = ts$rain, + timeseries_et = ts$et + ) + + ticks_sent <- 0L + if (!is.null(tick)) { + inner_fn <- run_fn + run_fn <- function(params) { + if (ticks_sent < tick_cap) { + ticks_sent <<- ticks_sent + 1L + tick(sprintf("%s | %s | Lauf %d", site, type, ticks_sent)) + } + inner_fn(params) + } + } + + prior <- if (file.exists(cfg$prior)) { + readr::read_csv(cfg$prior, show_col_types = FALSE) + } else { + NULL + } + + t0 <- Sys.time() + opt <- optimise_swale_design_simultaneous( + run_fn, x_targets = x_targets, + area_bounds = area_bounds, area_tol = area_tol, + height_bounds = height_bounds, height_tol = height_tol, + storage_spec = storage_spec[type], + fixed = fixed, + prior_results = prior, + method = method, + max_evals = max_evals, + cost_rates = cost_rates, + verbose = FALSE + ) + opt$site <- site + opt$n_runs_task <- attr(opt, "n_runs_total") + opt$minutes_task <- round(as.numeric( + difftime(Sys.time(), t0, units = "mins")), 1) + if (!is.null(tick) && is.finite(tick_cap) && tick_cap > ticks_sent) { + tick(sprintf("%s | %s fertig (%d Laeufe, %.1f min)", + site, type, opt$n_runs_task[1], opt$minutes_task[1]), + amount = tick_cap - ticks_sent) + } + opt +} +``` + +## Simultane Optimierung aller Standorte (Nelder-Mead, parallel) + +Wie im Bisektions-Workflow laufen **Standort × Speichertyp = 6 +unabhängige Tasks** parallel; innerhalb eines Tasks teilen sich die +x-Ziele den Evaluations-Cache. + +```{r optimise_all, eval = can_run} +t_nm_start <- Sys.time() + +tasks <- expand.grid(site = names(sites), type = names(storage_spec), + stringsAsFactors = FALSE) + +future::plan(future::multisession, + workers = min(nrow(tasks), + max(1, parallel::detectCores() - 1))) + +# Live-Fortschritt ueber die Worker hinweg: 1 Tick = 1 Engine-Lauf +# (tick_cap = grosszuegiges Kontingent je Task; der Rest wird am +# Task-Ende aufgefuellt, der Balken endet also exakt bei 100 %) +progressr::handlers(progressr::handler_txtprogressbar()) +tick_cap_nm <- 1000 + +nm_all <- progressr::with_progress({ + p <- progressr::progressor(steps = nrow(tasks) * tick_cap_nm) + nm_list <- future.apply::future_lapply(seq_len(nrow(tasks)), function(i) { + run_simultaneous_task(tasks$site[i], tasks$type[i], + method = "nelder_mead", x_targets = 0:5, + model_suffix = "NM", + tick = p, tick_cap = tick_cap_nm) + }, future.seed = TRUE) + dplyr::bind_rows(nm_list) +}) + +future::plan(future::sequential) + +t_nm_end <- Sys.time() +``` + +## Ergebnis: günstigstes Design je Standort und Überlaufziel + +```{r results_table, eval = can_run} +knitr::kable( + nm_all[, c("site", "x", "storage_type", "status", "mulde_area", + "mulde_height", "storage_height", "n_overflows", + "overflow_volume_m3", "et_pct", "cost_total")], + digits = c(NA, 0, NA, NA, 1, 0, 0, 0, 1, 1, 0) +) +``` + +```{r cost_curves, eval = can_run, fig.height = 3.4} +library(ggplot2) + +ok <- nm_all[nm_all$status == "ok", ] +ggplot(ok, aes(x, cost_total / 1000, colour = storage_type)) + + geom_line() + + geom_point(size = 2) + + facet_wrap(~ site, scales = "free_y") + + scale_x_continuous(breaks = 0:5) + + labs(title = "Kosten-Wirksamkeits-Kurven (simultane Suche, Nelder-Mead)", + x = "Ueberlaufziel x (zulaessige Ereignisse)", + y = "Kosten Optimum [Tsd. EUR]", + colour = "Speichertyp", + caption = cost_rates_caption("de", cost_rates)) + + theme_bw() +``` + +```{r export, eval = can_run} +readr::write_csv(nm_all, "optimisation_results_simultaneous_all_sites.csv") + +nm_stats <- unique(nm_all[, c("site", "storage_type", "n_runs_task", + "minutes_task")]) +knitr::kable( + nm_stats, + col.names = c("Standort", "Speichertyp", "Engine-Laeufe", "Minuten") +) +``` + +## Gegenprobe: Vergleich mit der Bisektion + +Liegt der Export der Bisektions-Vignette +(`optimisation_results_all_sites.csv`) neben dieser Vignette, werden +beide Optima Zelle für Zelle verglichen. Beide Verfahren müssen — bis +auf die Suchtoleranzen, also wenige Prozent — auf dieselben Kosten +kommen. Fände die simultane Suche *systematisch günstigere* Designs, +wäre das ein Hinweis auf Parameter-Wechselwirkungen, die die +Koordinatensuche nicht sieht (und ein Fall für die Monotonie-Analyse); +fände sie nur teurere, hat der Simplex sein Laufbudget nicht +ausgeschöpft oder klemmt in einem lokalen Tal (`n_starts` / `max_evals` +erhöhen). + +```{r bisection_available, echo = FALSE, eval = can_run} +has_bisection <- file.exists("optimisation_results_all_sites.csv") +``` + +```{r bisection_note, echo = FALSE, results = 'asis', eval = can_run && !has_bisection} +cat(paste0( + "> **Hinweis:** `optimisation_results_all_sites.csv` nicht gefunden — ", + "zuerst die Vignette `workflow_optimisation` rendern, dann liefert ", + "dieser Abschnitt den Zellenvergleich.\n" +)) +``` + +```{r compare_bisection, eval = can_run && has_bisection} +bisect_all <- readr::read_csv("optimisation_results_all_sites.csv", + show_col_types = FALSE) + +vergleich <- dplyr::full_join( + dplyr::select(bisect_all, site, storage_type, x, + status_bisektion = status, kosten_bisektion = cost_total), + dplyr::select(nm_all, site, storage_type, x, + status_simultan = status, kosten_simultan = cost_total), + by = c("site", "storage_type", "x") +) %>% + dplyr::mutate( + delta_pct = round(100 * (kosten_simultan - kosten_bisektion) / + kosten_bisektion, 1) + ) %>% + dplyr::arrange(site, storage_type, x) + +knitr::kable( + vergleich, digits = 0, + caption = paste("Gegenprobe Bisektion vs. simultane Suche:", + "delta_pct < 0 heisst, die simultane Suche hat ein", + "guenstigeres Design gefunden") +) +``` + +## Alternative Optimierer im Vergleich + +Dieselbe Zelle (x = 1, beide Speichertypen, alle Standorte), drei +Suchverfahren: Nelder-Mead (aus dem Haupt-Sweep oben), Differential +Evolution und die Halton-Baseline. Erwartung: Nelder-Mead und DE liegen +innerhalb weniger Prozent beieinander; die naive Halton-Stichprobe +bleibt trotz des gemeinsamen Feinschliffs messbar dahinter — der +Abstand zeigt, wie viel die strukturierte Suche beiträgt. Parallelisiert +über **Standort × Speichertyp × Methode = 12 Tasks**. + +```{r compare_methods_run, eval = can_run} +t_cmp_start <- Sys.time() + +method_tasks <- expand.grid(site = names(sites), + type = names(storage_spec), + method = c("diff_evolution", "halton_search"), + stringsAsFactors = FALSE) + +future::plan(future::multisession, + workers = min(nrow(method_tasks), + max(1, parallel::detectCores() - 1))) + +tick_cap_cmp <- 300 # eine Zelle je Task + +cmp_all <- progressr::with_progress({ + p <- progressr::progressor(steps = nrow(method_tasks) * tick_cap_cmp) + cmp_list <- future.apply::future_lapply(seq_len(nrow(method_tasks)), + function(i) { + run_simultaneous_task(method_tasks$site[i], method_tasks$type[i], + method = method_tasks$method[i], x_targets = 1, + model_suffix = toupper(substr( + method_tasks$method[i], 1, 2)), + tick = p, tick_cap = tick_cap_cmp) + }, future.seed = TRUE) + dplyr::bind_rows(cmp_list) +}) + +future::plan(future::sequential) + +methoden <- dplyr::bind_rows( + dplyr::filter(nm_all, x == 1), + cmp_all +) + +t_cmp_end <- Sys.time() +``` + +```{r compare_methods_table, eval = can_run} +methoden_breit <- methoden %>% + dplyr::select(site, storage_type, method, cost_total, n_runs_new) %>% + tidyr::pivot_wider(names_from = method, + values_from = c(cost_total, n_runs_new)) + +knitr::kable( + methoden_breit, digits = 0, + caption = paste("Methodenvergleich bei x = 1: Kosten des Optimums und", + "frische Engine-Laeufe der Zelle je Suchverfahren.", + "Der Nelder-Mead-Wert stammt aus dem Haupt-Sweep", + "(profitiert dort vom Cache der uebrigen x-Ziele).") +) +``` + +```{r compare_methods_plot, eval = can_run, fig.height = 3.4} +ggplot(methoden[methoden$status == "ok", ], + aes(method, cost_total / 1000, fill = storage_type)) + + geom_col(position = "dodge") + + facet_wrap(~ site, scales = "free_y") + + labs(title = "Kosten des gefundenen Optimums je Suchverfahren (x = 1)", + x = "Suchverfahren", + y = "Kosten Optimum [Tsd. EUR]", + fill = "Speichertyp", + caption = cost_rates_caption("de", cost_rates)) + + theme_bw() + + theme(axis.text.x = element_text(angle = 20, hjust = 1)) +``` + +## Einordnung + +- **Konsistenz:** Bisektion und simultane Suche bestätigen sich + gegenseitig, wenn ihre Kosten je Zelle nur um wenige Prozent + differieren — dann ist das Optimum eine Eigenschaft des Problems, + nicht des Suchwegs. +- **Wenn die simultane Suche systematisch günstiger ist**, gibt es zwei + mögliche Ursachen: (a) die Monotonie-Annahme der Bisektion ist + verletzt (echter Modell-Alarm → die + [Monotonie-Analyse](https://raindrop.kompetenz-wasser.io/optimisation/monotonicity_analysis/) + prüfen), oder + (b) der **Spezifikkosten-Proxy**, aus dem die Bisektion ihre + Suchreihenfolge herleitet (€ je mm Speicherkapazität, Kapazitätsmodell + V ≈ Fläche × (Tiefe + Porosität × Speicher)), greift zu kurz — etwa + weil ein Hebel nicht kapazitäts-additiv wirkt (z. B. eine variable + Filterdurchlässigkeit, die die Hydraulik nichtlinear verändert) oder + das Kapazitätsmodell die Standort-Hydraulik schlecht beschreibt. Die + simultane Suche trägt die `cost_rates` direkt in ihrer Zielfunktion + und braucht weder Proxy noch Kapazitätsmodell: Sie ist die + annahmefreie Instanz und **das primäre Verfahren, sobald Parameter + ohne saubere Grenzkosten-je-Kapazität ins Spiel kommen**. +- **`monotonicity_warning = TRUE`** heißt hier: Unter den Evaluationen + der Zelle liegt ein *strikt größeres* Design mit mehr Überläufen + *und* mehr Überlaufvolumen — echte Nicht-Monotonie; dann verdient die + Zelle einen Blick in das `"evaluations"`-Attribut. +- **Methodenwahl:** `nelder_mead` bleibt die Empfehlung (beste + Präzision je Engine-Lauf). `diff_evolution` ist die Absicherung gegen + Simplex-Artefakte, `halton_search` die Messlatte von unten. +- **Budget:** `max_evals` begrenzt die Suchphase je Zelle; der + Feinschliff (Multi-Tal-Musterabstieg) kommt obendrauf. Wer Laufzeit + sparen muss, reduziert zuerst `x_targets`, dann `max_evals`. + +```{r vignette_runtime, echo = FALSE, results = 'asis', eval = can_run} +cat(sprintf(paste0( + "---\n\n**Gesamtlaufzeit dieser Vignette:** %.1f Minuten ", + "(Nelder-Mead-Sweep %.1f min · Methodenvergleich %.1f min · ", + "Rest: Setup und Rendern).\n"), + as.numeric(difftime(Sys.time(), t_vignette_start, units = "mins")), + as.numeric(difftime(t_nm_end, t_nm_start, units = "mins")), + as.numeric(difftime(t_cmp_end, t_cmp_start, units = "mins")) +)) +``` diff --git a/vignettes/workflow_wien.Rmd b/vignettes/workflow_wien.Rmd index e9971ef..fab82b8 100644 --- a/vignettes/workflow_wien.Rmd +++ b/vignettes/workflow_wien.Rmd @@ -109,14 +109,52 @@ mulde_area <- c(25, 50, 75, 100, 125, 150, 175, 200) mulde_height <- c(100, 200, 300) filter_hydraulicconductivity <- c(36, 180, 360) filter_height <- 300 -storage_height <- c(100, 500, 1000) +# Storage-layer (Speicher = 2nd Bodenschichtung layer) presets per storage type. +# Each type brings its own storage_height levels plus the Speicher soil +# parameters (theta*). They are written to the model in the run loop below: +# storage_height -> Schichtdicken[2] +# Startwerte_theta_ActualSoilMoisture -> Startwerte_theta_ActualSoilMoisture[2] +# thetaWP/thetaFC/thetaS -> //Bodenarten/Speicher/theta* +storage_type <- list( + "infiltration_box" = list( + storage_height = c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.95 + ), + "gravel_trench" = list( + storage_height = 3 * c(300, 600, 900, 1200), + Startwerte_theta_ActualSoilMoisture = 0, + thetaWP_MoistureAtWiltingPoint = 0, + thetaFC_MoistureAtFieldCapacity = 0, + thetaS_MoistureAtSaturation = 0.3 + ) +) rain_factor <- 1 bottom_hydraulicconductivity <- 12 #c(1,5,10,20,45,90,180,270,360,1860,3600) # LAI for Mulde_Rigole only (Dach kept at H5 default). -# 8.5 = status-quo H5 default; 3.9 = grass per Hörnschemeyer et al., +# 8.5 = status-quo H5 default; 3.9 = grass per Hoernschemeyer et al., # Water 2023, 15, 2840, Tab. 6, plant type 5 (grasses/herbs). -lai <- c(3.9, 8.5) - +#lai <- c(3.9, 8.5) +lai <- 3.9 + + +# storage_height is coupled to storage_type (each type has its own levels), so +# build one row per (storage_type, storage_height) carrying the matching +# Speicher soil parameters, then cross-join with all remaining combinations. +storage_grid <- do.call(rbind, lapply(names(storage_type), function(type_name) { + spec <- storage_type[[type_name]] + data.frame( + storage_type = type_name, + storage_height = spec$storage_height, + storage_theta_start = spec$Startwerte_theta_ActualSoilMoisture, + storage_thetaWP = spec$thetaWP_MoistureAtWiltingPoint, + storage_thetaFC = spec$thetaFC_MoistureAtFieldCapacity, + storage_thetaS = spec$thetaS_MoistureAtSaturation, + stringsAsFactors = FALSE + ) +})) # Alle Kombinationen erzeugen param_grid_all_combinations <- expand.grid( @@ -125,16 +163,25 @@ param_grid_all_combinations <- expand.grid( mulde_height = mulde_height, filter_hydraulicconductivity = filter_hydraulicconductivity, filter_height = filter_height, - storage_height = storage_height, bottom_hydraulicconductivity = bottom_hydraulicconductivity, rain_factor = rain_factor, - lai = lai + lai = lai, + stringsAsFactors = FALSE ) +# Cross-join the free parameters with the coupled storage grid. +param_grid_all_combinations <- merge(param_grid_all_combinations, storage_grid, + by = NULL) + param_grid_all_combinations <- param_grid_all_combinations %>% dplyr::bind_cols(tibble::tibble(scenario_name = sprintf("s%05d", seq_len(nrow(param_grid_all_combinations))))) +# Reference = first storage type at its smallest storage_height (storage_height +# is coupled to storage_type, so both are fixed together). +ref_storage_type <- names(storage_type)[1] +ref_storage_height <- min(storage_type[[ref_storage_type]]$storage_height) + ref_scenario <- param_grid_all_combinations %>% dplyr::filter(connected_area == min(unique(param_grid_all_combinations$connected_area)), mulde_area == min(unique(param_grid_all_combinations$mulde_area)), @@ -142,7 +189,8 @@ ref_scenario <- param_grid_all_combinations %>% filter_hydraulicconductivity == min(param_grid_all_combinations$filter_hydraulicconductivity), bottom_hydraulicconductivity == min(unique(param_grid_all_combinations$bottom_hydraulicconductivity)), mulde_height == min(param_grid_all_combinations$mulde_height), - storage_height == min(param_grid_all_combinations$storage_height), + storage_type == ref_storage_type, + storage_height == ref_storage_height, lai == max(param_grid_all_combinations$lai)) %>% dplyr::pull(scenario_name) @@ -158,6 +206,20 @@ param_grid <- param_grid_all_combinations %>% dplyr::filter(scenario_name %in% scenarios_with_single_parameter_variation) param_grid <- param_grid_all_combinations +# Nutzbares Speichervolumen der Speicherschicht [m3] = Muldenflaeche x +# Speicherhoehe x nutzbare Porositaet (thetaS - thetaFC) des Speichertyps +# (Sickerbox 0.95, Schotterrigole 0.3). thetaFC statt thetaWP: nur das +# oberhalb der Feldkapazitaet entwaesserbare Porenvolumen leert sich zwischen +# den Ereignissen und steht als Retentionsvolumen erneut zur Verfuegung; +# Wasser zwischen WP und FC haelt die Schicht gegen die Schwerkraft (in den +# Presets sind thetaFC = thetaWP = 0, beide Definitionen also identisch). +# Erscheint in der Grid-Tabelle, den Ergebnis-CSVs und im Plot-Tooltip. +param_grid <- param_grid %>% + dplyr::mutate( + storage_volume_m3 = mulde_area * storage_height / 1000 * + (storage_thetaS - storage_thetaFC) + ) + DT::datatable(param_grid, filter = "top", options = list(pageLength = 25, @@ -239,7 +301,7 @@ timeseries_rain <- if(max(timeseries_et$time) > max(timeseries_rain$time)) { timeseries_rain } -txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Regenmenge: %f mm/a)\n", +txt <- sprintf("F\u00fcr den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Regenmenge: %f mm/a)\n", paths$path_rain, 100*sum(is.na(timeseries_rain$value))/nrow(timeseries_rain), 100*sum(timeseries_rain$value == 0, na.rm = TRUE)/nrow(timeseries_rain), @@ -247,7 +309,7 @@ txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Wer message(txt) -txt <- sprintf("Für den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Verdunstungs: %f mm/a)\n", +txt <- sprintf("F\u00fcr den Datensatz '%s' gibt es %.2f %% NA Werte und %.2f %% Werte die gleich Null sind. (Evapotranspirations: %f mm/a)\n", paths$path_et, 100*sum(is.na(timeseries_et$value))/nrow(timeseries_et), 100*sum(timeseries_et$value == 0, na.rm = TRUE)/nrow(timeseries_et), @@ -260,6 +322,12 @@ message(txt) period <- c(diff(timeseries_rain$time), mean(diff(timeseries_rain$time))) timeseries_rain$value <- timeseries_rain$value / period +### Convert ET0 from mm/day to mm/h (the engine reads //Kurven/ET0 as a mm/h +### rate, exactly like rain; daily values must be divided by their interval in +### hours = 24, otherwise ET0 is integrated 24x too high) +period_et <- c(diff(timeseries_et$time), mean(diff(timeseries_et$time))) +timeseries_et$value <- timeseries_et$value / period_et + #openxlsx::write.xlsx(list(regen = timeseries_rain, et = timeseries_et), "timeseries.xlsx") ``` @@ -305,9 +373,14 @@ run_one <- function(i, vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Regen-Skalierungsfaktor` <- 1 vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` <- param_grid_tmp$mulde_area vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` <- param_grid_tmp$mulde_height - vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, 0) + vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Startwerte_theta_ActualSoilMoisture` <- c(0.3, param_grid_tmp$storage_theta_start) vals$`//Massnahmenelemente/Mulde_Rigole/Bodenschichtung/Schichtdicken` <- c(param_grid_tmp$filter_height, param_grid_tmp$storage_height) + # Speicher (2nd Bodenschichtung layer) soil parameters depend on the + # storage type (infiltration_box vs. gravel_trench); see `storage_type`. + vals$`//Bodenarten/Speicher/thetaWP_MoistureAtWiltingPoint` <- param_grid_tmp$storage_thetaWP + vals$`//Bodenarten/Speicher/thetaFC_MoistureAtFieldCapacity` <- param_grid_tmp$storage_thetaFC + vals$`//Bodenarten/Speicher/thetaS_MoistureAtSaturation` <- param_grid_tmp$storage_thetaS vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Endversickerungsrate` <- param_grid_tmp$bottom_hydraulicconductivity vals$`//Bodenarten/Bodenfilter/Ks_HydraulicConductivity` <- param_grid_tmp$filter_hydraulicconductivity @@ -329,13 +402,33 @@ run_one <- function(i, path_input = paths$path_target_input, debug = debug) - invisible(NULL) + # Thin immediately: read only this run's results (lean = water balance + + # overflow rates, no states/meta/connected-area rates) and reduce to the + # single optimisation row. This way we never hold all scenarios' full + # time series in memory at once; the full result HDF5 stays on disk for + # ad-hoc inspection. + sim_one <- kwb.raindrop::get_simulation_results_optim( + paths = paths, + path_list = path_list, + simulation_names = param_grid_tmp$scenario_name, + debug = debug, + lean = TRUE + ) + + kwb.raindrop::add_overflow_events_and_waterbalance( + simulation_results = sim_one, + event_separation_hours = 4, + canonical_variables = kwb.raindrop::default_canonical_wb_variables() + ) } n_cores <- parallel::detectCores() +# run_one() now returns the thinned per-run optimisation row, so run_scenarios() +# yields a list of one-row tibbles we simply bind below. +scenario_rows <- NULL system.time(expr = { -kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), +scenario_rows <- kwb.raindrop::run_scenarios(indices = seq_len(nrow(param_grid)), run_one_scenario = run_one, timestep_hours = 0.1, debug = FALSE, @@ -364,21 +457,11 @@ x$Fehlerbeschreibung ### Analyse Results ```{r analyse_results, eval = data_available && is_windows && !is_ghactions} -system.time( -simulation_results <- kwb.raindrop::get_simulation_results_optim_parallel( - paths = paths, - path_list = path_list, - simulation_names = param_grid$scenario_name, - debug = FALSE) -) - -system.time( -simulation_results_optimisation <- kwb.raindrop::add_overflow_events_and_waterbalance( - simulation_results = simulation_results, - event_separation_hours = 4, - canonical_variables = kwb.raindrop::default_canonical_wb_variables() - ) -) +# Each run was already thinned to its optimisation row inside run_one(), so we +# just bind the per-run rows here instead of re-reading every run's full +# results into memory. (The previous get_simulation_results_optim_parallel() + +# add_overflow_events_and_waterbalance() pass loaded all runs at once.) +simulation_results_optimisation <- dplyr::bind_rows(scenario_rows) simulation_results_optimisation <- param_grid %>% dplyr::left_join(simulation_results_optimisation, @@ -403,6 +486,16 @@ htmlwidgets::saveWidget(DT::datatable(simulation_results_optimisation, ### Plot results +# Fuer Plots/Tooltips: die an storage_type gekoppelten Speicher-Bodenparameter +# (storage_theta*) sind durch den Typ bestimmt, also redundant - sie wuerden +# nur jeden Tooltip aufblaehen. Fuer den Modelllauf oben werden sie gebraucht, +# ab hier nicht mehr. storage_volume_m3 bekommt im Tooltip eine eigene Zeile +# (aus den Ergebnisdaten) und fliegt hier ebenfalls raus, sonst stuende es +# doppelt unter "Variierende Parameter". +param_grid <- param_grid %>% + dplyr::select(-dplyr::starts_with("storage_theta"), + -dplyr::any_of("storage_volume_m3")) + params <- c( #"connected_area", "mulde_area", @@ -412,12 +505,18 @@ params <- c( "storage_height", #"bottom_hydraulicconductivity", #"rain_factor", - "lai" + "lai", + "storage_type" ) lang <- "de" max_n_overflows <- 5 +# Kostensaetze-Caption fuer die interaktiven Kostenplots: ggplotly verwirft +# ggplot-Captions, daher wird sie dort per plotly_add_caption() nachgeruestet; +# die PDFs bekommen sie automatisch ueber den caption-Default der Funktionen. +cost_caption <- kwb.raindrop::cost_rates_caption(lang) + pdff <- sprintf("simulation_results_optimisation_%s_main-effects.pdf", paths$modelname) @@ -454,6 +553,7 @@ grDevices::pdf(pdff, width = 9, height = 4, onefile = TRUE) alpha_max = 1, drop_overflow_gt_valid_max = FALSE, keep_param_grid_limits = TRUE, + facet_storage_type = TRUE, lang = lang, subtitle = "" ) @@ -494,11 +594,15 @@ for (y in c("mulde_height", "filter_hydraulicconductivity", "storage_height")) { alpha_min = 0.25, alpha_max = 1, drop_overflow_gt_valid_max = FALSE, - keep_param_grid_limits = TRUE + keep_param_grid_limits = TRUE, + facet_storage_type = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; Farb-Legende je Ueberlaufklasse statt + # (Farbe, Form)-Tupeln - die Formen erklaeren die Panel-Beschriftungen. plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang, + add_shape_legend = FALSE) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_design-space_mulde-area_vs_%s.html", @@ -528,8 +632,10 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( use_jitter = TRUE ) - # interaktiv als HTML + # interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) + # statt der (Farbe, Form)-Tupel von ggplotly plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) htmlwidgets::saveWidget( widget = plotly_p, file = sprintf("simulation_results_optimisation_%s_water-balance.html", @@ -540,8 +646,165 @@ p <- kwb.raindrop::plot_wb_tradeoff_overflows( ) # statisch ins PDF (WICHTIG!) - suppressWarnings(print(p)) + suppressWarnings(print(p)) dev.off() #kwb.utils::finishAndShowPdf(pdff) +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_overflow_volume( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-overflow-volume.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. overflow volume", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() +#kwb.utils::finishAndShowPdf(pdff) + +# Drei Kosten-Boxplots mit unterschiedlichem Optimierungsziel je Kategorie +# (Kosten als Tie-Break): (i) guenstigste; (ii) geringstes Ueberlaufvolumen +# (Label m3 + %); (iii) hoechste Evapotranspiration (Label %, Punktgroesse = +# Evapotranspiration). x = max_n_overflows, Linie ueber alle Klassen. Die beiden +# Speichertypen liegen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); die Punkte bleiben Kreise, da die Panel-Streifen +# den Typ bereits benennen. +cost_boxplots <- list( + list(suffix = "cheapest", best_by = "min_cost", + size_by = "overflow_volume", label_best = FALSE), + list(suffix = "min-overflow", best_by = "min_overflow", + size_by = "overflow_volume", label_best = TRUE), + list(suffix = "max-evap", best_by = "max_evapotranspiration", + size_by = "evapotranspiration", label_best = TRUE) +) +for (cb in cost_boxplots) { + pdff <- sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.pdf", + paths$modelname, cb$suffix) + kwb.utils::preparePdf(pdfFile = pdff) + + p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + facet_storage_type = TRUE, + size_by = cb$size_by, + best_by = cb$best_by, + label_best = cb$label_best + ) + + # interaktiv als HTML + plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) + plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) + htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-by-overflows-boxplot-%s.html", + paths$modelname, cb$suffix), + selfcontained = TRUE, + title = sprintf("'%s' - Cost boxplot (%s)", paths$modelname, cb$suffix) + ) + + # statisch ins PDF (WICHTIG!) + suppressWarnings(print(p)) + dev.off() +} + + +# Kosten vs. Evapotranspiration: Streudiagramm ueber den Design-Raum, Punktform +# kodiert den Speichertyp (Viereck = Sickerbox, Dreieck = Schotterrigol), +# Farbe die Anzahl Ueberlaufereignisse. +pdff <- sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_vs_evaporation( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang +) + +# interaktiv als HTML; getrennte Legenden (Ueberlaufklassen + Speichertyp) +# statt der (Farbe, Form)-Tupel von ggplotly +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_split_legend(plotly_p, lang = lang) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf("simulation_results_optimisation_%s_cost-vs-evaporation.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost vs. evapotranspiration", + paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + + +# Kosten je Prozent Evapotranspiration [EUR/%]: Boxplot je Ueberlaufklasse mit den +# beiden Speichertypen als zwei Panels untereinander (Sickerbox oben, +# Schotterrigol unten); guenstigstes Szenario je Box markiert (Label EUR/%), +# Punktgroesse = Evapotranspiration. +pdff <- sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.pdf", + paths$modelname) +kwb.utils::preparePdf(pdfFile = pdff) + +p <- kwb.raindrop::plot_cost_overflow_boxplot( + simulation_results_optimisation = simulation_results_optimisation, + param_grid = param_grid, + x = max_n_overflows, + filter_n_gtx = FALSE, + use_jitter = TRUE, + lang = lang, + y_var = "cost_per_evap_pct", + facet_storage_type = TRUE, + size_by = "evapotranspiration", + best_by = "min_cost", + label_best = TRUE +) + +# interaktiv als HTML +plotly_p <- suppressWarnings(plotly::ggplotly(p, tooltip = "text")) +plotly_p <- kwb.raindrop::plotly_add_caption(plotly_p, cost_caption) +htmlwidgets::saveWidget( + widget = plotly_p, + file = sprintf( + "simulation_results_optimisation_%s_cost-per-evap-boxplot.html", + paths$modelname), + selfcontained = TRUE, + title = sprintf("'%s' - Cost per %% evapotranspiration", paths$modelname) +) + +# statisch ins PDF (WICHTIG!) +suppressWarnings(print(p)) +dev.off() + ```