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 has [B] stores and a [B,7] routing buffer. Its per-basin
flat layout has 12 values, [SP, LW, SM, SUZ, SLZ, b0..b6]; time-stacked state
and flux leaves are [B,T]:
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. A fixed
length-7 masked kernel (ROUTING_BUFFER_SIZE = 7, the declared maxbas upper
bound) preserves the routing semantics and batched tensor shape:
- Bin
iintegrates the triangle density over[i, min(i+1, maxbas)]. Bins withi >= maxbascollapse to zero — that is the mask. Tensor selection and clamp operations let Torch autograd differentiate the kernel with respect tomaxbas. - 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 torch
from hydrologeez.models.hbv import HBVForcing, HBVModel
dtype = torch.float64
model = HBVModel(
tt=torch.tensor(0.0, dtype=dtype),
cfmax=torch.tensor(3.5, dtype=dtype),
sfcf=torch.tensor(1.0, dtype=dtype),
cwh=torch.tensor(0.1, dtype=dtype),
cfr=torch.tensor(0.05, dtype=dtype),
fc=torch.tensor(250.0, dtype=dtype),
lp=torch.tensor(0.7, dtype=dtype),
beta=torch.tensor(2.0, dtype=dtype),
k0=torch.tensor(0.3, dtype=dtype),
k1=torch.tensor(0.1, dtype=dtype),
k2=torch.tensor(0.05, dtype=dtype),
perc=torch.tensor(2.0, dtype=dtype),
uzl=torch.tensor(20.0, dtype=dtype),
maxbas=torch.tensor(3.0, dtype=dtype),
)
forcing = HBVForcing(
precip=torch.tensor([[0.0, 4.0, 8.0, 2.0]], dtype=dtype),
pet=torch.tensor([[1.0, 1.1, 0.9, 1.0]], dtype=dtype),
temp=torch.tensor([[-2.0, 1.0, 3.0, 0.0]], dtype=dtype),
)
streamflow = model.run(forcing)
streamflow, fluxes, final_state = model.run(forcing, return_fluxes=True)
Use float64 on CPU for references and reproduction, and float32 on an explicitly selected accelerator for training. Model execution preserves caller dtype and device and does not mutate Torch global defaults.
API reference¶
hydrologeez.models.hbv.model.HBVModel
¶
Bases: StateSpaceModel
Single-zone, 14-parameter daily HBV-Light model.
init_state(parameters, *, batch_size)
¶
Initialize soil moisture to half of FC and every other store to zero.
run_components(forcing, parameters, n_components, *, warmup=None, warmup_parameters=None)
¶
Run HBV components with average-before-routing shared routing.
Component parameters may have shape [], [B], [B,T], or
[B,N,T]. Snow, soil, and response stores have shape [B,N];
their groundwater runoff is averaged over N before routing. Routing
is applied exactly once per basin with the component-mean routing
parameters and one shared routing buffer of shape [B,7], never one
routing buffer per component.
hydrologeez.metrics
¶
Torch-native differentiable hydrological performance metrics.
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).