Monotonie-Check der Optimierungsergebnisse

Worum geht es?

Der geplante Optimierer sucht die günstigste Muldenkonfiguration per Bisektion (Intervallhalbierung): “Zu klein” → größer probieren, “reicht” → kleiner probieren. Das funktioniert nur, wenn eine Regel gilt:

Monotonie: Wird das Bauwerk größer (mehr Fläche, mehr Tiefe, mehr Speicher, durchlässigerer Filter), darf es niemals mehr Überlaufereignisse geben als vorher.

Diese Vignette prüft die Regel an allen Nachbarpaaren des Brute-Force-Rasters (je Standort 576 Läufe, 1 704 Vergleichspaare — insgesamt 5 112). Verglichen werden immer zwei Läufe, die sich nur in einem Parameter um eine Stufe unterscheiden. Zusätzlich geprüft: das Überlaufvolumen in m³ (sum_overflows ist mm Wassersäule über der Muldenfläche, daher m³ = mm × Muldenfläche / 1000), die Verdunstung sowie zwei Struktur-Eigenschaften, die der Optimierer nutzt (Ambiguitätsband, Schwellentreppe, kf-Dominanz).

Voraussetzungen

Diese Vignette nach den drei Workflow-Vignetten ausführen — sie liest deren Ergebnisdateien (simulation_results_optimisation_*.csv) aus dem vignettes/-Verzeichnis.

Verwendete Dateien und Ablage-Struktur

Die Links folgen der Deploy-Struktur auf dem Server: Diese Analyse, der Ergebnisbericht und die Detailtabellen liegen unter …/optimisation/monotonicity_analysis/, die Ergebnisse der Workflow-Vignetten unter …/optimisation/brute-force/. Damit die Links auch lokal stimmen, wird diese Vignette in den gleichnamigen Unterordner gerendert:

rmarkdown::render("monotonicity_analysis.Rmd",
                  output_dir = "monotonicity_analysis")

(Eingelesen werden die CSVs unverändert aus vignettes/, wo die Workflows sie ablegen — nur die Links zeigen auf die Deploy-Orte.)

Standort Eingangsdaten (CSV) Interaktive Ergebnistabelle
Bad Aussee CSV HTML-Tabelle
Eisenstadt 2005 CSV HTML-Tabelle
Wien CSV HTML-Tabelle

Der zusammenfassende, allgemeinverständliche Ergebnisbericht (Artefakt, Titel: „Monotonie-Analyse RAINDROP“) liegt als index.html direkt neben dieser Seite — als Verzeichnis-Startseite von …/optimisation/monotonicity_analysis/. Nicht zu verwechseln mit dem „Brute-Force“-Linkhub: Dessen Quelle index.Rmd liegt in vignettes/, sein gerendertes index.html eine Ebene über dieser Seite.

library(dplyr)
library(ggplot2)

geom_params <- c("mulde_area", "mulde_height", "filter_hydraulicconductivity",
                 "storage_type", "storage_height")
check_params <- setdiff(geom_params, "storage_type")

results <- lapply(result_files, function(f) {
  readr::read_csv(f, show_col_types = FALSE) %>%
    # Die Engine liefert die Ueberlaufrate in mm/h bezogen auf die
    # Muldenflaeche; sum_overflows ist damit mm Wassersaeule.
    # Volumen: m3 = mm x Muldenflaeche / 1000.
    mutate(overflow_volume_m3 = sum_overflows * mulde_area / 1000)
})

# Alle Nachbarschritte entlang eines Parameters: alle uebrigen Parameter
# fixieren ("Gruppe"), nach dem Parameter sortieren, jeden Uebergang zum
# Nachbarwert als Schritt ausgeben. Verletzung :<=> dn > 0.
step_table <- function(d, v) {
  grp <- setdiff(geom_params, v)
  d %>%
    group_by(across(all_of(grp))) %>%
    arrange(.data[[v]], .by_group = TRUE) %>%
    mutate(val_from = lag(.data[[v]]),
           val_to   = .data[[v]],
           n_from   = lag(n_overflows),
           vol_from = lag(overflow_volume_m3),
           et_from  = lag(element.WB_Evapotranspiration_),
           is_last  = row_number() == n()) %>%
    ungroup() %>%
    filter(!is.na(val_from)) %>%
    mutate(dn   = n_overflows - n_from,
           dvol = overflow_volume_m3 - vol_from,
           det  = element.WB_Evapotranspiration_ - et_from)
}

