from __future__ import annotations
import logging
from abc import abstractmethod
from typing import Any
from hydrobricks._exceptions import ConfigurationError, ModelError
from hydrobricks.models.model import Model
from hydrobricks.modules.glacier import GlacierModule
logger = logging.getLogger(__name__)
[docs]
class HBV(Model):
"""Base class for the HBV model family (Bergström, 1976, and successors).
This class holds the structure elements shared by the HBV versions: the snow
routine (degree-day melt with liquid water retention and refreezing), the soil
moisture routine (beta-function recharge, LP-limited evapotranspiration) and
the transformation function (triangular unit hydrograph, MAXBAS). The response
routine differs between versions and must be provided by the subclasses through
``_define_response_structure()`` (e.g. the non-linear upper zone of HBV-96).
Precipitation undercatch can be corrected with the rainfall and snowfall
correction factors ``rfcf`` and ``sfcf`` (both default 1.0), applied to the
rain and snow components at the snow/rain splitter.
The model is integrated by the ODE solver (as Socont), so the results are a
continuous approximation of the original discrete HBV formulations.
Options
-------
snow_melt_process : str
Snowmelt method (default: 'melt:degree_day'). Must be 'melt:degree_day'
when snow refreezing is enabled.
snow_water_retention_process : str or None
Outflow process of the snowpack liquid water storage (default:
'outflow:snow_holding', the HBV holding capacity CWH). None disables the
liquid water retention (melt water leaves the snowpack directly).
snow_refreezing_process : str or None
Refreezing process of the retained liquid water (default:
'refreeze:degree_day', the HBV refreezing coefficient CFR). None disables
refreezing. Requires a snow water retention process.
rain_to_snowpack : bool
Route the rain to the snowpack liquid water storage instead of the ground
(default: True, as in the original HBV snow routine). The rain is retained
in the snowpack (up to the holding capacity CWH) and exposed to
refreezing; without snow, it reaches the ground within the same time
step. Requires a snow water retention process.
snow_rain_process : str or None
Rain/snow partitioning method (default: None, i.e. 'snow_rain:linear',
which matches the HBV-96 linear transition over TT ± TTI/2).
snow_redistribution : str or None
Optional snow redistribution process (e.g. 'transport:snow_slide').
share_soil : bool
Share a single soil moisture storage across all land covers (default:
False, i.e. each land cover has its own soil moisture store, as in the
original HBV land-use formulation). With several land covers and per-class
soils, the soil/recharge parameters (fc, lp, beta) become cover-specific
and are exposed with a per-cover suffix (e.g. ``fc_forest``); with a single
land cover (or when sharing) the bare aliases (``fc``, ``lp``, ``beta``)
are kept.
glacier_infinite_storage : bool
Treat the glacier ice as an infinite storage (default: True), as in Socont.
glacier_module : str
Glacier formulation to plug in (default: 'gsm', the Glacier Sub-Model of
GSM-SOCONT: two linear reservoirs for the glacierized-area rain + snowmelt
and ice melt).
Land-use classes
----------------
Besides the default soil-bearing ``ground`` cover, HBV supports the HBV land-use
classes as land covers: ``forest`` (canopy interception on the rain path),
``lake`` (exclusive open-water cover: all precipitation direct, open-water
evaporation, linear outflow — its own no-snow structure variant) and ``glacier``
(Socont-style: glacier-area rain + snowmelt and ice melt feed two linear
sub-basin reservoirs draining to the outlet, with a glacier-free base variant).
"""
@abstractmethod
def __init__(self, name: str = "hbv", **kwargs: Any) -> None:
super().__init__(name=name, **kwargs)
# Default options
self.options["snow_melt_process"] = "melt:degree_day"
self.options["snow_water_retention_process"] = "outflow:snow_holding"
self.options["snow_refreezing_process"] = "refreeze:degree_day"
self.options["rain_to_snowpack"] = True
self.options["snow_rain_process"] = None
self.options["snow_redistribution"] = None
self.options["share_soil"] = False
self.options["glacier_infinite_storage"] = True
self.options["glacier_module"] = "gsm"
self.allowed_land_cover_types = ["open", "forest", "lake", "glacier"]
self._set_options(kwargs)
try:
self._define_structure()
self._generate_structure()
self._define_parameter_aliases()
self._define_parameter_constraints()
self._define_parameter_transforms()
except RuntimeError as err:
raise ModelError(
f"{type(self).__name__} model initialization raised "
f"an exception: {err}"
)
def _define_structure(self) -> None:
"""Define the structure elements shared by the HBV versions.
- Land covers: each splits the incoming water (rain + snowpack outflow)
between its soil moisture storage and the response routine using the HBV
beta function (recharge = in × (SM/FC)^beta).
- Response routine: defined by the subclass (``_define_response_structure``).
Its bricks must route their outflows to the 'routing' brick.
- Soil moisture storage (capacity FC): evapotranspiration limited by LP;
overflow safety to the routing brick. One per land cover (original HBV
land-use formulation), or a single shared store when ``share_soil`` is
set.
- Routing: triangular unit hydrograph (MAXBAS) to the outlet.
The brick declaration order matters: the solver applies the bricks in
order, so every brick-to-brick flux must flow towards a later brick to be
received within the same iteration (hence the soil moisture stores are
declared after the response routine, which may feed them through a
capillary flux, and the routing is declared last).
Lakes are an exclusive open-water cover with no soil/snow routine: they are
excluded from the soil routine here and handled as a separate structure
variant (see ``_define_structure_variants``). Glaciers (Socont-style) drain
to catchment-level linear reservoirs, bypassing the soil routine, and add a
with-glacier structure variant on top of a glacier-free base. The lake and
glacier bricks are still added to this (superset) structure so their
parameters are generated; the variants select the relevant subsets.
"""
# Separate the cover categories: lakes (exclusive open water), glaciers
# (Socont-style, drain to sub-basin reservoirs) and the soil-bearing covers
# (ground, forest, ...) that go through the soil/response/routing routine.
self._lake_cover_names = [
name
for name, cover_type in zip(self.land_cover_names, self.land_cover_types)
if cover_type == "lake"
]
self._glacier_cover_names = [
name
for name, cover_type in zip(self.land_cover_names, self.land_cover_types)
if cover_type == "glacier"
]
soil_cover_names = [
name
for name, cover_type in zip(self.land_cover_names, self.land_cover_types)
if cover_type not in ("lake", "glacier")
]
if not soil_cover_names:
raise ConfigurationError(
"The HBV model requires at least one soil-bearing land cover "
"(ground or forest).",
item_name="land_cover_types",
reason="Only lake/glacier covers provided",
)
# Soil naming: a single shared store when requested or with one soil cover,
# otherwise one soil moisture store per soil cover.
self._shared_soil = (
bool(self.options.get("share_soil")) or len(soil_cover_names) == 1
)
if self._shared_soil:
self._soil_names = {name: "soil_moisture" for name in soil_cover_names}
else:
self._soil_names = {
name: f"{name}_soil_moisture" for name in soil_cover_names
}
multi_cover = len(soil_cover_names) > 1
# Beta-function split of rain and snowpack outflow per soil cover. The
# recharge (outflow:rest) is the complement of the infiltration and must be
# declared after it.
for cover_name in soil_cover_names:
brick = {
"attach_to": "hydro_unit",
"kind": "land_cover",
"processes": {
"infiltration": {
"kind": "infiltration:hbv",
"target": self._soil_names[cover_name],
},
"recharge": {"kind": "outflow:rest", "target": "upper_zone"},
},
}
if multi_cover:
brick["alias_suffix"] = f"_{cover_name}"
self.structure[cover_name] = brick
# Response routine (version-specific)
self._define_response_structure()
# Soil moisture storage(s) (capacity FC). The overflow is a numerical safety
# only (the infiltration and the capillary flux both vanish at FC).
for cover_name, soil_name in self._soil_names.items():
if soil_name in self.structure:
continue # shared store already declared
soil = {
"attach_to": "hydro_unit",
"kind": "storage",
"parameters": {"capacity": 250},
"processes": {
"et": {"kind": "et:hbv"},
"overflow": {"kind": "overflow", "target": "routing"},
},
}
if not self._shared_soil and multi_cover:
soil["alias_suffix"] = f"_{cover_name}"
self.structure[soil_name] = soil
# Transformation function (triangular unit hydrograph)
self.structure["routing"] = {
"attach_to": "hydro_unit",
"kind": "storage",
"processes": {
"routing": {"kind": "routing:hbv", "target": "outlet"},
},
}
# Glacier covers (Socont-style by default): delegated to the pluggable glacier
# module. The glacierized area sends its rain + snowmelt and its ice melt to
# catchment-level reservoirs draining to the outlet, bypassing the soil routine.
# The snow on the glacier still melts through its (generated) snowpack; ice melt
# is suppressed while snow covers it.
self._glacier_module = GlacierModule.get_module(self.options["glacier_module"])
self._glacier_module.add_bricks(
self.structure,
self._glacier_cover_names,
melt_process=self.options["snow_melt_process"],
options=self.options,
)
# Lake covers: an exclusive open-water cover with no soil/snow. All precip
# enters a lake storage directly; it evaporates at the potential rate and drains
# through a linear outflow to the outlet. The lake land cover is a pass-through
# (instantaneous direct outflow) into the storage, mirroring the Socont glacier
# land-cover → sub-basin-storage pattern. These bricks live in the lake
# structure variant only (see ``_define_structure_variants``).
for lake_name in self._lake_cover_names:
self.structure[lake_name] = {
"attach_to": "hydro_unit",
"kind": "land_cover",
"processes": {
"outflow": {
"kind": "outflow:direct",
"target": f"{lake_name}_storage",
"instantaneous": True,
},
},
}
self.structure[f"{lake_name}_storage"] = {
"attach_to": "hydro_unit",
"kind": "storage",
"processes": {
"et": {"kind": "et:open_water"},
"outflow": {"kind": "outflow:linear", "target": "outlet"},
},
}
def _define_structure_variants(
self,
) -> list[tuple]:
"""Build the structure variants for the land-use classes.
The primary (structure 1) is the glacier- and lake-free **base**: the soil
covers (ground, forest) with the soil/response/routing routine, plus the
catchment-level glacier reservoirs (kept here so the sub-basin, built from
structure 1, owns them and they are shared by all units). Optional variants:
- a **with-glacier** variant (when a glacier cover is present) adding the
glacier land covers on top of the base, used by glacierized units while
glacier-free units use the base (as in Socont);
- a **lake** variant (when a lake cover is present): only the lake storage,
with no snow (all precipitation enters the open water directly).
Units are auto-assigned (in the C++ core) to the variant matching their
present covers. With neither glacier nor lake there is a single variant. The
glacier split (and the glacier formulation) is the shared, pluggable glacier
module; the lake variant is HBV-specific.
"""
lake_brick_keys = set(self._lake_cover_names) | {
f"{name}_storage" for name in self._lake_cover_names
}
# The non-lake covers/bricks (soil covers, plus the glacier covers and the
# glacier reservoirs the module added). The shared glacier helper splits this
# into a glacier-free base and a with-glacier variant.
non_lake_names = [
name
for name, cover_type in zip(self.land_cover_names, self.land_cover_types)
if cover_type != "lake"
]
non_lake_types = [
cover_type for cover_type in self.land_cover_types if cover_type != "lake"
]
non_lake_structure = {
key: brick
for key, brick in self.structure.items()
if key not in lake_brick_keys
}
variants: list[tuple] = self._split_glacier_variants(
non_lake_names, non_lake_types, non_lake_structure
)
# Lake variant: only the lake bricks, with no snow (all precipitation enters
# the open water directly).
if self._lake_cover_names:
lake_names = list(self._lake_cover_names)
lake_types = ["lake"] * len(lake_names)
lake_structure = {
key: brick
for key, brick in self.structure.items()
if key in lake_brick_keys
}
lake_options = {
"with_snow": False,
"snow_melt_process": None,
"snow_water_retention_process": None,
"snow_refreezing_process": None,
"rain_to_snowpack": False,
}
variants.append((lake_names, lake_types, lake_structure, lake_options))
return variants
@abstractmethod
def _define_response_structure(self) -> None:
"""Define the version-specific response routine bricks.
The bricks must receive the recharge from the 'upper_zone' target and
route their outflows to the 'routing' brick.
"""
raise ConfigurationError(
f"The response routine has to be defined by the child class "
f"(named {self.name}).",
reason="Abstract method not implemented",
)
def _define_parameter_aliases(self) -> None:
"""Define common HBV parameter aliases (literature names).
- fc: Soil moisture storage capacity (FC). With per-class soils, one alias
per cover (e.g. ``fc_forest``); shared/single soil keeps the bare ``fc``.
- cfmax: Snow melt degree-day factor (CFMAX).
- tt: Snow melt threshold temperature (TT).
The process parameter specs already provide lp, beta, maxbas, cfr (snow
refreezing coefficient) and cwh/whc (snowpack water holding capacity); with
per-class soils these likewise carry a per-cover suffix (lp_<cover>,
beta_<cover>).
"""
self.parameter_aliases = {
"type:snowpack:degree_day_factor": ["cfmax"],
"type:snowpack:melting_temperature": ["tt"],
}
if self._shared_soil:
self.parameter_aliases["soil_moisture:capacity"] = ["fc"]
else:
for cover_name, soil_name in self._soil_names.items():
self.parameter_aliases[f"{soil_name}:capacity"] = [f"fc_{cover_name}"]
# Lake (open water) linear outflow response factor.
single_lake = len(self._lake_cover_names) == 1
for lake_name in self._lake_cover_names:
alias = "k_lake" if single_lake else f"k_lake_{lake_name}"
self.parameter_aliases[f"{lake_name}_storage:response_factor"] = [alias]
# Glacier-area reservoir response factors, from the (pluggable) glacier module.
if self._glacier_module is not None:
self.parameter_aliases.update(
self._glacier_module.parameter_aliases(self._glacier_cover_names)
)
def _define_parameter_constraints(self) -> None:
"""Define parameter constraints (none required for HBV)."""
self.parameter_constraints = []
def _set_specific_options(self, kwargs: dict[str, Any]) -> None:
"""Set and validate HBV-specific configuration options."""
retention = self.options.get("snow_water_retention_process")
refreezing = self.options.get("snow_refreezing_process")
if self.options.get("rain_to_snowpack") and retention is None:
raise ConfigurationError(
"Routing the rain to the snowpacks requires a snow water "
"retention process.",
item_name="rain_to_snowpack",
item_value=True,
reason="Missing snow water retention process",
)
if refreezing is not None:
if retention is None:
raise ConfigurationError(
"Snow refreezing requires a snow water retention process.",
item_name="snow_refreezing_process",
item_value=refreezing,
reason="Missing snow water retention process",
)
if self.options.get("snow_melt_process") != "melt:degree_day":
raise ConfigurationError(
"The refreeze:degree_day process requires the melt:degree_day "
"snow melt process.",
item_name="snow_melt_process",
item_value=self.options.get("snow_melt_process"),
reason="Incompatible option",
)