Workflow Optimierung (Eisenstadt · Wien · Bad Aussee)

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 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.

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().

# 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>"
  )
}

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:

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)"
)
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

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.

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

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×).

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.

n_mc <- 10
mc_seeds <- 1:n_mc
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")
)
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).

Einordnung


Gesamtlaufzeit dieser Vignette: 37.3 Minuten (Optimierung 19.0 min · Such-Monte-Carlo 18.2 min · Rest: Setup und Rendern).