steps_all <- purrr::map_dfr(names(results), function(site) {
  purrr::map_dfr(check_params, function(v) {
    step_table(results[[site]], v) %>% mutate(site = site, param = v)
  })
})

1 Schrittverteilung: fällt, Plateau oder steigt?

Plateaus (keine Änderung) sind unkritisch — sie sind die flachen Stufen der Treppenfunktion und entstehen vor allem dort, wo bereits n = 0 erreicht ist. Kritisch sind nur Anstiege (“mehr Überläufe trotz größer”).

step_distribution <- steps_all %>%
  group_by(site, param) %>%
  summarise(steps = n(),
            faellt = sum(dn < 0),
            plateau = sum(dn == 0),
            steigt = sum(dn > 0),
            steigt_anteil_pct = round(100 * mean(dn > 0), 2),
            max_sprung = max(dn),
            .groups = "drop")

knitr::kable(step_distribution)
site param steps faellt plateau steigt steigt_anteil_pct max_sprung
BadAussee filter_hydraulicconductivity 384 282 102 0 0.00 0
BadAussee mulde_area 504 398 94 12 2.38 1
BadAussee mulde_height 384 301 83 0 0.00 0
BadAussee storage_height 432 311 121 0 0.00 0
Eisenstadt filter_hydraulicconductivity 384 102 282 0 0.00 0
Eisenstadt mulde_area 504 160 344 0 0.00 0
Eisenstadt mulde_height 384 143 241 0 0.00 0
Eisenstadt storage_height 432 48 384 0 0.00 0
Wien filter_hydraulicconductivity 384 321 63 0 0.00 0
Wien mulde_area 504 441 63 0 0.00 0
Wien mulde_height 384 334 50 0 0.00 0
Wien storage_height 432 208 223 1 0.23 1

2 Die Verletzungen im Detail

Jede Verletzung wird mit ihrer Volumen-Gegenprobe (in m³) gezeigt: Fällt das Überlaufvolumen am selben Schritt weiter, ist der Anstieg des Zählers ein Artefakt der Ereignistrennung (Pausen > 4 h teilen ein langes Überlaufereignis in zwei gezählte), keine echte Verschlechterung.

violations <- steps_all %>%
  filter(dn > 0) %>%
  transmute(site, param,
            kontext = paste0("h_m ", mulde_height,
                             " | kf ", filter_hydraulicconductivity,
                             " | ", storage_type,
                             ifelse(param == "storage_height", "",
                                    paste0(" ", storage_height))),
            von = val_from, nach = val_to,
            n = paste0(n_from, " → ", n_overflows),
            volumen_m3 = paste0(round(vol_from, 1), " → ",
                                round(overflow_volume_m3, 1)),
            volumen_delta_pct = round(100 * dvol / vol_from, 1),
            am_rasterrand = is_last)

DT::datatable(violations, filter = "top",
              options = list(pageLength = 25, autoWidth = TRUE))

Zur Anschauung dieselbe Bauweise an allen drei Standorten (Muldentiefe 300 mm, kf 360 mm/h, Sickerbox 300 mm): Der Zähler fällt überall steil — nur in Bad Aussee springt er am letzten Rasterschritt von 1 auf 2 (roter Punkt), während das Volumen auch dort weiter fällt.

example_series <- purrr::map_dfr(names(results), function(site) {
  results[[site]] %>%
    filter(mulde_height == 300, filter_hydraulicconductivity == 360,
           storage_type == "infiltration_box", storage_height == 300) %>%
    arrange(mulde_area) %>%
    mutate(site = site,
           verletzung = n_overflows > lag(n_overflows, default = Inf))
})

ggplot(example_series, aes(mulde_area, n_overflows)) +
  geom_line(colour = "grey50") +
  geom_point(aes(colour = verletzung), size = 2, show.legend = FALSE) +
  scale_colour_manual(values = c(`FALSE` = "steelblue4", `TRUE` = "firebrick")) +
  scale_y_sqrt() +
  facet_wrap(~ site, scales = "free_y") +
  labs(title = "Ueberlaufereignisse je Muldenflaeche (Wurzel-Skala)",
       x = "Muldenflaeche [m2]", y = "Anzahl Ueberlaufereignisse") +
  theme_bw()


