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 Vignette monotonicity_analysis belegte Monotonie
(“größer = nie mehr Überläufe”); die dort abgeleiteten Absicherungen
(Rand-Guard, Volumen-Schiedsrichter) sind in
find_min_feasible() eingebaut.
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.
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().
# 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 = "<root_path>/models/<modelname>/input",
dir_output = "<root_path>/models/<modelname>/output",
dir_target_output = "<dir_output>/<dir_target>",
file_errors_hdf5 = "Fehlerprotokoll.h5",
file_results_hdf5_element = "Mulde_Rigole.h5",
file_results_hdf5_flaeche = "Dach.h5",
file_results_hdf5_verschaltungen = "<dir_target>_Verschaltungen.h5",
file_results_txt = "Mulde_Rigole_RAINDROP.txt",
file_results_txt_multilayer = "Mulde_Rigole_RAINDROP_multi_layer.txt",
file_target = "<dir_target>.h5",
path_base = extdata_path("models", model_dir, "base.h5"),
path_exe = download_engine(),
path_errors_hdf5 = "<dir_target_output>/<file_errors_hdf5>",
path_results_hdf5_element = "<dir_target_output>/<file_results_hdf5_element>",
path_results_hdf5_flaeche = "<dir_target_output>/<file_results_hdf5_flaeche>",
path_results_hdf5_verschaltungen = "<dir_target_output>/<file_results_hdf5_verschaltungen>",
path_results_txt = "<dir_target_output>/<file_results_txt>",
path_results_txt_multilayer = "<dir_target_output>/<file_results_txt_multilayer>",
path_target_input = "<dir_input>/<file_target>"
)
}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:
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()
# 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"
)
))| Parameter | Suchraum |
|---|---|
| mulde_area [m2] | stufenlos 25 bis 200 (Toleranz 2) |
| mulde_height [mm] | stufenlos 100 bis 300 (Toleranz 10) |
| storage_height Sickerbox [mm] | Stufen: 300 / 600 / 900 / 1200 |
| storage_height Schotterrigol [mm] | stufenlos 900 bis 3600 (Toleranz 25) |
| filter_hydraulicconductivity [mm/h] | fix = 360 (Rastermaximum; kostenfrei dominant) |
| filter_height [mm] | 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:
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)"
)| Kostensatz | EUR je m2/m3 |
|---|---|
| excavation_eur_per_m3 | 70 |
| profiling_eur_per_m2 | 10 |
| filter_eur_per_m3 | 200 |
| infiltration_box_eur_per_m3 | 350 |
| gravel_trench_eur_per_m3 | 50 |
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.
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()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)
)| site | x | storage_type | status | mulde_area | mulde_height | storage_height | n_overflows | overflow_volume_m3 | et_pct | cost_total |
|---|---|---|---|---|---|---|---|---|---|---|
| Eisenstadt_2005 | 0 | infiltration_box | ok | 54.7 | 288 | 300 | 0 | 0.0 | 12.9 | 12968 |
| Eisenstadt_2005 | 1 | infiltration_box | ok | 45.3 | 288 | 300 | 1 | 8.6 | 10.9 | 10745 |
| Eisenstadt_2005 | 2 | infiltration_box | ok | 45.3 | 288 | 300 | 1 | 8.6 | 10.9 | 10745 |
| Eisenstadt_2005 | 3 | infiltration_box | ok | 43.8 | 300 | 300 | 3 | 10.6 | 10.6 | 10412 |
| Eisenstadt_2005 | 4 | infiltration_box | ok | 43.8 | 294 | 300 | 4 | 11.1 | 10.6 | 10393 |
| Eisenstadt_2005 | 5 | infiltration_box | ok | 43.8 | 100 | 300 | 5 | 43.9 | 10.6 | 9800 |
| Wien | 0 | infiltration_box | ok | 160.9 | 294 | 600 | 0 | 0.0 | 18.4 | 58511 |
| Wien | 1 | infiltration_box | ok | 129.7 | 281 | 300 | 1 | 78.8 | 15.3 | 30695 |
| Wien | 2 | infiltration_box | ok | 109.4 | 300 | 300 | 2 | 110.2 | 13.2 | 26031 |
| Wien | 3 | infiltration_box | ok | 100.0 | 294 | 300 | 3 | 132.8 | 12.2 | 23756 |
| Wien | 4 | infiltration_box | ok | 95.3 | 300 | 300 | 4 | 147.7 | 11.7 | 22684 |
| Wien | 5 | infiltration_box | ok | 93.8 | 288 | 300 | 5 | 157.1 | 11.5 | 22230 |
| BadAussee | 0 | infiltration_box | ok | 150.0 | 300 | 600 | 0 | 0.0 | 7.5 | 54600 |
| BadAussee | 1 | infiltration_box | ok | 146.9 | 294 | 300 | 1 | 45.1 | 7.3 | 34892 |
| BadAussee | 2 | infiltration_box | ok | 140.6 | 294 | 300 | 2 | 56.3 | 7.1 | 33407 |
| BadAussee | 3 | infiltration_box | ok | 134.4 | 300 | 300 | 3 | 67.4 | 6.8 | 31981 |
| BadAussee | 4 | infiltration_box | ok | 128.1 | 300 | 300 | 4 | 84.6 | 6.5 | 30494 |
| BadAussee | 5 | infiltration_box | ok | 120.3 | 250 | 300 | 5 | 136.3 | 6.2 | 28213 |
| Eisenstadt_2005 | 0 | gravel_trench | ok | 56.2 | 281 | 900 | 0 | 0.0 | 13.2 | 12301 |
| Eisenstadt_2005 | 1 | gravel_trench | ok | 45.3 | 300 | 900 | 1 | 8.7 | 10.9 | 9969 |
| Eisenstadt_2005 | 2 | gravel_trench | ok | 45.3 | 294 | 900 | 2 | 9.0 | 10.9 | 9949 |
| Eisenstadt_2005 | 3 | gravel_trench | ok | 45.3 | 294 | 900 | 2 | 9.0 | 10.9 | 9949 |
| Eisenstadt_2005 | 4 | gravel_trench | ok | 45.3 | 288 | 900 | 4 | 9.6 | 10.9 | 9929 |
| Eisenstadt_2005 | 5 | gravel_trench | ok | 43.8 | 100 | 900 | 5 | 46.7 | 10.6 | 9012 |
| Wien | 0 | gravel_trench | ok | 200.0 | 300 | 1111 | 0 | 0.0 | 21.9 | 49062 |
| Wien | 1 | gravel_trench | ok | 132.8 | 300 | 900 | 1 | 75.2 | 15.6 | 29219 |
| Wien | 2 | gravel_trench | ok | 112.5 | 294 | 900 | 2 | 109.5 | 13.5 | 24701 |
| Wien | 3 | gravel_trench | ok | 101.6 | 300 | 900 | 3 | 130.8 | 12.3 | 22344 |
| Wien | 4 | gravel_trench | ok | 95.3 | 300 | 900 | 4 | 153.6 | 11.7 | 20969 |
| Wien | 5 | gravel_trench | ok | 95.3 | 288 | 900 | 5 | 158.3 | 11.7 | 20885 |
| BadAussee | 0 | gravel_trench | ok | 200.0 | 300 | 1027 | 0 | 0.0 | 9.5 | 47038 |
| BadAussee | 1 | gravel_trench | ok | 148.4 | 300 | 900 | 1 | 44.9 | 7.4 | 32656 |
| BadAussee | 2 | gravel_trench | ok | 142.2 | 300 | 900 | 2 | 56.7 | 7.1 | 31281 |
| BadAussee | 3 | gravel_trench | ok | 137.5 | 300 | 900 | 3 | 65.5 | 6.9 | 30250 |
| BadAussee | 4 | gravel_trench | ok | 131.2 | 300 | 900 | 4 | 81.9 | 6.7 | 28875 |
| BadAussee | 5 | gravel_trench | ok | 120.3 | 262 | 900 | 5 | 137.9 | 6.2 | 26153 |
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()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")
)| Standort | Speichertyp | Engine-Laeufe | Minuten |
|---|---|---|---|
| Eisenstadt_2005 | infiltration_box | 31 | 1.0 |
| Wien | infiltration_box | 71 | 18.3 |
| BadAussee | infiltration_box | 63 | 16.3 |
| Eisenstadt_2005 | gravel_trench | 27 | 0.9 |
| Wien | gravel_trench | 73 | 18.8 |
| BadAussee | gravel_trench | 71 | 18.3 |
Gesamtlaufzeit Optimierung: 336 Engine-Läufe · Summe der Task-Zeiten 73.6 min · tatsächliche Laufzeit 19.0 min (paralleler Speedup 3.9×).
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.
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()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")
)| site | storage_type | flaeche_spanne_m2 | tiefe_spanne_mm | speicher_identisch | kosten_min | kosten_max | kosten_spanne_pct |
|---|---|---|---|---|---|---|---|
| BadAussee | gravel_trench | 1.8 | 8.8 | TRUE | 32496.5 | 32791.0 | 0.9 |
| BadAussee | infiltration_box | 1.7 | 14.1 | TRUE | 34733.8 | 34987.4 | 0.7 |
| Eisenstadt_2005 | gravel_trench | 1.8 | 35.0 | TRUE | 9976.9 | 10259.6 | 2.8 |
| Eisenstadt_2005 | infiltration_box | 1.3 | 32.2 | TRUE | 10648.2 | 10870.7 | 2.1 |
| Wien | gravel_trench | 1.9 | 6.4 | TRUE | 29067.0 | 29435.1 | 1.3 |
| Wien | infiltration_box | 1.6 | 6.8 | TRUE | 30424.1 | 30800.6 | 1.2 |
Laufzeit Such-Monte-Carlo: 1021 Engine-Läufe in 18.2 min (parallel über 60 Tasks: 3 Standorte × 2 Speichertypen × 10 Wiederholungen).
monotonicity_warning = TRUE in einer Zeile hieße: Zähler
und Volumen sind gemeinsam gestiegen — dann Branch prüfen.find_min_feasible() deckt
ihn ab.max_total_depth
(mm) begrenzt Muldentiefe + Filter + Speicher analytisch (DWA-A 138 /
Überdeckung), ohne zusätzliche Läufe.Gesamtlaufzeit dieser Vignette: 37.3 Minuten (Optimierung 19.0 min · Such-Monte-Carlo 18.2 min · Rest: Setup und Rendern).