Skip to contents

This vignette is a small, self-contained reduction of the full Wien workflow used for end-to-end checks (CI and review) and — since 2026-05-07 — for diagnosing the very high modelled ET share (~47-58 %) versus the SWIMM Urban Eva reference of ~7 % for a comparable parametrisation. Geometry is fixed at Daniel’s reference design (mulde_area = 75, mulde_height = 200, filter_height = 300, storage_height = 500, filter_kf = 36, bottom_kf = 12).

Three corrections to the base.h5 defaults are also applied unconditionally on every row, after Daniel’s review of the XLSX diff against the SWIMM-UrbanEva run:

  • Dach/Berechnungsparameter/Evapotranspiration_aktiv is forced to 0 — the 1000 m² roof is impervious and has no vegetation, so the full crop-ET pathway should not run there. The shipped base.h5 ships this on, which silently activated all of the vegetation-ET parameters on the roof. With this flag at 0 the engine skips writing the connected-area Dach.h5; the package’s result reader and water-balance pipeline now handle a missing connected-area H5 by reporting only the element side (element.*_ columns populated, connectedarea.*_ columns left as NA).
  • Mulde_Rigole/Eigenschaften_Oberflaeche/EvapPond is forced to 0 — when the grass is submerged in a ponded swale, the open-water pond-ET pathway must not run alongside the crop-coefficient one on the same surface.
  • Mulde_Rigole/Parameter_Evapotranspiration/LAI_LeafAreaIndex is pinned to 3.9 — the grass value from Hörnschemeyer et al. (Water 2023, 15, 2840, Tab. 6, plant type 5 = grasses/herbs). The shipped base.h5 value is 8.5. Michael’s May 2026 sweep already showed LAI 3.9 vs 8.5 only changes the modelled ET share by ~0.1 pp, so this is mostly a hygiene fix rather than the smoking gun.

The 12-scenario grid varies three engine-side switches that are candidate ET-discrepancy drivers:

  • //Berechnungsparameter/keineVerdunstungBeiRegen0 (default, ET active during rain — physically suspect) vs 1 (ET suppressed while raining).
  • //Berechnungsparameter/Hoernschemeyer_aktiv0 (default, generic ET model) vs 1 (Hoernschemeyer et al. 2023 scheme with seasonal Growth/Shading modulation; with the current flat Growth_1 = Shading_1 = 1 curves the legacy path effectively unmodulates ET).
  • //Massnahmenelemente/Mulde_Rigole/Parameter_Evapotranspiration/ET0ref_GrasReferenzverdunstung — default 0.01 looks like a scaling factor, not a reference ET₀ value. Sweep 0, 1, 100 to check whether the parameter acts as a multiplier on the ET₀ curve (in which case 1 is “normal” behaviour and the 0.01 default is what is currently throttling / unbalancing ET).

After the run, the per-scenario *.h5 inputs are dumped to a single XLSX file (raindrop_wien_minimal_params.xlsx) with one sheet per scenario and a base sheet for the un-modified template, so the exact engine input for any row can be diffed against the others.

Input data

Precipitation (10-minute resolution) and evapotranspiration (daily ET0) for 2011-2025 are provided by GeoSphere Austria (Österreichischer Wetterdienst, formerly ZAMG) and ship with the package under inst/extdata/models/wien/. The HDF5 model template (base.h5) is produced with the Tandler “Regenwasserbewirtschaftung” calculation engine; the engine itself is downloaded from the KWB-R/kwb.raindrop.binaries GitHub Release on demand via kwb.raindrop::download_engine().

Static base.h5 parameter overview

All values present in the shipped base.h5 template. The scenarios below override geometry, three ET-related engine switches, plus the three corrections listed above (roof ET off, pond ET off, LAI = 3.9). Everything else listed here is the static default that drives the simulation.

library(kwb.raindrop)

h5 <- hdf5r::H5File$new(path_base, mode = "r")
all_vals <- kwb.raindrop::h5_read_values(h5)
h5$close_all()

format_value <- function(v) {
  if (is.data.frame(v)) {
    sprintf("[time series: %d rows, columns: %s]",
            nrow(v), paste(names(v), collapse = ", "))
  } else if (length(v) > 1L) {
    paste(format(v, trim = TRUE), collapse = ", ")
  } else {
    format(v, trim = TRUE)
  }
}

base_params <- tibble::tibble(
  parameter = names(all_vals),
  value = vapply(all_vals, format_value, character(1L))
) |>
  dplyr::arrange(parameter)

DT::datatable(
  base_params,
  filter = "top",
  options = list(pageLength = 25, autoWidth = TRUE),
  caption = sprintf("All %d parameters in base.h5 (Wien)", nrow(base_params))
)

Twelve-scenario parameter grid