ggplot(example_series, aes(mulde_area, overflow_volume_m3)) +
  geom_line(colour = "grey50") +
  geom_point(colour = "springgreen4", size = 2) +
  scale_y_sqrt() +
  facet_wrap(~ site, scales = "free_y") +
  labs(title = "Ueberlaufvolumen je Muldenflaeche (Wurzel-Skala): faellt ausnahmslos",
       x = "Muldenflaeche [m2]", y = "Ueberlaufvolumen [m3]") +
  theme_bw()

3 Gegenprobe: das Überlaufvolumen (m³)

Auch in m³ gerechnet (mm Wassersäule × Muldenfläche / 1000 — entlang der Fläche wächst der Umrechnungsfaktor mit, die Monotonie ist also nicht automatisch übertragbar) gilt das Ergebnis:

volume_check <- steps_all %>%
  group_by(site) %>%
  summarise(steps = n(),
            volumen_steigt = sum(dvol > 1e-9),
            plateau = sum(abs(dvol) <= 1e-9),
            volumen_faellt = sum(dvol < -1e-9),
            .groups = "drop")

knitr::kable(volume_check)
site steps volumen_steigt plateau volumen_faellt
BadAussee 1704 0 314 1390
Eisenstadt 1704 0 1146 558
Wien 1704 0 220 1484

4 Verdunstung: hängt nur an der Fläche

et_check <- steps_all %>%
  group_by(site, param) %>%
  summarise(steigt_pct = round(100 * mean(det > 1e-9), 1),
            flach_pct = round(100 * mean(abs(det) <= 1e-9), 1),
            faellt_pct = round(100 * mean(det < -1e-9), 1),
            .groups = "drop")

knitr::kable(et_check)
site param steigt_pct flach_pct faellt_pct
BadAussee filter_hydraulicconductivity 0.0 100.0 0.0
BadAussee mulde_area 100.0 0.0 0.0
BadAussee mulde_height 0.0 100.0 0.0
BadAussee storage_height 0.0 100.0 0.0
Eisenstadt filter_hydraulicconductivity 0.0 100.0 0.0
Eisenstadt mulde_area 100.0 0.0 0.0
Eisenstadt mulde_height 0.0 100.0 0.0
Eisenstadt storage_height 0.0 100.0 0.0
Wien filter_hydraulicconductivity 0.0 99.2 0.8
Wien mulde_area 100.0 0.0 0.0
Wien mulde_height 0.5 99.5 0.0
Wien storage_height 0.0 99.3 0.7

Konsequenz für das Sekundärziel “Verdunstung maximieren”: Der Trade-off Kosten ↔︎ Verdunstung verläuft eindimensional entlang der Muldenfläche — mehr Verdunstung gibt es nur über mehr Fläche.

5 Ambiguitätsband und Schwellentreppe

Für die Bisektion relevant: Gibt es Konfigurationsgruppen, in denen oberhalb der ersten zulässigen Fläche wieder eine unzulässige liegt (Ambiguitätsband)? Und ist die Schwellentreppe a*(x) — die kleinste zulässige Fläche je Überlaufziel x — monoton (ein lockereres Ziel verlangt nie mehr Fläche)?

grp_area <- setdiff(geom_params, "mulde_area")

ambiguity <- purrr::map_dfr(names(results), function(site) {
  purrr::map_dfr(0:5, function(x) {
    results[[site]] %>%
      group_by(across(all_of(grp_area))) %>%
      summarise(first_ok = ifelse(any(n_overflows <= x),
                                  min(mulde_area[n_overflows <= x]), NA),
                last_bad = ifelse(any(n_overflows > x),
                                  max(mulde_area[n_overflows > x]), NA),
                .groups = "drop") %>%
      summarise(site = site, x = x,
                gruppen = n(),
                mit_loesung = sum(!is.na(first_ok)),
                ambig = sum(!is.na(first_ok) & !is.na(last_bad) &
                              last_bad > first_ok),
                max_band_m2 = max(c(0, (last_bad - first_ok)[
                  !is.na(first_ok) & !is.na(last_bad)]), na.rm = TRUE))
  })
})

