Common Utilities and Configurations¶
This section covers common utilities, configurations, and shared components used across all hydrological models in hydromodel.
Model Configuration¶
Copyright (c) 2021-2022 Wenyu Ouyang. All rights reserved.
ParamRangeConfigError (ValueError)
¶
Raised when an explicit parameter range configuration is invalid.
Source code in hydromodel/models/model_config.py
class ParamRangeConfigError(ValueError):
"""Raised when an explicit parameter range configuration is invalid."""
attach_parameter_contract(result, model_setup)
¶
Add explicit parameter and loss metadata without removing legacy fields.
Source code in hydromodel/models/model_config.py
def attach_parameter_contract(result, model_setup):
"""Add explicit parameter and loss metadata without removing legacy fields."""
result = dict(result)
model_name = model_setup.model_name
best_params = copy.deepcopy(result.get("best_params"))
normalized = None
if "objective_value" in result:
result["objective_value"] = float(result["objective_value"])
if isinstance(best_params, dict):
model_params = best_params.get(model_name)
if isinstance(model_params, dict):
normalized = {
name: float(model_params[name])
for name in model_setup.parameter_names
if name in model_params
}
best_params[model_name] = normalized
result["parameter_format"] = "normalized"
result["best_params_normalized"] = (
{model_name: normalized} if normalized is not None else None
)
result["best_params_denormalized"] = (
{
model_name: denormalize_parameter_dict(
normalized, model_setup.model_param_config
)
}
if normalized is not None
else None
)
result["param_range_source"] = model_setup.param_range_source
result["param_range_source_path"] = model_setup.param_range_source_path
result["loss_config"] = serializable_loss_config(model_setup.loss_config)
return result
denormalize_parameter_dict(parameters, model_param_config)
¶
Convert a normalized parameter dict to physical values.
Source code in hydromodel/models/model_config.py
def denormalize_parameter_dict(parameters, model_param_config):
"""Convert a normalized parameter dict to physical values."""
param_names = model_param_config["param_name"]
param_ranges = model_param_config["param_range"]
denormalized = OrderedDict()
for name in param_names:
value = parameters[name]
lower, upper = param_ranges[name]
denormalized[name] = lower + float(value) * (upper - lower)
return denormalized
get_model_param_config(model_name, kwargs=None)
¶
Resolve model parameter metadata from explicit, legacy, or default kwargs.
Source code in hydromodel/models/model_config.py
def get_model_param_config(model_name, kwargs=None):
"""Resolve model parameter metadata from explicit, legacy, or default kwargs."""
kwargs = kwargs or {}
param_config = kwargs.get("param_config")
if isinstance(param_config, dict):
if model_name in param_config:
return validate_model_param_dict(
{model_name: param_config[model_name]}, model_name=model_name
)[model_name]
if "param_range" in param_config:
default_config = MODEL_PARAM_DICT.get(model_name)
if default_config is None:
raise ParamRangeConfigError(
f"No default parameter configuration for model '{model_name}'"
)
names = param_config.get(
"param_name", default_config["param_name"]
)
return validate_model_param_dict(
{
model_name: {
"param_name": names,
"param_range": param_config["param_range"],
}
},
model_name=model_name,
)[model_name]
explicit_range = kwargs.get("param_range")
if isinstance(explicit_range, dict):
default_config = MODEL_PARAM_DICT.get(model_name)
if default_config is None:
raise ParamRangeConfigError(
f"No default parameter configuration for model '{model_name}'"
)
names = kwargs.get("param_name", default_config["param_name"])
return validate_model_param_dict(
{model_name: {"param_name": names, "param_range": explicit_range}},
model_name=model_name,
)[model_name]
legacy_config = kwargs.get(model_name)
if isinstance(legacy_config, dict):
if "param_name" not in legacy_config:
default_config = MODEL_PARAM_DICT.get(model_name, {})
legacy_config = dict(
param_name=default_config.get("param_name", []),
**legacy_config,
)
return validate_model_param_dict(
{model_name: legacy_config}, model_name=model_name
)[model_name]
warnings.warn(
f"Parameter metadata for model '{model_name}' was not provided. "
"Falling back to MODEL_PARAM_DICT defaults.",
RuntimeWarning,
stacklevel=2,
)
return _copy_model_param_dict(MODEL_PARAM_DICT)[model_name]
inject_model_param_config(model_params, model_name, model_param_config)
¶
Inject parameter metadata using both explicit and legacy contracts.
Source code in hydromodel/models/model_config.py
def inject_model_param_config(model_params, model_name, model_param_config):
"""Inject parameter metadata using both explicit and legacy contracts."""
injected = dict(model_params or {})
injected[model_name] = model_param_config
injected["param_config"] = {model_name: model_param_config}
injected["param_name"] = model_param_config["param_name"]
injected["param_range"] = model_param_config["param_range"]
return injected
resolve_model_param_config(model_name, param_range_file=None, fallback_dir=None, strict=False)
¶
Resolve parameter ranges for a model with explicit source metadata.
Source code in hydromodel/models/model_config.py
def resolve_model_param_config(
model_name,
param_range_file=None,
fallback_dir=None,
strict=False,
):
"""Resolve parameter ranges for a model with explicit source metadata."""
source = "default"
resolved_file = None
if param_range_file is not None:
resolved_file = str(param_range_file)
source = "explicit"
elif fallback_dir is not None:
candidate = os.path.join(str(fallback_dir), "param_range.yaml")
if os.path.exists(candidate):
resolved_file = candidate
source = "artifact"
if resolved_file is None:
warnings.warn(
f"param_range_file not provided for model '{model_name}'. "
"Using default MODEL_PARAM_DICT ranges.",
RuntimeWarning,
stacklevel=2,
)
param_dict = read_model_param_dict(
resolved_file, strict=strict or resolved_file is not None
)
normalized = validate_model_param_dict(param_dict, model_name=model_name)
model_param_config = normalized[model_name]
return {
"param_dict": param_dict,
"model_param_config": model_param_config,
"source": source,
"source_path": resolved_file,
}
serializable_loss_config(loss_config)
¶
Return a JSON-safe copy of a resolved loss configuration.
Source code in hydromodel/models/model_config.py
def serializable_loss_config(loss_config):
"""Return a JSON-safe copy of a resolved loss configuration."""
serializable = {}
for key, value in loss_config.items():
if callable(value):
serializable[key] = getattr(value, "__name__", "callable")
else:
serializable[key] = value
return serializable
validate_model_param_dict(param_dict, model_name=None)
¶
Validate and normalize model parameter metadata.
The returned param_range order always follows param_name, which is required because normalized parameter vectors are positional.
Source code in hydromodel/models/model_config.py
def validate_model_param_dict(param_dict, model_name=None):
"""Validate and normalize model parameter metadata.
The returned param_range order always follows param_name, which is required
because normalized parameter vectors are positional.
"""
if not isinstance(param_dict, dict):
raise ParamRangeConfigError(
"Parameter range file must contain a mapping"
)
models = (
[model_name] if model_name is not None else list(param_dict.keys())
)
normalized = {}
for model in models:
if model not in param_dict:
raise ParamRangeConfigError(
f"Parameter range does not define model '{model}'"
)
contents = param_dict[model]
if not isinstance(contents, dict):
raise ParamRangeConfigError(
f"Parameter range for model '{model}' must be a mapping"
)
param_names = contents.get("param_name")
param_ranges = contents.get("param_range")
if not isinstance(param_names, list) or not param_names:
raise ParamRangeConfigError(
f"Model '{model}' must define a non-empty param_name list"
)
if not isinstance(param_ranges, dict):
raise ParamRangeConfigError(
f"Model '{model}' must define a param_range mapping"
)
missing = [name for name in param_names if name not in param_ranges]
extra = [name for name in param_ranges if name not in param_names]
if missing:
raise ParamRangeConfigError(
f"Model '{model}' is missing ranges for: {missing}"
)
if extra:
raise ParamRangeConfigError(
f"Model '{model}' has extra ranges not in param_name: {extra}"
)
normalized[model] = {
"param_name": list(param_names),
"param_range": OrderedDict(
(
name,
_validate_param_bounds(model, name, param_ranges[name]),
)
for name in param_names
),
}
return normalized
Parameter Utilities¶
Copyright (c) 2025 Wenyu Ouyang. All rights reserved.
detect_parameter_format(parameters, param_ranges)
¶
Detect whether parameters are normalized (0-1 range) or in original scale.
Parameters¶
parameters : np.ndarray Model parameters array [basin, parameter] param_ranges : Dict[str, List[float]] Parameter ranges dictionary with min/max values for each parameter
Returns¶
bool True if parameters appear to be normalized (0-1 range), False otherwise
Source code in hydromodel/models/param_utils.py
def detect_parameter_format(
parameters: np.ndarray, param_ranges: Dict[str, List[float]]
) -> bool:
"""
Detect whether parameters are normalized (0-1 range) or in original scale.
Parameters
----------
parameters : np.ndarray
Model parameters array [basin, parameter]
param_ranges : Dict[str, List[float]]
Parameter ranges dictionary with min/max values for each parameter
Returns
-------
bool
True if parameters appear to be normalized (0-1 range), False otherwise
"""
# Check if all parameters are within [0, 1] range
# Allow small tolerance for numerical precision
tolerance = 1e-6
# If any parameter is outside [0, 1] with tolerance, assume original scale
if np.any(parameters < -tolerance) or np.any(parameters > 1 + tolerance):
return False
# Additional check: if parameters are suspiciously close to range boundaries
# when interpreted as original values, they're likely normalized
param_values = list(param_ranges.values())
for i, param_range in enumerate(param_values):
if i >= parameters.shape[1]:
break
param_col = parameters[:, i]
min_val, max_val = param_range[0], param_range[1]
# If parameters are all very close to 0-1 range but the actual range
# is much larger, they're likely normalized
if max_val - min_val > 2 and np.all(param_col <= 1.1):
return True
return True # Default assumption: parameters are normalized
get_parameter_scales(param_ranges)
¶
Extract parameter scales from param_ranges for backward compatibility.
Parameters¶
param_ranges : Dict[str, List[float]] Parameter ranges dictionary
Returns¶
Dict[str, List[float]] Dictionary mapping parameter names to [min, max] ranges
Source code in hydromodel/models/param_utils.py
def get_parameter_scales(
param_ranges: Dict[str, List[float]],
) -> Dict[str, List[float]]:
"""
Extract parameter scales from param_ranges for backward compatibility.
Parameters
----------
param_ranges : Dict[str, List[float]]
Parameter ranges dictionary
Returns
-------
Dict[str, List[float]]
Dictionary mapping parameter names to [min, max] ranges
"""
return param_ranges.copy()
normalize_parameters(parameters, param_ranges)
¶
Convert parameters from original scale to normalized (0-1) scale.
Parameters¶
parameters : np.ndarray Model parameters in original scale [basin, parameter] param_ranges : Dict[str, List[float]] Parameter ranges dictionary
Returns¶
np.ndarray Parameters normalized to 0-1 scale
Source code in hydromodel/models/param_utils.py
def normalize_parameters(
parameters: np.ndarray, param_ranges: Dict[str, List[float]]
) -> np.ndarray:
"""
Convert parameters from original scale to normalized (0-1) scale.
Parameters
----------
parameters : np.ndarray
Model parameters in original scale [basin, parameter]
param_ranges : Dict[str, List[float]]
Parameter ranges dictionary
Returns
-------
np.ndarray
Parameters normalized to 0-1 scale
"""
normalized_params = np.zeros_like(parameters)
param_list = list(param_ranges.values())
for i, param_range in enumerate(param_list):
min_val, max_val = param_range[0], param_range[1]
normalized_params[:, i] = (parameters[:, i] - min_val) / (
max_val - min_val
)
return normalized_params
process_parameters(parameters, param_ranges, normalized='auto')
¶
Process model parameters to convert from normalized to original scale if needed.
This function provides a unified interface for parameter handling across all models, supporting both normalized (0-1 range) and original scale parameters.
Parameters¶
parameters : np.ndarray Model parameters array [basin, parameter] param_ranges : Dict[str, List[float]] Parameter ranges dictionary with min/max values for each parameter normalized : Union[bool, str], optional Parameter format specification: - "auto": Automatically detect parameter format (default) - True: Parameters are normalized (0-1 range), convert to original scale - False: Parameters are already in original scale, use as-is
Returns¶
np.ndarray Parameters in original scale, ready for model computation
Examples¶
param_ranges = {"K": [0.1, 1.0], "B": [0.1, 0.4]}
Normalized parameters¶
norm_params = np.array([[0.5, 0.8]]) # Will be converted to [0.55, 0.34] orig_params = process_parameters(norm_params, param_ranges, normalized=True)
Original scale parameters¶
orig_params = np.array([[0.55, 0.34]]) # Will be used as-is final_params = process_parameters(orig_params, param_ranges, normalized=False)
Source code in hydromodel/models/param_utils.py
def process_parameters(
parameters: np.ndarray,
param_ranges: Dict[str, List[float]],
normalized: Union[bool, str] = "auto",
) -> np.ndarray:
"""
Process model parameters to convert from normalized to original scale if needed.
This function provides a unified interface for parameter handling across all models,
supporting both normalized (0-1 range) and original scale parameters.
Parameters
----------
parameters : np.ndarray
Model parameters array [basin, parameter]
param_ranges : Dict[str, List[float]]
Parameter ranges dictionary with min/max values for each parameter
normalized : Union[bool, str], optional
Parameter format specification:
- "auto": Automatically detect parameter format (default)
- True: Parameters are normalized (0-1 range), convert to original scale
- False: Parameters are already in original scale, use as-is
Returns
-------
np.ndarray
Parameters in original scale, ready for model computation
Examples
--------
>>> param_ranges = {"K": [0.1, 1.0], "B": [0.1, 0.4]}
>>> # Normalized parameters
>>> norm_params = np.array([[0.5, 0.8]]) # Will be converted to [0.55, 0.34]
>>> orig_params = process_parameters(norm_params, param_ranges, normalized=True)
>>>
>>> # Original scale parameters
>>> orig_params = np.array([[0.55, 0.34]]) # Will be used as-is
>>> final_params = process_parameters(orig_params, param_ranges, normalized=False)
"""
if parameters.shape[1] != len(param_ranges):
raise ValueError(
f"Parameter array has {parameters.shape[1]} columns but "
f"param_ranges has {len(param_ranges)} parameters"
)
# Auto-detect parameter format if requested
if normalized == "auto":
normalized = detect_parameter_format(parameters, param_ranges)
# If parameters are already in original scale, return as-is
if not normalized:
return parameters.copy()
# Convert normalized parameters to original scale
converted_params = np.zeros_like(parameters)
param_list = list(param_ranges.values())
for i, param_range in enumerate(param_list):
min_val, max_val = param_range[0], param_range[1]
converted_params[:, i] = min_val + parameters[:, i] * (
max_val - min_val
)
return converted_params
validate_parameters(parameters, param_ranges, normalized=False)
¶
Validate that parameters are within acceptable ranges.
Parameters¶
parameters : np.ndarray Model parameters array [basin, parameter] param_ranges : Dict[str, List[float]] Parameter ranges dictionary normalized : bool, optional Whether parameters are normalized (default: False)
Returns¶
bool True if all parameters are within valid ranges
Source code in hydromodel/models/param_utils.py
def validate_parameters(
parameters: np.ndarray,
param_ranges: Dict[str, List[float]],
normalized: bool = False,
) -> bool:
"""
Validate that parameters are within acceptable ranges.
Parameters
----------
parameters : np.ndarray
Model parameters array [basin, parameter]
param_ranges : Dict[str, List[float]]
Parameter ranges dictionary
normalized : bool, optional
Whether parameters are normalized (default: False)
Returns
-------
bool
True if all parameters are within valid ranges
"""
if normalized:
# For normalized parameters, check 0-1 range
return np.all(parameters >= 0) and np.all(parameters <= 1)
else:
# For original scale parameters, check against param_ranges
param_list = list(param_ranges.values())
for i, param_range in enumerate(param_list):
if i >= parameters.shape[1]:
break
min_val, max_val = param_range[0], param_range[1]
param_col = parameters[:, i]
if np.any(param_col < min_val) or np.any(param_col > max_val):
return False
return True
Model Dictionary¶
check_dependencies()
¶
Return availability of optional calibration dependencies.
Source code in hydromodel/models/model_dict.py
def check_dependencies():
"""Return availability of optional calibration dependencies."""
dependencies = {}
for package in ["deap", "spotpy", "scipy", "xarray"]:
try:
__import__(package)
dependencies[package] = True
except ImportError:
dependencies[package] = False
return dependencies
describe_model(model_name)
¶
Return model callable and parameter contract metadata.
Source code in hydromodel/models/model_dict.py
def describe_model(model_name):
"""Return model callable and parameter contract metadata."""
if model_name not in MODEL_DICT:
raise KeyError(f"Unsupported model: {model_name}")
from hydromodel.models.model_config import MODEL_PARAM_DICT
return {
"name": model_name,
"available": True,
"parameters": MODEL_PARAM_DICT.get(model_name),
}
list_losses()
¶
Return user-facing objectives and registered internal loss keys.
Source code in hydromodel/models/model_dict.py
def list_losses():
"""Return user-facing objectives and registered internal loss keys."""
return {
"user_objectives": sorted(_USER_OBJECTIVE_MAP.keys()),
"registered_losses": sorted(LOSS_DICT.keys()),
"maximize_losses": sorted(_MAXIMIZE_LOSSES),
}
list_models()
¶
Return registered model names.
Source code in hydromodel/models/model_dict.py
def list_models():
"""Return registered model names."""
return sorted(MODEL_DICT.keys())
resolve_loss_config(loss_config)
¶
Resolve user-facing objective names to minimization loss keys.
Hydromodel optimizers minimize objective values. User-facing metrics such as NSE, KGE, and LogNSE are maximized by mapping them to negated objectives. Existing LOSS_DICT keys remain accepted for compatibility.
Source code in hydromodel/models/model_dict.py
def resolve_loss_config(loss_config):
"""Resolve user-facing objective names to minimization loss keys.
Hydromodel optimizers minimize objective values. User-facing metrics such as
NSE, KGE, and LogNSE are maximized by mapping them to negated objectives.
Existing LOSS_DICT keys remain accepted for compatibility.
"""
resolved = dict(loss_config or {})
obj_func = resolved.get("obj_func", "RMSE")
if callable(obj_func):
resolved.setdefault(
"requested_obj_func", getattr(obj_func, "__name__", "callable")
)
resolved.setdefault("resolved_obj_func", obj_func)
return resolved
requested = str(obj_func)
requested_upper = requested.upper()
if requested_upper in _USER_OBJECTIVE_MAP:
resolved_obj_func = _USER_OBJECTIVE_MAP[requested_upper]
elif requested in LOSS_DICT:
resolved_obj_func = requested
if requested in _MAXIMIZE_LOSSES:
warnings.warn(
f"Objective '{requested}' is a higher-is-better metric but "
"hydromodel optimizers minimize objective values. Use "
f"'{_MAXIMIZE_LOSS_REPLACEMENTS.get(requested, requested_upper)}' "
"or the matching negated objective for calibration.",
RuntimeWarning,
stacklevel=2,
)
elif f"spotpy_{requested.lower()}" in LOSS_DICT:
resolved_obj_func = f"spotpy_{requested.lower()}"
if resolved_obj_func in _MAXIMIZE_LOSSES:
warnings.warn(
f"Objective '{resolved_obj_func}' is a higher-is-better "
"metric but hydromodel optimizers minimize objective values. "
"Use "
f"'{_MAXIMIZE_LOSS_REPLACEMENTS.get(resolved_obj_func, requested_upper)}' "
"or the matching negated objective for calibration.",
RuntimeWarning,
stacklevel=2,
)
else:
supported = sorted(set(_USER_OBJECTIVE_MAP) | set(LOSS_DICT))
raise KeyError(
f"Unsupported objective function '{requested}'. "
f"Supported values include: {', '.join(supported[:20])}"
)
if resolved_obj_func not in LOSS_DICT:
raise KeyError(
f"Resolved objective '{resolved_obj_func}' is not registered in LOSS_DICT"
)
resolved["requested_obj_func"] = resolved.get(
"requested_obj_func", requested_upper
)
resolved["resolved_obj_func"] = resolved_obj_func
resolved["obj_func"] = resolved_obj_func
return resolved
rmse43darr(obs, sim)
¶
RMSE for 3D array
Parameters¶
obs : np.ndarray observation data sim : np.ndarray simulation data
Returns¶
type description
Raises¶
ValueError description
Source code in hydromodel/models/model_dict.py
def rmse43darr(obs, sim):
"""RMSE for 3D array
Parameters
----------
obs : np.ndarray
observation data
sim : np.ndarray
simulation data
Returns
-------
_type_
_description_
Raises
------
ValueError
_description_
"""
rmses = np.sqrt(np.nanmean((sim - obs) ** 2, axis=0))
rmse = rmses.mean(axis=0)
if np.isnan(rmse) or any(np.isnan(sim)):
raise ValueError(
"RMSE is nan or there are nan values in the simulation data, "
"please check the input data."
)
# tolist is necessary for spotpy to get the value
# otherwise the print will incur to an issue
# https://github.com/thouska/spotpy/issues/319
return rmse.tolist()