Tutorial: Calibrating a Wflow Hydrological Model
Goal
This tutorial shows how to calibrate a Wflow‑based hydrological model using Optienv. We treat calibration as a three‑objective optimization problem:
KGEp — maximize
logNSE — maximize
bias_score — maximize
We will:
Prepare the wrapper that runs Wflow and computes metrics.
Provide variables and objectives via CSV.
Run search (NSGA‑II and NSGA‑III options).
Extract the global front and compute normalized HV.
Folder structure (example)
your_project/
├─ wflow_gilgel_abay/ # model directory (inputs, scripts, batch)
│ ├─ wrapper_wflow.py # your wrapper
│ ├─ Wflow-julia.bat # starts the Wflow/Julia simulation
│ ├─ compute_metrics.py # computes KGEp/logNSE/bias_score → metrics.txt
│ ├─ robust_update_staticmaps.py # any preprocessing needed by the model
│ ├─ observed_streamflow.csv
│ └─ ... (Wflow assets)
├─ variable_declaration.csv
├─ objective_declaration.csv
└─ run_search.json
Wrapper contract (recap)
Optienv writes variable_values.csv inside a working copy of your
model directory. Your wrapper must:
Read
variable_values.csvand apply parameters to the model inputs.Run the simulation (e.g., by calling the provided batch or Julia script).
Compute metrics and write
metrics.txt(or directly write objectives).Produce
objective_values.csvwith two columns:Name,Value.
Example wrapper outline
File: wflow_gilgel_abay/wrapper_wflow.py (outline only)
from __future__ import annotations
import os, csv, subprocess, sys
def create_tbl_files(model_folder: str) -> None:
# Map variable_values.csv → per-parameter .tbl files for Wflow inputs
# Make sure outputs go INSIDE model_folder (no global temp paths).
...
def update_static_maps(model_folder: str) -> None:
# Call robust_update_staticmaps with --staticmap, --param_dir, --backup
...
def run_wflow(model_folder: str) -> None:
# IMPORTANT: run in the working directory; avoid shell=True
env = os.environ.copy()
# If using Julia, avoid global depot contention (optional but recommended):
env.setdefault("JULIA_PROJECT", model_folder)
env.setdefault("JULIA_DEPOT_PATH", os.path.join(model_folder, ".julia_depot"))
exe = os.path.join(model_folder, "Wflow-julia.bat")
subprocess.run([exe], cwd=model_folder, env=env, check=True)
def calculate_metrics(model_folder: str) -> None:
# e.g., compute_metrics.py writes metrics.txt with three comma-separated values
sys.path.insert(0, os.path.dirname(__file__))
import compute_metrics
sys.argv = [sys.argv[0],
"--sim", os.path.join(model_folder, "run_default/output.csv"),
"--obs", os.path.join(model_folder, "observed_streamflow.csv"),
"--out_dir", model_folder]
compute_metrics.main()
def prepare_objectives(model_folder: str) -> None:
inp = os.path.join(model_folder, "metrics.txt")
outp = os.path.join(model_folder, "objective_values.csv")
names = ["KGEp", "logNSE", "bias_score"]
with open(inp, "r") as f:
values = f.readline().strip().split(",")
if len(values) != 3:
raise RuntimeError("Expected 3 metrics in metrics.txt")
with open(outp, "w", newline="") as f:
w = csv.writer(f); w.writerow(["Name", "Value"])
w.writerows(zip(names, values))
def search_and_apply_variables(model_folder: str) -> None:
create_tbl_files(model_folder)
update_static_maps(model_folder)
run_wflow(model_folder)
calculate_metrics(model_folder)
prepare_objectives(model_folder)
Variables and objectives
Objectives (three maximize) → objective_declaration.csv
Name,Objective
KGEp,maximize
logNSE,maximize
bias_score,maximize
Variables → variable_declaration.csv
Paste the full list provided for your catchment. The header must be
Name,Upper_bound,Lower_bound. Below is a compact example showing the pattern;
see the appendix for the full list (as supplied).
Name,Upper_bound,Lower_bound
thetaS.1,0.01,0.7
thetaS.2,0.01,0.7
...
N_River.1,0.04,0.5
N_River.2,0.04,0.5
...
SoilThickness.1,2000,8950
...
KsatVer.1,500,10000
...
RootingDepth.1,100,5000
...
KsatHorFrac.1,800,1000
...
InfiltCapSoil.1,100,400
...
Appendix A (full variable list)
Name,Upper_bound,Lower_bound
thetaS.1,0.01,0.7
thetaS.2,0.01,0.7
thetaS.3,0.01,0.7
thetaS.4,0.01,0.7
thetaS.5,0.01,0.7
thetaS.6,0.01,0.7
thetaS.7,0.01,0.7
thetaS.8,0.01,0.7
thetaS.9,0.01,0.7
thetaS.10,0.01,0.7
thetaS.11,0.01,0.7
thetaS.12,0.01,0.7
thetaS.13,0.01,0.7
N_River.1,0.04,0.5
N_River.2,0.04,0.5
N_River.3,0.04,0.5
N_River.4,0.04,0.5
N_River.5,0.04,0.5
N_River.6,0.04,0.5
N_River.7,0.04,0.5
N_River.8,0.04,0.5
N_River.9,0.04,0.5
N_River.10,0.04,0.5
N_River.11,0.04,0.5
N_River.12,0.04,0.5
N_River.13,0.04,0.5
SoilThickness.1,2000,8950
SoilThickness.2,2000,8950
SoilThickness.3,2000,8950
SoilThickness.4,2000,8950
SoilThickness.5,2000,8950
SoilThickness.6,2000,8950
SoilThickness.7,2000,8950
SoilThickness.8,2000,8950
SoilThickness.9,2000,8950
SoilThickness.10,2000,8950
SoilThickness.11,2000,8950
SoilThickness.12,2000,8950
SoilThickness.13,2000,8950
KsatVer.1,500,10000
KsatVer.2,500,10000
KsatVer.3,500,10000
KsatVer.4,500,10000
KsatVer.5,500,10000
KsatVer.6,500,10000
KsatVer.7,500,10000
KsatVer.8,500,10000
KsatVer.9,500,10000
KsatVer.10,500,10000
KsatVer.11,500,10000
KsatVer.12,500,10000
KsatVer.13,500,10000
RootingDepth.1,100,5000
RootingDepth.2,100,5000
RootingDepth.3,100,5000
RootingDepth.4,100,5000
RootingDepth.5,100,5000
RootingDepth.6,100,5000
RootingDepth.7,100,5000
RootingDepth.8,100,5000
RootingDepth.9,100,5000
RootingDepth.10,100,5000
RootingDepth.11,100,5000
RootingDepth.12,100,5000
RootingDepth.13,100,5000
KsatHorFrac.1,800,1000
KsatHorFrac.2,800,1000
KsatHorFrac.3,800,1000
KsatHorFrac.4,800,1000
KsatHorFrac.5,800,1000
KsatHorFrac.6,800,1000
KsatHorFrac.7,800,1000
KsatHorFrac.8,800,1000
KsatHorFrac.9,800,1000
KsatHorFrac.10,800,1000
KsatHorFrac.11,800,1000
KsatHorFrac.12,800,1000
KsatHorFrac.13,800,1000
InfiltCapSoil.1,100,400
InfiltCapSoil.2,100,400
InfiltCapSoil.3,100,400
InfiltCapSoil.4,100,400
InfiltCapSoil.5,100,400
InfiltCapSoil.6,100,400
InfiltCapSoil.7,100,400
InfiltCapSoil.8,100,400
InfiltCapSoil.9,100,400
InfiltCapSoil.10,100,400
InfiltCapSoil.11,100,400
InfiltCapSoil.12,100,400
JSON configuration
File: run_search.json
{
"model": {
"model_dir": "./wflow_gilgel_abay",
"wrapper_file": "wrapper_wflow.py",
"variables_csv": "./variable_declaration.csv",
"objectives_csv": "./objective_declaration.csv"
},
"algorithm": {
"population_size": 60,
"generations": 40
}
}
You can start modestly with 60×40 to verify throughput, then scale.
Running the calibration
NSGA‑II baseline
optienv search -c run_search.json \
--algo nsga2 -j 4 --seed 11 \
--label-columns --no-save-final-csvs
NSGA‑III alternative (3 objectives)
With 3 objectives you can also use NSGA‑III with reference directions. Two convenient choices are:
--ref-parts 8→ 45 directions (match population ≈ 45)--ref-parts 12→ 91 directions (match population ≈ 91)
# Example with p=12 (91 directions)
jq '.algorithm.population_size=91' run_search.json > run_search_nsga3.json
optienv search -c run_search_nsga3.json \
--algo nsga3 --ref-parts 12 \
-j 4 --seed 11 \
--label-columns --no-save-final-csvs
(You may keep your population at 60 and still run NSGA‑III; matching the number of reference directions generally improves spread.)
Optional wrapper controls
If your metrics script supports warm‑up/epsilon factors, you can pass them as environment variables:
WRAPPER_WARMUP_YEARS=1 WRAPPER_EPS_FACTOR=0.25 \
optienv search -c run_search.json --algo nsga2 -j 4 --seed 11
Outputs & analysis
History:
results/history_seed{SEED}.csv— all generations in one fileGlobal front (optionally ε‑thinned):
optienv front --epsilon 0.01 # → results/pareto_front_all.csv
Normalized HV (wide format by seed):
optienv hypervolume # → results/hypervolume.csv
Troubleshooting
Parallelism stalls or runs one at a time - Ensure the wrapper runs the batch/script with
cwd=model_folderandavoid
shell=Trueunless necessary.If using Julia, set a local depot via
JULIA_PROJECTandJULIA_DEPOT_PATHin the wrapper to avoid global cache contention when multiple workers start simultaneously.
No objectives written - Confirm the wrapper ends by writing a valid
objective_values.csvwiththe exact headers
Name,Value.Mixed population sizes in one history - Make sure you are not mixing results from different experiments into the
same
results/folder with the same--seed.Keep population size consistent within a single experiment; if you resume with checkpoints, the population is dictated by the checkpoint.
Checkpoint/resume for long jobs
optienv search ... --checkpoint-every 1 --resume-latest
Appendix B (expected metrics format)
If your metrics script writes metrics.txt, it must contain one line with
three comma‑separated values in the following order:
KGEp,logNSE,bias_score
Example:
0.76,0.81,0.04