knitr::kable(ambiguity)
site x gruppen mit_loesung ambig max_band_m2
BadAussee 0 72 42 0 0
BadAussee 1 72 60 12 50
BadAussee 2 72 64 0 0
BadAussee 3 72 64 0 0
BadAussee 4 72 64 0 0
BadAussee 5 72 70 0 0
Eisenstadt 0 72 72 0 0
Eisenstadt 1 72 72 0 0
Eisenstadt 2 72 72 0 0
Eisenstadt 3 72 72 0 0
Eisenstadt 4 72 72 0 0
Eisenstadt 5 72 72 0 0
Wien 0 72 36 0 0
Wien 1 72 48 0 0
Wien 2 72 54 0 0
Wien 3 72 62 0 0
Wien 4 72 64 0 0
Wien 5 72 64 0 0

staircase <- purrr::map_dfr(names(results), function(site) {
  results[[site]] %>%
    group_by(across(all_of(grp_area))) %>%
    reframe(x = 0:5,
            a_star = sapply(0:5, function(x)
              ifelse(any(n_overflows <= x),
                     min(mulde_area[n_overflows <= x]), NA))) %>%
    group_by(across(all_of(grp_area))) %>%
    summarise(treppen_verletzungen = {
      a <- a_star[order(x)]
      a <- a[!is.na(a)]
      if (length(a) > 1) sum(diff(a) > 0) else 0L
    }, .groups = "drop") %>%
    summarise(site = site, branches = n(),
              treppen_verletzungen = sum(treppen_verletzungen))
})

knitr::kable(staircase)
site branches treppen_verletzungen
BadAussee 72 0
Eisenstadt 72 0
Wien 72 0

6 kf-Dominanz: der Filter ist ein Gratis-Hebel

Die Filterdurchlässigkeit kostet nichts (sie taucht in compute_costs() nicht auf). Wenn sie gleichzeitig die Verdunstung nicht verändert und die Überläufe nie erhöht, kann der Optimierer sie fest auf das Maximum setzen.

kf_dominance <- purrr::map_dfr(names(results), function(site) {
  results[[site]] %>%
    group_by(filter_hydraulicconductivity) %>%
    summarise(site = site,
              mittlere_ET_pct = round(mean(element.WB_Evapotranspiration_), 2),
              mittlere_n_overflows = round(mean(n_overflows), 1),
              mittleres_volumen_m3 = round(mean(overflow_volume_m3), 1),
              .groups = "drop")
})

knitr::kable(kf_dominance)
filter_hydraulicconductivity site mittlere_ET_pct mittlere_n_overflows mittleres_volumen_m3
36 BadAussee 5.65 129.9 1165.4
180 BadAussee 5.65 73.1 800.3
360 BadAussee 5.65 65.7 766.6
36 Eisenstadt 23.59 3.7 19.5
180 Eisenstadt 23.59 1.5 9.3
360 Eisenstadt 23.59 1.3 8.5
36 Wien 13.13 69.0 605.3
180 Wien 13.13 32.4 329.1
360 Wien 13.13 25.8 284.2

7 Warmstart: billigstes zulässiges Raster-Design je Ziel x

Diese Tabelle ist der Startpunkt der Bisektion: das jeweils günstigste zulässige Design aus dem vorhandenen Raster (bei kf = Maximum), je Überlaufziel x und Speichertyp. Der Optimierer verfeinert nur noch im 25-m²-Bracket darunter.

warmstart <- purrr::map_dfr(names(results), function(site) {
  d <- results[[site]] %>%
    filter(filter_hydraulicconductivity == max(filter_hydraulicconductivity))
  purrr::map_dfr(0:5, function(x) {
    d %>%
      filter(n_overflows <= x) %>%
      group_by(storage_type) %>%
      slice_min(cost_total, n = 1, with_ties = FALSE) %>%
      ungroup() %>%
      transmute(site = site, x = x, storage_type, mulde_area, mulde_height,
                storage_height, cost_total,
                overflow_volume_m3 = round(overflow_volume_m3, 1),
                ET_pct = round(element.WB_Evapotranspiration_, 1))
  })
})

DT::datatable(warmstart, filter = "top",
              options = list(pageLength = 12, autoWidth = TRUE))

Fazit: Regeln für den Optimierer

