Calibration Reference¶
pywmp.calibration provides a universal parameter calibration framework that works with any PyWMP simulation module. You wrap your simulation as a Python callable, declare parameter bounds, choose an objective function and algorithm, and the engine finds the optimal parameter set.
Installation¶
pip install pywmp # no extra needed — calibration uses scipy
Quick start¶
from pywmp.calibration import CalibrationEngine, ParameterSet
from pywmp.workflow.design_storm import DesignStormSimulation
import numpy as np
# --- Define your model function ---
def run_model(p):
sim = DesignStormSimulation(
storm_type="SCS_II",
total_depth_in=8.5,
duration_hr=24,
dt_hr=0.1,
)
sim.add_subbasin(
name="A",
area_mi2=3.2,
loss_method="SCS_CN",
loss_params={"CN": p["cn"], "ia_ratio": 0.2, "amc": 2},
transform_method="Clark",
transform_params={"Tc": p["tc"], "R": p["R"]},
)
results = sim.run()
return results.outflows["A"].values # numpy array
# --- Declare parameters: (name, lo, hi, initial) ---
params = ParameterSet([
("cn", 55.0, 90.0, 75.0), # SCS Curve Number
("tc", 0.5, 4.0, 2.0), # Time of concentration (hr)
("R", 0.5, 4.0, 1.5), # Clark storage coefficient (hr)
])
# --- Observed hydrograph (from gauge) ---
observed = np.loadtxt("data/observed_q.csv", delimiter=",")
# --- Run calibration ---
engine = CalibrationEngine(
model_fn = run_model,
observed = observed,
params = params,
objective = "nse",
)
results = engine.run_differential_evolution(maxiter=100)
print(results.summary())
Expected output:
========================================================
PyWMP Calibration Results
========================================================
Algorithm : differential_evolution
Objective : nse (maximize)
Evaluations : 1847
Converged : True
Optimal NSE : 0.923
--------------------------------------------------------
Parameter : Optimal Range
cn : 81.4 [55.0 – 90.0]
tc : 1.7 [0.5 – 4.0]
R : 2.1 [0.5 – 4.0]
========================================================
ParameterSet¶
ParameterSet(bounds: list[tuple[str, float, float, float]])
Each entry is (name, lower_bound, upper_bound, initial_value).
from pywmp.calibration import ParameterSet
params = ParameterSet([
("CN", 55.0, 90.0, 75.0),
("manning_n", 0.01, 0.15, 0.035),
("lag_hr", 0.3, 3.0, 1.0),
])
The ParameterSet is passed to CalibrationEngine and controls which values are varied during optimization. Access results as a dict: p["CN"], p["manning_n"].
ParameterBound¶
@dataclass
class ParameterBound(name, lo, hi, initial=None, log_scale=False)
Defines search bounds for a single calibration parameter.
| Field | Description |
|---|---|
name |
Parameter name string (must match model_fn kwarg) |
lo, hi |
Lower and upper bounds |
initial |
Starting point; if None, midpoint of [lo, hi] |
log_scale |
If True, search on log₁₀ space (useful for orders-of-magnitude parameters such as Ks) |
from pywmp.calibration import ParameterBound
# Example: hydraulic conductivity spans orders of magnitude — use log scale
ParameterBound("Ks", lo=1e-6, hi=1e-2, initial=1e-4, log_scale=True)
CalibrationEngine¶
CalibrationEngine(model_fn, observed, params, objective="nse", dt=None)
| Parameter | Type | Description |
|---|---|---|
model_fn |
callable |
A function f(p: dict) → np.ndarray that runs the simulation and returns the simulated hydrograph |
observed |
np.ndarray |
Observed streamflow time series (same length as model output) |
params |
ParameterSet |
Parameter bounds and initial values |
objective |
str \| callable |
Objective function (see table below) |
dt |
float \| None |
Timestep for time-alignment checks (hours) |
Objective functions¶
| Name | Full name | Direction | Notes |
|---|---|---|---|
"nse" |
Nash-Sutcliffe Efficiency | Maximize | Range (−∞, 1]; ≥0.65 = satisfactory |
"kge" |
Kling-Gupta Efficiency | Maximize | Range (−∞, 1]; ≥0.65 = satisfactory |
"nse_log" |
Log-NSE | Maximize | Emphasizes low flows; useful for baseflow |
"pbias" |
−|Percent Bias| | Maximize (less bias = higher) | ±10% = very good |
"rmse" |
Root Mean Square Error | Minimize | Units match observed |
"peak_flow" |
Peak flow error (%) | Minimize | Good for flood design |
"volume" |
Volume error (%) | Minimize | Good for water balance |
Custom objective:
def my_obj(simulated, observed):
# return a scalar — engine maximizes by default
peak_err = abs(simulated.max() - observed.max()) / observed.max()
vol_err = abs(simulated.sum() - observed.sum()) / observed.sum()
return -(0.7 * peak_err + 0.3 * vol_err) # negative = minimize
engine = CalibrationEngine(model_fn, observed, params, objective=my_obj)
.run_differential_evolution(**kwargs)¶
Global search using differential evolution (Storn & Price, 1997). Recommended as the default for any problem with more than 3 parameters or a multi-modal response surface.
results = engine.run_differential_evolution(maxiter=200, popsize=15)
.run(algorithm, **kwargs)¶
Generic entry-point supporting multiple algorithms:
| Algorithm | Best for | Speed | kwargs |
|---|---|---|---|
"differential_evolution" |
Any problem; most robust (default) | Slow | maxiter, popsize, tol |
"sce_ua" |
Classic hydrology calibration; 3–15 parameters | Medium | maxiter, n_complexes |
"nelder_mead" |
Smooth 2–4 param surfaces | Fast | maxiter, xatol, fatol |
"dual_annealing" |
Multi-modal, many parameters | Medium | maxiter, initial_temp |
"lhs" |
Space-filling sensitivity survey | Very fast | n_samples |
"grid" |
Exhaustive sweep (≤3 params only) | Variable | n_steps |
results = engine.run("differential_evolution", maxiter=200, popsize=15)
results = engine.run("sce_ua", maxiter=500, n_complexes=5) # Duan et al. (1992)
results = engine.run("lhs", n_samples=500) # fast parameter space survey
.run_parallel(n_workers=4, **kwargs)¶
Parallel variant of differential evolution that distributes model evaluations across multiple CPU workers. Useful when each model evaluation is expensive (>1 s).
results = engine.run_parallel(n_workers=8, maxiter=200, popsize=15)
CalibrationResults¶
| Attribute | Description |
|---|---|
.optimal_params |
dict[str, float] — best parameter values found |
.optimal_value |
Objective function value at the optimum |
.n_evaluations |
Total number of model function calls |
.algorithm |
Algorithm used |
.converged |
Whether the algorithm reported convergence |
.history |
List of CalibrationRecord (one per evaluation) |
Methods¶
| Method | Description |
|---|---|
.summary() |
Print parameter table + objective value |
.plot_convergence(path) |
Objective value vs. evaluation number |
.plot_dotty_plots(path) |
Parameter sensitivity scatter plots |
.to_csv(path) |
Save full evaluation history to CSV |
.to_json(path) |
Save results to JSON |
# After calibration: extract and apply optimal parameters
best = results.optimal_params
print(f"Best CN={best['cn']:.1f}, Tc={best['tc']:.2f} hr, R={best['R']:.2f} hr")
print(f"NSE = {results.optimal_value:.3f}")
results.plot_convergence("output/convergence.png")
results.plot_dotty_plots("output/dotty_plots.png")
results.to_csv("output/calibration_history.csv")
Sensitivity analysis¶
Use Latin Hypercube Sampling ("lhs") for a fast parameter sensitivity survey before running a full optimization:
# Step 1: LHS survey (fast — just samples the space)
survey = engine.run("lhs", n_samples=300)
survey.plot_dotty_plots("output/sensitivity_survey.png")
# Step 2: Focused optimization using survey results to set tighter bounds
focused_params = ParameterSet([
("cn", 70.0, 88.0, survey.optimal_params["cn"]),
("tc", 1.0, 3.0, survey.optimal_params["tc"]),
("R", 1.0, 3.0, survey.optimal_params["R"]),
])
engine2 = CalibrationEngine(run_model, observed, focused_params, objective="nse")
final = engine2.run_differential_evolution(maxiter=200)
print(final.summary())
Validation after calibration¶
After calibration, always validate on a separate storm event:
from pywmp.validation import ModelVsObserved
# Re-run model with optimal parameters on a validation event
best = results.optimal_params
val_sim = run_model_on_validation_event(best)
validator = ModelVsObserved(
simulated=val_sim,
observed=validation_observed,
dt=0.1,
label="Calibration validation — Feb 2024 storm",
)
print(validator.metrics().summary())
validator.plot("output/validation_plot.png")
See Validation Reference for full details.
Tips for good calibration¶
Parameter identifiability: The more parameters you calibrate simultaneously, the harder it is to find a unique solution. Start with 2–3 most sensitive parameters (usually CN/Ks, Tc or lag, and routing K).
Objective function selection: NSE emphasizes high flows (peaks); log-NSE emphasizes low flows (recession). For flood design, use "nse" or "peak_flow". For water balance, use "pbias" or "volume".
Avoid over-fitting: A high NSE on the calibration storm but poor performance on the validation storm means over-fitting. Use at least 2 calibration storms and validate on 1–2 independent events.
Performance ratings (Moriasi et al. 2007):
| Rating | NSE | |PBIAS| |
|---|---|---|
| Very good | ≥ 0.75 | < 10% |
| Good | 0.65–0.75 | 10–15% |
| Satisfactory | 0.50–0.65 | 15–25% |
| Unsatisfactory | < 0.50 | > 25% |