Parameters and calibration
All parameters for a model run are held in a single ParameterSet object.
Each parameter has the following attributes
(more information in the Python API):
component: the model component it belongs to (e.g.,
glacier,slow_reservoir)name: the full parameter name (e.g.,
degree_day_factor)unit: the physical unit (e.g.,
mm/d/°C)aliases: short names accepted by
set_values()(e.g.,a_snow)value: the currently assigned value
min / max: the valid range, used during calibration and for validation
default_value: a pre-set value, if any. Parameters with defaults (such as melt temperatures) are optional — you only need to set them if you want to deviate from the default.
mandatory: whether the user must supply a value (i.e., the parameter has no default)
prior: prior distribution for Bayesian or Monte Carlo calibration — see the calibration page
transform: an optional monotonic mapping between the parameter’s real value and a transformed value used during calibration — see Parameter transforms
Creating a parameter set
For pre-built models, call generate_parameters() on the model instance.
This produces a ParameterSet populated with all parameters appropriate for
the chosen model configuration, including their names, aliases, units, and
default ranges:
socont = models.Socont(soil_storage_nb=2)
parameters = socont.generate_parameters()
Assigning parameter values
Use set_values() with a dictionary. Keys can be either the full parameter
name (e.g., snowpack:degree_day_factor) or any alias (e.g., a_snow).
The matching is case-insensitive, so the literature capitalisation can be used
as is (e.g., PERC and perc are equivalent):
parameters.set_values({'A': 100, 'k_slow': 0.01, 'a_snow': 5})
Parameter constraints
Constraints enforce ordering relationships between parameters. They are checked during calibration — any parameter set that violates a constraint is rejected. To add a custom constraint:
parameters.define_constraint('k_slow_2', '<', 'k_slow_1')
Supported operators: > (or gt), >= (or ge), < (or lt),
<= (or le).
Models define some constraints automatically — for example, GSM-Socont requires
a_snow < a_ice because the ice melt factor must exceed the snow melt factor.
These built-in constraints can be removed when needed:
parameters.remove_constraint('a_snow', '<', 'a_ice')
Parameter ranges
Each parameter is generated with a default range. The calibration algorithm samples within this range, and values outside it are rejected. To adjust the range for a parameter:
parameters.change_range('a_snow', 2, 5)
Parameter transforms
A parameter can carry a transform: a monotonic mapping between its real value (the one passed to the C++ engine and stored in the parameter set) and a transformed value used during calibration. Searching in transformed space makes the optimisation better behaved — a storage capacity that spans orders of magnitude is calibrated in log space, while an exchange coefficient that may be negative or positive is calibrated through an inverse hyperbolic sine.
Pre-built models attach the appropriate transforms automatically. GR4J and GR6J
follow the airGR TransfoParam conventions:
Parameter |
Transform (real → transformed) |
Rationale |
|---|---|---|
X1 (production store capacity) |
|
spans orders of magnitude, > 0 |
X2 (groundwater exchange) |
|
signed (import or export) |
X3 (routing store capacity) |
|
spans orders of magnitude, > 0 |
X4 (unit-hydrograph base time) |
|
log-like, enforces X4 > 0.5 |
X5 (exchange threshold, GR6J) |
|
signed |
X6 (exponential store, GR6J) |
|
> 0 |
How it works:
The model always keeps and runs the real value; the transform is used only to map to and from the optimiser’s search space.
get_for_spotpy()maps each parameter’s realmin/maxthrough its transform, so the optimiser searches the transformed bounds.During calibration, the sampled (transformed) values are mapped back to real values automatically before each model run.
A
logtransform is undefined at zero, so where the default lower bound would fall there it is raised slightly for calibration (e.g. GR4J/GR6J X1 to 1 mm and X4 to 0.51 d).
Inspect or set transformed values directly:
import math
parameters.set_values({'X1': 350}) # real value
parameters.get_transformed('X1') # -> log(350)
# Set a value given in transformed space (mapped back to the real value):
parameters.set_values({'X1': math.log(500)}, transformed=True)
parameters.get('X1') # -> 500.0
Attach a custom transform to any parameter with a (real → transformed,
transformed → real) pair of monotonic functions:
parameters.set_transform('k_slow', math.log, math.exp)
Transforms are not supported on list-valued parameters.
Calibratable forcing parameters
Some aspects of forcing data preparation — elevation gradients, correction factors — can themselves be calibrated. Because these depend on the input data rather than the model structure, they are not generated automatically and must be added explicitly:
parameters.add_data_parameter('precip_corr_factor', 1, min_value=0.7, max_value=1.3)
parameters.add_data_parameter('precip_gradient', 0.05, min_value=0, max_value=0.2)
parameters.add_data_parameter('temp_gradients', -0.6, min_value=-1, max_value=0)
The first argument is the parameter name, the second is the initial value. For details on how these parameters link to spatialization operations, see the Spatialization section on the forcing page.
For seasonally varying quantities such as temperature lapse rates, monthly values and ranges can be specified:
parameters.add_data_parameter(
'temp_gradients',
[-0.6, -0.6, -0.6, -0.6, -0.7, -0.7, -0.8, -0.8, -0.8, -0.7, -0.7, -0.6],
min_value=[-0.8]*12,
max_value=[-0.3]*12
)
Calibration using SPOTPY
Hydrobricks uses the SPOTPY package for parameter calibration and sensitivity analysis. SPOTPY provides a unified interface to many optimization and sampling algorithms and records every model evaluation so results can be analysed after sampling.
By default, all parameters generated by generate_parameters() are eligible for
calibration. To restrict calibration to a specific subset, list the aliases or full
names of the parameters that should vary:
parameters.allow_changing = ['a_snow', 'k_quick', 'A', 'k_slow_1', 'percol',
'k_slow_2', 'precip_corr_factor']
Create a SPOTPY setup object that bundles the model, parameters, forcing, observations,
and objective function. The calibration helpers live in the hydrobricks.trainer
module. The warmup argument (in days) excludes the opening period of each run from
the objective function to avoid spin-up artefacts — see the
spin-up section for background. Alternatively, declare explicit
calibration/validation periods with a built-in spin-up, which scores
the whole calibration period instead of trimming its start:
import hydrobricks.trainer as trainer
spot_setup = trainer.SpotpySetup(
socont,
parameters,
forcing,
obs,
warmup=365,
obj_func='nse'
)
Note
You do not need to worry about the sign of the objective function. Hydrobricks
always computes it as a skill (higher is better): metrics that are naturally
maximized (NSE, KGE, …) are used as-is, while error metrics (MSE, RMSE, …) are
negated internally so that higher is still better. The sign each SPOTPY algorithm
expects is then applied automatically from the algorithm’s optimization direction
(some minimize, e.g. SCE-UA and NSGA-II; most maximize). There is therefore no
invert_obj_func flag: just pass a metric name (or a “higher is better” callable)
as obj_func and run the calibration through
trainer.calibrate (below), which applies the
correct sign for the chosen algorithm.
With the setup object ready, run the calibration with
trainer.calibrate, choosing an algorithm based
on the goal:
Optimization (finding the best parameter set): SCE-UA is well suited to multi-parameter hydrological calibration problems:
sampler = trainer.calibrate(
spot_setup,
'sceua',
10000,
dbname='socont_SCEUA',
dbformat='csv',
)
Sensitivity analysis (understanding which parameters matter): Monte Carlo sampling covers the full parameter space without steering towards any optimum:
sampler = trainer.calibrate(
spot_setup,
'mc',
10000,
dbname='socont_MC',
dbformat='csv',
save_sim=False,
)
After sampling, retrieve the best parameter set and its score with
trainer.get_best, and the full table of evaluated
sets with trainer.get_results. Both report the
score in metric space (higher is better, with the optimizer sign undone), so a KGE of
0.7 reads as 0.7, never -0.7:
best = trainer.get_best(sampler)
print(best['score'], best['parameters']) # e.g. 0.83 {'a_snow': 4.2, ...}
results = trainer.get_results(sampler) # pandas DataFrame: a 'score' column + params
SPOTPY’s own analysis tools also work on the raw records from sampler.getdata(),
e.g. to visualize parameter interactions and isolate high-performing samples (note that
getdata() stores the raw, possibly sign-flipped objective):
records = sampler.getdata()
# Plot parameter interactions across all samples
spotpy.analyser.plot_parameterInteraction(records)
plt.tight_layout()
plt.show()
# Restrict to the top-performing 10 % (posterior distribution)
posterior = spotpy.analyser.get_posterior(records, percentage=10)
spotpy.analyser.plot_parameterInteraction(posterior)
plt.tight_layout()
plt.show()
Calibration, validation and simulation periods
For split-sample testing, the periods can be declared once and drive the whole workflow — the model setup, the calibration and the per-period evaluation:
periods = hb.Periods(
calibration=('1981-01-01', '2000-12-31'),
validation=('2001-01-01', '2020-12-31'),
spinup='4y',
)
The simulation span defaults to the union of the declared periods (earliest
start to latest end). The spinup (see the spin-up section)
replaces the warmup: each model replays the first years of its own period to
warm the storages, so the whole declared period is evaluated and the
calibration and validation periods can be contiguous without losing a year of
observations.
Set the calibration model up over the calibration period and pass periods
to the setup (warmup must then be left unset):
socont.setup(
spatial_structure=hydro_units,
output_path='/path/to/dir',
period=periods.calibration,
spinup=periods.spinup,
)
obs = hb.DischargeObservations(periods) # loaded over the full span
obs.load_from_csv(...)
spot_setup = trainer.SpotpySetup(
socont, parameters, forcing, obs,
obj_func='kge_2012',
periods=periods,
)
sampler = trainer.calibrate(spot_setup, 'sceua', 10000)
For the validation, do not restart cold on the validation period: re-run the best parameters once over the full span (continuous, warm states) and read the score of every period from its date slice:
best = trainer.get_best(sampler)
# A fresh model over the full span (a model instance is single-use).
socont_full.setup(
spatial_structure=hydro_units,
output_path='/path/to/dir',
period=periods.simulation,
spinup=periods.spinup,
)
parameters.set_values(best['parameters'])
socont_full.run(parameters, forcing)
scores = hb.evaluate_periods(socont_full, obs, periods,
metrics=('kge_2012', 'nse'))
print(scores) # one row per period, one column per metric
The same forcing object serves every period (the forcing may be longer than the
modelling period; only coverage is required). A single date slice can also be
scored directly with model.eval(metric, obs, period=periods.validation).
See the complete example in examples/basics/calibrate_validate_periods.py.
Parallel calibration
Depending on the calibration approach, the model can be run many times with independent
parameter sets, so it can be spread across CPU cores. SPOTPY ships parallel backends
(parallel='mpc' for multiprocessing), but to use them the setup has to be sent to
worker processes. The model, forcing, and observations are backed by the C++ core and
cannot be pickled, so they must be rebuilt inside each worker rather than shipped.
The simplest way to do this is
calibrate_from_factory. You write a
single factory — a module-level function that builds everything and returns a
(model, parameters, forcing, obs) tuple — and one call does the rest. The same code
runs sequentially or in parallel; only parallel changes.
import hydrobricks.trainer as trainer
def build():
# Build the model, parameters, forcing and observations here, then:
return socont, parameters, forcing, obs
if __name__ == '__main__':
sampler = trainer.calibrate_from_factory(
build,
'mc',
10000,
warmup=365,
obj_func='nse',
dbname='socont_MC',
dbformat='csv',
parallel='mpc', # 'seq' for a single process
n_workers=4, # optional; default is all logical CPUs
)
results = sampler.getdata()
Note
The
'mpc'backend requires the optional pathos package (pip install pathos).The factory must be a top-level (module-level) function so it can be pickled; lambdas and closures will not work.
On Windows (and any platform that spawns workers) the call must run under an
if __name__ == '__main__':guard.Not every algorithm benefits equally: independent samplers such as
mc,lhs,ropeand thedreamchains scale close to linearly with the number of cores, whereas SCE-UA is largely sequential and sees only a modest speedup.
For finer control you can assemble the pieces yourself:
SpotpySetup.from_factory (whose
factory returns just (model, forcing, obs), with parameters passed separately)
builds the picklable setup, and
calibrate runs a given setup with the chosen
backend and n_workers. The single-process path is unchanged: a SpotpySetup built
directly from objects (no factory) still works with parallel='seq' (the default).
Calibrating on glacier mass balance
On glacierized catchments, the observed glacier mass balance (e.g. from GLAMOS) is a strong, independent constraint on the snow and ice melt parameters. Hydrobricks can use it alongside discharge during calibration. Everything happens on the Python side; the simulation itself is unchanged (there is no data assimilation).
Observed mass balance is loaded from a CSV file with
GlacierMassBalanceObservations, one of the
auxiliary observation classes in the hydrobricks.evaluation subpackage. The GLAMOS
“fixdate” products have a dedicated preset (whole-glacier or per elevation band; annual,
winter and summer balances), with the observation periods taken from the per-row dates in
the file, so calendar and hydrological years are both handled:
glacier_mb = hb.GlacierMassBalanceObservations.from_glamos(
'/path/to/massbalance_fixdate.csv',
kind='whole', # or 'elevationbins'
glacier_id='B43-03',
balance_types=('annual', 'winter', 'summer'),
)
For non-GLAMOS data, from_csv reads any tabular CSV: map the value and period
columns (explicit date_start_col / date_end_col, or a year_col with a
hydro_year_start month), optionally add elevation-band columns, and set the units:
glacier_mb = hb.GlacierMassBalanceObservations.from_csv(
'/path/to/mass_balance.csv',
value_col='Ba', balance_type='annual',
year_col='year', hydro_year_start='October', # or date_start_col/date_end_col
value_unit='mm_we',
)
What is compared (glaciological vs. geodetic)
Hydrobricks computes the glaciological (surface) mass balance — accumulation minus ablation at the glacier surface, which is exactly what the glaciological method (stakes, as used by GLAMOS) measures. Per glacier hydro unit and observation period:
where \(S\) is the glacier snowpack water equivalent (a stock) and \(M_\text{ice}\) the glacier ice-melt flux. This follows from \(B = (P_\text{snow} + \text{refreeze}) - (M_\text{snow} + M_\text{ice})\) together with \(\mathrm{d}S/\mathrm{d}t = P_\text{snow} + \text{refreeze} - M_\text{snow}\), so the snowfall, snowmelt and refreezing terms collapse into \(\Delta S\) and only the snowpack stock and the ice melt are needed.
This flux-based surface balance is preferred over a state difference \(\Delta(\text{snow} + \text{ice})\). With a delta-h evolution, the state difference per elevation band is contaminated by the dynamic ice redistribution and becomes a geodetic per-band balance, whereas GLAMOS reports the glaciological (surface) balance per band. The flux formula excludes dynamics and stays a clean surface balance whether or not the geometry evolves, and works with both the default infinite ice storage and a finite ice storage. Per-band values are normalized by the model’s own (time-varying) glacier area, for self-consistency with the simulated geometry.
Note
The glacier snowpack and ice-melt series must be recorded so they can be read from memory each iteration (no file dump). Two options:
create the model with
record_all=True(records everything), orrecord only what the signal needs by calling
glacier_mb.configure_recording(model)beforemodel.setup()— a lightweight, targeted alternative that logs just the required stores and fluxes (see Recording specific stores and fluxes).
Both a static glacier (the default glacier_infinite_storage=True) and an
evolving glacier (delta-h or area-scaling action) work in a calibration loop: the
glacier extent and ice volume are automatically reset to their initial state at the
start of every run, so the model re-runs cleanly across the many iterations.
Three ways to use the mass balance
Discharge stays the primary signal (the discharge argument of
SpotpySetup); auxiliary signals such as
glacier mass balance are passed as a list to extra_observations. Each auxiliary
signal carries its own metric, weight, mode and tolerance (set on the
object), and the setup-level combine argument selects how the objective terms are
aggregated:
mode='objective'withcombine='weighted'— a single score combining the discharge and mass-balance goodness of fit,discharge_weight * f(Q) + weight * f(MB). Works with every SPOTPY algorithm (including SCE-UA).mode='objective'withcombine='pareto'— returns a[discharge, mass balance]objective vector for a multi-objective sampler (SPOTPY’sNSGAII), yielding a Pareto front rather than a single best run.
Note
Comparable ranges. Mixing a discharge KGE/NSE (≤ 1, where 1 is perfect) with an
auxiliary RMSE (in the signal’s units, where 0 is perfect) in a weighted sum would let
the largest-magnitude term dominate and make the weights meaningless. By default
(normalize=True, for combine='weighted') each term is therefore combined as a
benchmark skill score: an error metric E becomes 1 - E / E_ref where
E_ref is the error of predicting the mean/climatology of that signal’s
observations (for RMSE, an NSE-like score), while skill metrics (NSE, KGE, …) are
used as they are. Every term then shares the same scale — 1 = perfect, 0 = no better
than the benchmark — so discharge_weight and each signal’s weight are
directly comparable. Pass normalize=False to combine the raw oriented metrics
instead. combine='pareto' is unaffected (each objective is optimized on its own).
mode='constraint'— a behavioural pass/fail filter: runs whose mean absolute mass-balance error exceeds a tolerance are rejected, while the discharge metric remains the objective. The tolerance is either absolute, in the signal’s units, viatolerance(mm w.e.), or relative viarelative_tolerance(a fraction of the mean absolute observed value, e.g.0.1for 10 %). Set exactly one of the two.
# Weighted multi-objective (discharge KGE + mass-balance NSE)
glacier_mb.metric, glacier_mb.weight, glacier_mb.mode = 'nse', 1.0, 'objective'
spot_setup = trainer.SpotpySetup(
socont, parameters, forcing, discharge,
warmup=365,
obj_func='kge_2012',
extra_observations=[glacier_mb],
combine='weighted',
discharge_weight=1.0,
)
sampler = trainer.calibrate(spot_setup, 'sceua', 5000, dbformat='ram')
# Pareto front (NSGAII needs n_obj via sample_kwargs)
spot_setup = trainer.SpotpySetup(
socont, parameters, forcing, discharge, warmup=365,
obj_func='kge_2012',
extra_observations=[glacier_mb], combine='pareto',
)
sampler = trainer.calibrate(
spot_setup, 'NSGAII', 5000, dbformat='ram',
sample_kwargs={'n_obj': 2, 'n_pop': 50},
)
# Behavioural filter (reject runs whose mass balance is off by > 500 mm w.e.)
glacier_mb_constraint = hb.GlacierMassBalanceObservations.from_glamos(
'/path/to/massbalance_fixdate.csv', kind='whole', glacier_id='B43-03',
mode='constraint', tolerance=500.0, # or relative_tolerance=0.1
)
spot_setup = trainer.SpotpySetup(
socont, parameters, forcing, discharge, warmup=365,
obj_func='kge_2012',
extra_observations=[glacier_mb_constraint],
)
The simulated mass balance can also be computed on its own (for plotting or evaluation)
with the observation’s simulated method after a run:
socont.run(parameters=parameters, forcing=forcing)
sim_mb = glacier_mb.simulated(socont)
A complete example is available in calibrate_with_glacier_mass_balance.py.
Calibrating on snow cover
Observed snow cover fraction from remote sensing (e.g. MODIS) is another independent constraint, particularly on the
snow accumulation and melt parameters. It is used alongside discharge with
SnowCoverObservations,
another auxiliary observation class in the hydrobricks.evaluation subpackage,
scored by RMSE by default. As with glacier mass balance, everything happens on the Python
side; the simulation itself is unchanged (there is no data assimilation).
From SWE to a snow-cover fraction
The model stores only the snow water equivalent (SWE) per hydro unit, with no explicit snow-cover-fraction state. The fraction of a hydro unit that is snow-covered is therefore derived from the recorded SWE at evaluation time, through a simple linear depletion curve:
i.e. the covered fraction grows linearly with SWE until it reaches a full-cover threshold
swe_full [mm w.e.] (a fixed, configurable argument of the observation; default
100). When a hydro unit carries several land covers (open ground, glacier, …), each
with its own snowpack, the unit-level fraction is the land-cover-fraction-weighted mean of
the per-cover fractions. Pass land_covers=['open'] to restrict the signal to specific
land covers (e.g. to ignore snow on glaciers).
Loading the observations
The observations are per (hydro unit, date) fractions in [0, 1]. A remote-sensing
raster stack is aggregated per hydro unit (reusing the raster of hydro-unit ids, as for the
gridded forcing); cloud/nodata pixels are ignored, value_scale rescales e.g. a 0–100 %
product to a fraction, and valid_min / valid_max drop out-of-range quality/error
codes (many snow products encode clouds, water or no-decision as values above 100). Four
loaders are available, all sharing the aggregation and filtering options:
from_modis reads the MODIS daily snow products (MOD10A1 / MYD10A1), distributed as
HDF-EOS tiles in the sinusoidal projection, one date per file. It parses the date from the
file name, mosaics the tiles of a date, reprojects to the hydro-unit raster’s CRS, and
aggregates. The HDF-EOS georeferencing is read from the file’s StructMetadata and the
files are read with xarray’s netcdf4 engine, so no special HDF4/GDAL build is
needed:
snow_cover = hb.SnowCoverObservations.from_modis(
'/path/to/MOD10A1_tiles/', # a folder of tiles (or a single file)
raster_hydro_units='/path/to/unit_ids.tif',
hydro_units=catchment.hydro_units,
file_pattern='MOD10A1*.hdf',
variable='NDSI_Snow_Cover',
value_scale=0.01, # 0-100 % -> 0-1
valid_min=0, valid_max=100, # drop quality/error codes (> 100)
min_valid_ratio=0.5, # drop a unit/date with too many cloudy pixels
swe_full=100.0,
cache_dir='/path/to/cache', # optional: cache the aggregated result
)
Aggregating a multi-year archive of daily tiles is slow (it reprojects and reduces one
tile per date), so pass cache_dir to store the per-(unit, date) result as a CSV
named snow_cover_<hash>.csv. The hash is built from the hydro-unit id raster (the
discretization), the aggregation options and a signature of the input tiles, so a later
run with the same inputs loads the CSV directly — while different discretizations or
options never collide. The cache uses the same long format as from_csv. The same
cache_dir option is available on from_netcdf and from_hdf5 (whose spatial
reprojection and per-unit extraction are likewise worth caching).
For data already stacked into a NetCDF (or HDF5) file with a time dimension, use
from_netcdf (or from_hdf5, which needs the h5netcdf package):
snow_cover = hb.SnowCoverObservations.from_netcdf(
'/path/to/snow_cover.nc',
raster_hydro_units='/path/to/unit_ids.tif',
hydro_units=catchment.hydro_units,
var_name='NDSI_Snow_Cover',
value_scale=0.01, nodata=255, valid_max=100, swe_full=100.0,
)
Pre-aggregated per-unit fractions (prepared externally) load from a long-format CSV with
from_csv by mapping the date, unit-id and value columns:
snow_cover = hb.SnowCoverObservations.from_csv(
'/path/to/snow_cover.csv',
date_col='date', unit_col='unit_id', value_col='scf',
value_scale=1.0, swe_full=100.0,
)
Note
The snowpack snow-content series (and the land-cover fractions) must be recorded so they
can be read from memory each iteration. As for glacier mass balance, either create the
model with record_all=True, or record only what the signal needs by calling
snow_cover.configure_recording(model) before model.setup() (see
Recording specific stores and fluxes).
Using the snow cover
Snow cover is combined with discharge exactly like glacier mass balance — the same
mode ('objective' / 'constraint'), weight, tolerance /
relative_tolerance and setup-level combine apply (see
Calibrating on glacier mass balance). The observation’s units here are a fraction in
[0, 1], so a constraint tolerance is also a fraction:
spot_setup = trainer.SpotpySetup(
socont, parameters, forcing, discharge,
warmup=365,
obj_func='kge_2012',
extra_observations=[snow_cover], # metric defaults to 'rmse'
combine='weighted',
)
sampler = trainer.calibrate(spot_setup, 'sceua', 5000, dbformat='ram')
The simulated snow cover can also be computed on its own (for plotting or evaluation) with
the observation’s simulated method after a run:
socont.run(parameters=parameters, forcing=forcing)
sim_scf = snow_cover.simulated(socont)
A complete example is available in calibrate_with_snow_cover.py.
Prior distributions
The default prior distribution is uniform over the parameter range defined by min
and max. A non-uniform prior can be assigned when prior knowledge — from the
literature or previous calibrations — justifies concentrating the search:
parameters.set_prior('a_snow', spotpy.parameter.Normal(mean=4, stddev=2))
Prior distributions provided by SPOTPY: Uniform, Normal, logNormal,
Chisquare, Exponential, Gamma, Wald, Weibull.