Stand der letzten vollständigen Auswertung (2026-07-10): 5 112 Vergleichspaare, 13 Verletzungen (0,25 %), alle mit Sprunghöhe genau +1 und fallendem Volumen — Eisenstadt 0, Wien 1 (bei n ≈ 287, irrelevant für x ≤ 5), Bad Aussee 12 (alle am Rasterrand 175 → 200 m², Niveau n = 1). Das Überlaufvolumen stieg in keinem einzigen Vergleich. Die Monotonie-Voraussetzung der Bisektion ist damit erfüllt, abgesichert durch drei Regeln:

  1. Grid-Warmstart: Bisektion nur im 25-m²-Bracket um das bekannte first_ok aus dem Raster verfeinern — dort ist die Grenze an allen drei Standorten eindeutig.
  2. Rand-Guard statt Abbruch: Fällt der obere Intervallrand nur um +1 über das Ziel (n = x + 1), erst Cache-/Rasterpunkte darunter prüfen, bevor “keine Lösung” gemeldet wird.
  3. Volumen als Schiedsrichter: Bei jedem nicht-monotonen Flip prüfen, ob sum_overflows weiter gefallen ist. Ja → bekannter Zähl-Wobble, weiterrechnen. Nein → Warnung (bisher nie aufgetreten) — der eingebaute Rauchmelder für künftige Standorte.

Beim Hinzufügen eines neuen Standorts: Workflows laufen lassen und diese Vignette erneut rendern. Zeigt Abschnitt 1 Sprünge > +1 oder Abschnitt 3 steigendes Volumen, ist die Bisektion für diesen Standort nicht abgesichert.

save_table_html <- function(df, file, title) {
  htmlwidgets::saveWidget(
    DT::datatable(df, filter = "top",
                  options = list(pageLength = 25, autoWidth = TRUE)),
    file = file, selfcontained = TRUE, title = title
  )
  # saveWidget() laesst bei Zielpfaden ausserhalb des Arbeitsverzeichnisses
  # den "<name>_files"-lib-Ordner stehen, obwohl die Datei selfcontained
  # ist -> redundante Duplikate aufraeumen.
  unlink(sub("\\.html$", "_files", file), recursive = TRUE)
}

exports <- list(
  mono_step_distribution = list(df = step_distribution,
                                titel = "Monotonie: Schrittverteilung"),
  mono_violations_detail = list(df = violations,
                                titel = "Monotonie: Verletzungen im Detail"),
  mono_volume            = list(df = volume_check,
                                titel = "Monotonie: Ueberlaufvolumen (m3)"),
  mono_et                = list(df = et_check,
                                titel = "Monotonie: Verdunstung"),
  mono_ambiguity         = list(df = ambiguity,
                                titel = "Ambiguitaetsband je Ziel x"),
  mono_staircase         = list(df = staircase,
                                titel = "Schwellentreppe a*(x)"),
  mono_kf_dominance      = list(df = kf_dominance,
                                titel = "kf-Dominanz"),
  mono_warmstart_designs = list(df = warmstart,
                                titel = "Warmstart-Designs")
)

# Exporte in den Unterordner des Ergebnisberichts (index.html), damit der
# gesamte Ordner monotonicity_analysis/ als eine Einheit deploybar ist.
out_dir <- "monotonicity_analysis"
dir.create(out_dir, showWarnings = FALSE)

for (name in names(exports)) {
  readr::write_csv(exports[[name]]$df, file.path(out_dir, paste0(name, ".csv")))
  save_table_html(exports[[name]]$df, file.path(out_dir, paste0(name, ".html")),
                  title = exports[[name]]$titel)
}

# Den Rmd-Quellcode mit in den Deploy-Ordner kopieren, damit der Link
# "monotonicity_analysis.Rmd" des Ergebnisberichts (index.html) dort
# funktioniert.
invisible(file.copy("monotonicity_analysis.Rmd",
                    file.path(out_dir, "monotonicity_analysis.Rmd"),
                    overwrite = TRUE))

Die exportierten Detailtabellen liegen neben dieser Seite (im Unterordner monotonicity_analysis/), jeweils als CSV und als interaktive HTML-Tabelle:

Tabelle Inhalt CSV HTML
Schrittverteilung fällt / Plateau / steigt je Standort × Parameter CSV HTML
Verletzungen alle 13 Fälle mit Volumen-Gegenprobe (m³) CSV HTML
Überlaufvolumen Monotonie des Volumens in m³ CSV HTML
Verdunstung ET-Richtung je Parameter CSV HTML
Ambiguitätsband Eindeutigkeit der Zulässigkeitsgrenze je Ziel x CSV HTML
Schwellentreppe Monotonie von a*(x) je Branch CSV HTML
kf-Dominanz ET / Überläufe / Volumen je kf-Stufe CSV HTML
Warmstart-Designs billigstes zulässiges Raster-Design je x und Speichertyp CSV HTML