Daniel’s reference geometry at every row; the cross product of keineVerdunstungBeiRegen × Hoernschemeyer_aktiv × ET0ref gives 2 × 2 × 3 = 12 scenarios.

path_list <- list(
  modelname = "Wien",
  root_path = file.path(tempdir(), "raindrop_wien_minimal"),
  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 = system.file("extdata/models/wien/base.h5",  package = "kwb.raindrop"),
  path_exe  = if (is_windows) kwb.raindrop::download_engine() else NA_character_,
  path_et   = system.file("extdata/models/wien/et.csv",   package = "kwb.raindrop"),
  path_rain = system.file("extdata/models/wien/rain.csv.gz", package = "kwb.raindrop"),
  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>"
)

param_grid <- expand.grid(
  keineVerdunstungBeiRegen = c(0L, 1L),
  Hoernschemeyer_aktiv     = c(0L, 1L),
  ET0ref_factor            = c(0, 1, 100),
  KEEP.OUT.ATTRS = FALSE,
  stringsAsFactors = FALSE
)
param_grid <- tibble::as_tibble(param_grid) |>
  dplyr::mutate(
    scenario_name                = sprintf("s%05d", seq_len(dplyr::n())),
    connected_area               = 1000,
    mulde_area                   = 75,
    mulde_height                 = 200,
    filter_hydraulicconductivity = 36,
    filter_height                = 300,
    storage_height               = 500,
    bottom_hydraulicconductivity = 12,
    rain_factor                  = 1
  ) |>
  dplyr::relocate(scenario_name, .before = dplyr::everything())

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

psi_s_mm <- function(kf_mmh) (3.237 * (kf_mmh / 25.4)^(-0.328)) * 25.4

paths <- kwb.utils::resolve(path_list)

timeseries_et <- readr::read_delim(paths$path_et, delim = ";", col_types = "cd") |>
  dplyr::mutate(
    date = lubridate::dmy(date),
    time = as.integer(difftime(date, min(date), units = "hours"))
  ) |>
  dplyr::select(-date) |>
  dplyr::filter(!is.na(value)) |>
  dplyr::relocate(time, .before = value)
timeseries_et$time[nrow(timeseries_et)] <- ceiling(timeseries_et$time[nrow(timeseries_et)])

timeseries_rain <- readr::read_csv(paths$path_rain, show_col_types = FALSE) |>
  dplyr::rename(datetime = time, value = rr) |>
  dplyr::mutate(time = as.double(difftime(datetime, min(datetime), units = "secs")) / 3600) |>
  dplyr::filter(!is.na(value)) |>
  dplyr::select(-datetime, -station) |>
  dplyr::relocate(time, .before = value)
timeseries_rain$time[nrow(timeseries_rain)] <- ceiling(timeseries_rain$time[nrow(timeseries_rain)])

# Align ET / rain end-times to the longer of the two
if (max(timeseries_rain$time) > max(timeseries_et$time)) {
  timeseries_et <- dplyr::bind_rows(
    timeseries_et,
    tibble::tibble(time = max(timeseries_rain$time),
                   value = timeseries_et$value[nrow(timeseries_et)])
  )
}
if (max(timeseries_et$time) > max(timeseries_rain$time)) {
  timeseries_rain <- dplyr::bind_rows(
    timeseries_rain,
    tibble::tibble(time = max(timeseries_et$time),
                   value = timeseries_rain$value[nrow(timeseries_rain)])
  )
}

# Convert rain from mm to mm/h (per 10-min interval)
period <- c(diff(timeseries_rain$time), mean(diff(timeseries_rain$time)))
timeseries_rain$value <- timeseries_rain$value / period

Run the twelve scenarios

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

  # ET-diagnostics sweep dimensions
  vals$`//Berechnungsparameter/keineVerdunstungBeiRegen` <- param_grid_tmp$keineVerdunstungBeiRegen
  vals$`//Berechnungsparameter/Hoernschemeyer_aktiv` <- param_grid_tmp$Hoernschemeyer_aktiv
  vals$`//Massnahmenelemente/Mulde_Rigole/Parameter_Evapotranspiration/ET0ref_GrasReferenzverdunstung` <-
    param_grid_tmp$ET0ref_factor

  # Roof: no vegetation, no ET — the 1000 m² Dach is impervious in this
  # case study. With the flag at 0 the engine skips writing Dach.h5
  # entirely; the result reader and water-balance pipeline now tolerate
  # a missing connected_area H5 (see get_simulation_results_optim_parallel
  # and add_overflow_events_and_waterbalance).
  vals$`//Massnahmenelemente/Dach/Berechnungsparameter/Evapotranspiration_aktiv` <- 0
  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` <- param_grid_tmp$rain_factor
  vals$`//Massnahmenelemente/Mulde_Rigole/Allgemein/Flaeche` <- param_grid_tmp$mulde_area
  vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/Ueberlaufhoehe` <- param_grid_tmp$mulde_height
  # Pond ET off — when the grass is submerged in a ponded swale, the
  # crop-coefficient pathway should not also collect open-water ET on
  # the same surface. Base.h5 ships EvapPond = 1; force to 0 here.
  vals$`//Massnahmenelemente/Mulde_Rigole/Eigenschaften_Oberflaeche/EvapPond` <- 0
  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
  # Pin LAI to the grass value from Hörnschemeyer et al. (Water 2023,
  # 15, 2840, Tab. 6, plant type 5 = grasses/herbs); base.h5 ships 8.5.
  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)


  vals$`//Kurven/ET0` <- timeseries_et
  vals$`//Kurven/Regen` <- timeseries_rain
  vals$`//Kurven/Growth_1`$time[2] <- max(timeseries_rain$time)
  vals$`//Kurven/Shading_1`$time[2] <- max(timeseries_rain$time)

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

