HBV-Light (single zone)¶
HBV-Light is a 14-parameter daily lumped snow / soil-moisture / groundwater rainfall-runoff model. hydrologeez implements the single-zone (lumped) variant as a differentiable state-space model. Multi-zone (elevation-band) HBV is out of scope.
Parameters (14, canonical order, code bounds)¶
| # | Param | Meaning | Bounds |
|---|---|---|---|
| 0 | tt |
rain/snow temperature threshold [°C] | [-2.5, 2.5] |
| 1 | cfmax |
degree-day melt factor [mm/°C/d] | [0.5, 10.0] |
| 2 | sfcf |
snowfall correction factor [–] | [0.4, 1.4] |
| 3 | cwh |
snow water-holding capacity [–] | [0.0, 0.2] |
| 4 | cfr |
refreezing coefficient [–] | [0.0, 0.2] |
| 5 | fc |
field capacity [mm] | [50.0, 700.0] |
| 6 | lp |
ET limit (fraction of FC) [–] | [0.3, 1.0] |
| 7 | beta |
recharge shape coefficient [–] | [1.0, 6.0] |
| 8 | k0 |
surface/quick recession [1/d] | [0.05, 0.99] |
| 9 | k1 |
interflow recession [1/d] | [0.01, 0.5] |
| 10 | k2 |
baseflow recession [1/d] | [0.001, 0.2] |
| 11 | perc |
max percolation [mm/d] | [0.0, 6.0] |
| 12 | uzl |
upper-zone Q0 threshold [mm] | [0.0, 100.0] |
| 13 | maxbas |
routing/UH base length [days] | [1.0, 7.0] |
Only maxbas is hard-validated (maxbas ∈ [1, 7], tied to the fixed length-7
routing buffer). The other 13 bounds are advisory — used for calibration
only; the model runs them silently out of range.
State and initialisation¶
Single-zone state is 12 values, flat layout
[SP, LW, SM, SUZ, SLZ, b0..b6]:
SPsnow pack [mm],LWliquid water held in snow (a.k.a. WC) [mm],SMsoil moisture [mm].SUZupper groundwater zone [mm],SLZlower groundwater zone [mm].routing_buffer— the length-7 triangular-UH convolution delay line.
Initial state: SM = 0.5 * fc (the only non-zero, parameter-dependent init);
SP = LW = SUZ = SLZ = 0; routing buffer zeroed.
Transition (per step)¶
For the lumped, no-elevation model the forcing (precip, temp, pet) passes
through unchanged. Each step reads the start-of-step stores and updates them in
this order — all branches within a phase read the same start-of-step store
(explicit / simultaneous update, not sequential depletion):
- Snow.
- Partition:
temp > tt(strict) → all rain(precip, 0); else → all snow(0, sfcf*precip).sfcfscales snowfall only. - Melt: if
temp > tt,melt = min(cfmax*(temp - tt), SP), else0. - Refreeze: if
temp < tt,refreeze = min(cfr*cfmax*(tt - temp), LW), else0. (Attemp == tt: snowfall, zero melt, zero refreeze.) - Update:
SP' = SP + p_snow - melt + refreeze;WC' = LW + melt - refreeze;lw_max = cwh*SP'(pre-floorSP');outflow = max(WC' - lw_max, 0)(andWC'is capped atlw_max); floorSP', WC'at 0. Thensnow_input = p_rain + outflow. - Soil. All read the same start-of-step
SM. - Recharge:
recharge = snow_input * clip(SM/fc, 0, 1)^beta;0iffc <= 0orsnow_input <= 0. - Actual ET:
lp_threshold = lp*fc;ET = petifSM >= lp_thresholdelsepet*SM/lp_threshold; cappedET = min(ET, max(SM, 0));0iffc <= 0orlp <= 0. - Update:
SM_raw = SM + (snow_input - recharge) - ET;overflow = max(SM_raw - fc, 0)is routed to upper-zone recharge (not discarded);SM' = clip(SM_raw, 0, fc). The reportedrechargeflux is the total soil->upper-zone fluxrecharge + overflow. - Response.
q0,q1,percall read the same start-of-stepSUZ;q2reads start-of-stepSLZ. q0 = k0 * max(SUZ - uzl, 0);q1 = k1 * SUZ.perc = min(perc_max, max(SUZ, 0)).SUZ' = max(SUZ + recharge_total - q0 - q1 - perc, 0)(recharge_total = base recharge + above-FC overflow).q2 = k2 * SLZ;SLZ' = max(SLZ + perc - q2, 0).qgw = q0 + q1 + q2(percolation is an internal SUZ→SLZ transfer, not inqgw).- Routing.
streamflow = convolve(routing_buffer, maxbas_weights, qgw)(see below).
MAXBAS routing — the masked-kernel crux¶
The routing is a triangular unit hydrograph of base length maxbas. Its length is
a function of a continuous parameter, which would be a non-static array shape under
jit/vmap. As with the GR6J UH, hydrologeez resolves this with a fixed
length-7 masked kernel (ROUTING_BUFFER_SIZE = 7, the declared maxbas upper
bound):
- Bin
iintegrates the triangle density over[i, min(i+1, maxbas)]. Bins withi >= maxbascollapse to zero — that is the mask.jnp.where/clamp idioms replace branchingif/continuelogic so the kernel is a smooth function ofmaxbasandjax.gradw.r.t.maxbasis finite. - Normalize-by-sum is load-bearing: the raw per-bin weights integrate to 0.5,
not 1.0, so the kernel is explicitly divided by its sum (
w / sum(w)whensum > 0). Skipping this halves the routed flow. - Ordinate values are continuous across integer
maxbas, but the gradient has a kink at integermaxbas(a new bin activates exactly there, since the active-bin count isceil(maxbas)). The kernel is value-continuous, not gradient-continuous — the same treatment GR6J gives integerx4.
The convolution is a read-after-shift length-7 delay line: the buffer is
shifted one slot, weights * qgw is injected, then the new head is read (the
same-day ordinate-1 term). There is no forced one-step lag — the routed pulse
turns on the same step qgw does — matching the published HBV-Light routing
(Seibert & Vis 2012; Seibert 2005 manual Eq. 6). It shares
hydrologeez.convolution.convolve_delay_line with GR6J.
Fluxes (20 outputs, in order)¶
precip, temp, pet, precip_rain, precip_snow, snow_pack, snow_melt,
liquid_water_in_snow, snow_input, soil_moisture, recharge, actual_et,
upper_zone, lower_zone, q0, q1, q2, percolation, qgw, streamflow
precip/temp/pet echo the forcing; snow_pack, liquid_water_in_snow,
soil_moisture, upper_zone, lower_zone are post-update stores; the rest are
within-step fluxes; qgw = q0 + q1 + q2; recharge is the total soil->upper-zone
flux (base recharge + above-FC overflow); streamflow is the routed qgw
(same-day read-after-shift).
Corrected version-fidelity note + retained deviations¶
The soil routine is corrected to the published HBV-Light: above-field-capacity
soil moisture is routed to upper-zone recharge (mass-conserving; Seibert & Vis
2012), not discarded, so the reported recharge flux is the total soil->upper-zone
flux (base recharge + FC overflow).
These behaviours are retained by decision and reproduced verbatim:
- Explicit-split over-draw.
q0,q1,percall draw from the same start-of-stepSUZ; the store is clamped atmax(0)after subtracting all of them, but the already-emittedq0/q1are not reduced, so mass can be created on over-draw. The lower zone behaves the same forq2. Standard explicit-HBV (forward-Euler) behaviour, ported as-is. - Only
maxbasis range-validated. The other 13 parameters run silently out of range.
Usage¶
import os
os.environ["JAX_ENABLE_X64"] = "1"
import jax.numpy as jnp # noqa: E402
from hydrologeez.models.hbv import HBVModel, HBVForcing # noqa: E402
model = HBVModel(
tt=jnp.asarray(0.0), cfmax=jnp.asarray(3.5), sfcf=jnp.asarray(1.0),
cwh=jnp.asarray(0.1), cfr=jnp.asarray(0.05), fc=jnp.asarray(250.0),
lp=jnp.asarray(0.7), beta=jnp.asarray(2.0), k0=jnp.asarray(0.3),
k1=jnp.asarray(0.1), k2=jnp.asarray(0.05), perc=jnp.asarray(2.0),
uzl=jnp.asarray(20.0), maxbas=jnp.asarray(3.0),
)
forcing = HBVForcing(
precip=jnp.asarray(precip_series),
pet=jnp.asarray(pet_series),
temp=jnp.asarray(temp_series),
)
streamflow = model.run(forcing)
obs, fluxes, final = model.run(forcing, return_fluxes=True)
float64 is required and enforced at import — set JAX_ENABLE_X64=1 before
importing jax or hydrologeez (the package raises rather than silently flipping it).
API reference¶
hydrologeez.models.hbv.model.HBVModel
¶
Bases: StateSpaceModel
Single-zone (lumped) HBV-Light, 14-parameter daily model, as an Equinox module.
Parameters are the 14 fields tt..maxbas (canonical order). n_zones and
routing_buffer_size are STATIC structural integers (never calibrated).
init_state()
¶
Rust State::initialize (state.rs:28-40): SM=0.5*fc, all else zero.
hydrologeez.metrics
¶
JAX-native differentiable hydrological performance metrics.
All metrics are pure free functions on (obs, sim) JAX arrays and are written
to be jax.grad-clean: the logarithmic transform is guarded with a small
additive constant so log and its derivative stay finite at zero flow. float64
is required process-wide and is enforced at import hydrologeez; the test suite
enables it via tests/conftest.py before any jax import.
Signature convention: every metric takes (obs, sim) in that order. Gradient
calibration differentiates w.r.t. sim (argnums=1).
nse(obs, sim)
¶
Nash-Sutcliffe Efficiency: 1 - SS_res / SS_tot.
rmse(obs, sim)
¶
Root Mean Squared Error.
mae(obs, sim)
¶
Mean Absolute Error.
pbias(obs, sim)
¶
Percent bias (hydroGOF convention): 100 * sum(sim - obs) / sum(obs).
lognse(obs, sim, eps=LOG_EPS)
¶
Nash-Sutcliffe Efficiency on log-transformed flows.
Uses log(x + eps) so the transform and its gradient remain finite at zero
flow. eps defaults to :data:LOG_EPS.
kge(obs, sim)
¶
Kling-Gupta Efficiency (Gupta et al., 2009).
KGE = 1 - sqrt((r - 1)**2 + (alpha - 1)**2 + (beta - 1)**2) where r is
the Pearson correlation, alpha = std(sim) / std(obs) is the variability
ratio and beta = mean(sim) / mean(obs) is the bias ratio (population
statistics, ddof=0).