kwb.raindrop::run_scenarios(
  indices = seq_len(nrow(param_grid)),
  run_one_scenario = run_one,
  timestep_hours = 0.1,
  debug = FALSE,
  parallel = FALSE,
  show_progress = FALSE
)
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00001.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00002.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00003.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00004.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00005.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00006.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00007.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00008.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00009.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00010.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00011.h5'
#> had status 1
#> Warning in system(cmd, intern = intern, wait = wait | intern,
#> show.output.on.console = wait, : running command 'C:\Windows\system32\cmd.exe
#> /c
#> C:\Users\runneradmin\AppData\Local\R\cache\R\kwb.raindrop\2026-02-24\Regenwasserbewirtschaftung.exe
#> C:\Users\runneradmin\AppData\Local\Temp\Rtmp0YIRXR\raindrop_wien_minimal\models\Wien\input\s00012.h5'
#> had status 1
#> [[1]]
#> NULL
#> 
#> [[2]]
#> NULL
#> 
#> [[3]]
#> NULL
#> 
#> [[4]]
#> NULL
#> 
#> [[5]]
#> NULL
#> 
#> [[6]]
#> NULL
#> 
#> [[7]]
#> NULL
#> 
#> [[8]]
#> NULL
#> 
#> [[9]]
#> NULL
#> 
#> [[10]]
#> NULL
#> 
#> [[11]]
#> NULL
#> 
#> [[12]]
#> NULL

Persist per-scenario engine inputs to XLSX

The XLSX dump bundles, in one file:

  • base — full flattened list of base.h5 values (un-modified template).
  • timeseries_info — period and water-balance totals for the rain and ET0 series fed to every scenario (these are identical across runs).
  • applied_settings — long-format diff of every key the package writes on top of base.h5, per scenario (scenario × parameter × base_value × scenario_value). Use this to see at a glance exactly what RAINDROP is being told to change.
  • s00001, s00002, …, s00012 — full per-scenario H5 dump (the same view as base, but for each scenario’s modified input file).
format_h5_value <- function(v) {
  if (is.null(v)) {
    "<NULL>"
  } else if (is.data.frame(v)) {
    sprintf("[time series: %d rows; cols: %s]",
            nrow(v), paste(names(v), collapse = ", "))
  } else if (length(v) > 1L) {
    paste(format(v, trim = TRUE), collapse = ", ")
  } else {
    format(v, trim = TRUE)
  }
}

vals_to_tibble <- function(vals) {
  tibble::tibble(
    parameter = names(vals),
    value     = vapply(vals, format_h5_value, character(1L))
  ) |>
    dplyr::arrange(parameter)
}

vals_equal <- function(a, b) {
  if (is.null(a) || is.null(b)) return(identical(a, b))
  isTRUE(all.equal(a, b))
}

# --- Base sheet -----------------------------------------------------------
h5_base <- hdf5r::H5File$new(path_base, mode = "r")
base_vals <- kwb.raindrop::h5_read_values(h5_base)
h5_base$close_all()

sheets <- list(base = vals_to_tibble(base_vals))

# --- Timeseries info (identical across all scenarios) ---------------------
# `timeseries_rain$value` is mm/h after the per-interval scaling earlier;
# multiply by the original interval (in h) to recover total mm.
rain_period_h <- c(diff(timeseries_rain$time),
                   mean(diff(timeseries_rain$time)))
rain_total_mm <- sum(timeseries_rain$value * rain_period_h)
sim_period_h  <- max(timeseries_rain$time) - min(timeseries_rain$time)
sim_period_yr <- sim_period_h / (24 * 365.25)

sheets$timeseries_info <- tibble::tibble(
  series        = c("Rain (//Kurven/Regen)", "ET0 (//Kurven/ET0)"),
  n_rows        = c(nrow(timeseries_rain), nrow(timeseries_et)),
  unit          = c("mm/h (engine input; convert via *period_h to mm)",
                    "mm/day"),
  period_hours  = round(c(sim_period_h, max(timeseries_et$time) -
                                          min(timeseries_et$time)), 1),
  period_years  = round(rep(sim_period_yr, 2), 2),
  total_mm      = round(c(rain_total_mm, sum(timeseries_et$value)), 1),
  mean_per_year = round(c(rain_total_mm, sum(timeseries_et$value)) /
                          sim_period_yr, 1),
  min_value     = c(min(timeseries_rain$value), min(timeseries_et$value)),
  max_value     = c(max(timeseries_rain$value), max(timeseries_et$value))
)

# --- Applied settings: per-scenario diff vs. base.h5 ----------------------
applied_rows <- list()
for (sname in param_grid$scenario_name) {
  p <- kwb.utils::resolve(path_list, dir_target = sname)
  h5_s <- hdf5r::H5File$new(p$path_target_input, mode = "r")
  scenario_vals <- kwb.raindrop::h5_read_values(h5_s)
  h5_s$close_all()
  sheets[[sname]] <- vals_to_tibble(scenario_vals)

  for (k in union(names(base_vals), names(scenario_vals))) {
    if (!vals_equal(base_vals[[k]], scenario_vals[[k]])) {
      applied_rows[[length(applied_rows) + 1L]] <- tibble::tibble(
        scenario       = sname,
        parameter      = k,
        base_value     = format_h5_value(base_vals[[k]]),
        scenario_value = format_h5_value(scenario_vals[[k]])
      )
    }
  }
}
sheets$applied_settings <- if (length(applied_rows) > 0L) {
  dplyr::bind_rows(applied_rows) |>
    dplyr::arrange(parameter, scenario)
} else {
  tibble::tibble(scenario = character(),
                 parameter = character(),
                 base_value = character(),
                 scenario_value = character())
}

# Re-order: base + timeseries_info + applied_settings + per-scenario sheets.
sheets <- c(sheets[c("base", "timeseries_info", "applied_settings")],
            sheets[param_grid$scenario_name])

xlsx_path <- file.path(tempdir(), "raindrop_wien_minimal_params.xlsx")
writexl::write_xlsx(sheets, path = xlsx_path)
message("Wrote XLSX (", length(sheets),
        " sheets: base + timeseries_info + applied_settings + ",
        nrow(param_grid), " scenarios) to:\n  ",
        xlsx_path)
#> Wrote XLSX (15 sheets: base + timeseries_info + applied_settings + 12 scenarios) to:
#>   C:\Users\RUNNER~1\AppData\Local\Temp\Rtmp0YIRXR/raindrop_wien_minimal_params.xlsx

Results

simulation_results <- kwb.raindrop::get_simulation_results_optim_parallel(
  paths = paths,
  path_list = path_list,
  simulation_names = param_grid$scenario_name,
  debug = FALSE
)
#> Reading results files in parallel ('Mulde_Rigole.h5|Dach.h5') for 12 model runs

# get_simulation_results_optim_parallel() now returns NULL only when the
# element H5 (Mulde_Rigole.h5) is missing; a missing connected-area H5
# (Dach.h5) yields a partial result with connected_area = NULL. Both cases
# are absorbed by add_overflow_events_and_waterbalance(), which emits a
# warning and fills the corresponding columns with NA.
simulation_results_optimisation <- kwb.raindrop::add_overflow_events_and_waterbalance(
  simulation_results = simulation_results,
  event_separation_hours = 4
)
#> Warning in FUN(X[[i]], ...): Scenario 's00001' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00002' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00003' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00004' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00005' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00006' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00007' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00008' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00009' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00010' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00011' is NULL -- returning a row with
#> NA for all metrics.
#> Warning in FUN(X[[i]], ...): Scenario 's00012' is NULL -- returning a row with
#> NA for all metrics.

results <- param_grid |>
  dplyr::left_join(simulation_results_optimisation, by = c("scenario_name" = "s_name")) |>
  dplyr::relocate(scenario_name, .before = connected_area)

# Attach construction-cost columns (storage layer assumed infiltration box;
# switch to gravel trench via storage_type = "gravel_trench" or a per-row
# storage_type column in param_grid).
results <- kwb.raindrop::compute_costs(results)

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

The cost_* columns enable cost-aware optimisation: filter or sort the table by cost_total to find the cheapest scenario that still meets a hydraulic target (low overflow count, high evapotranspiration share, …). Switching the storage layer between infiltration box (Austrian “Sickerbox”, ≈ 95 % porosity, 350 EUR/m³) and gravel trench (Austrian “Schotterrigol”, ≈ 30 % porosity, 50 EUR/m³) trades storage volume against cost; pass storage_type = "gravel_trench" to compute_costs() (or add a storage_type column to param_grid) to explore that trade-off.