Skip to content

Conceptual model inputs#

The conceptual model's inputs used to be six loose attributes on Catchment whose rules were enforced nowhere in particular. They are three value objects now, each checking its own invariant in __post_init__, so a bad combination is refused where it is made rather than several frames into a run.

  • ParameterSet — the parameter values plus the (snow, maxbas) pair that fixes their width. Every route to a parameter set goes through the same width rule, including the per-trial replacements a calibration makes. It is frozen; use with_values to derive a new set from an optimiser's vector.
  • ConceptualModelSetup — the model, the catchment area, the initial condition and the initial discharge, as read_lumped_model produces them.
  • ParameterBounds — the calibration's search space, held to the same width rule as the trial vectors it bounds.

ParameterSet#

hapi.conceptual.ParameterSet dataclass #

A parameter set together with the configuration that fixes its width.

Exactly what read_parameters produces. snow and maxbas are not tags travelling beside the array -- they determine how many parameters there must be (:data:PARAMETER_COUNTS), so holding the three together is what lets the rule be checked on construction instead of at one call site.

Frozen: a calibration explores parameter sets by the thousand, and each is a different set rather than a mutation of the last. :meth:with_values returns a new one and re-runs the check, so a distribution function producing the wrong width fails on the trial that produced it rather than as an index error inside the per-cell loop.

Attributes:

Name Type Description
values ndarray | list

(rows, cols, n) for a distributed run, a flat sequence for a lumped one.

snow bool

Whether the snow routine runs.

maxbas bool

Whether the set carries a MAXBAS value instead of Muskingum's two.

Examples:

  • The width has to match the configuration:
    >>> import numpy as np
    >>> from hapi.conceptual import ParameterSet
    >>> ParameterSet(np.zeros((2, 2, 12))).count
    12
    >>> ParameterSet(np.zeros((2, 2, 5)))
    Traceback (most recent call last):
        ...
    ValueError: a model with snow=False, maxbas=False takes 12 parameters, got 5
    
Source code in src/hapi/conceptual.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
@dataclass(frozen=True)
class ParameterSet:
    """A parameter set together with the configuration that fixes its width.

    Exactly what `read_parameters` produces. `snow` and `maxbas` are not tags travelling
    beside the array -- they *determine how many parameters there must be*
    (:data:`PARAMETER_COUNTS`), so holding the three together is what lets the rule be checked
    on construction instead of at one call site.

    Frozen: a calibration explores parameter sets by the thousand, and each is a different
    set rather than a mutation of the last. :meth:`with_values` returns a new one and re-runs
    the check, so a distribution function producing the wrong width fails on the trial that
    produced it rather than as an index error inside the per-cell loop.

    Attributes:
        values: `(rows, cols, n)` for a distributed run, a flat sequence for a lumped one.
        snow: Whether the snow routine runs.
        maxbas: Whether the set carries a MAXBAS value instead of Muskingum's two.

    Examples:
        - The width has to match the configuration:
            ```python
            >>> import numpy as np
            >>> from hapi.conceptual import ParameterSet
            >>> ParameterSet(np.zeros((2, 2, 12))).count
            12
            >>> ParameterSet(np.zeros((2, 2, 5)))
            Traceback (most recent call last):
                ...
            ValueError: a model with snow=False, maxbas=False takes 12 parameters, got 5

            ```
    """

    values: np.ndarray | list
    snow: bool = False
    maxbas: bool = False

    def __post_init__(self):
        """Check the width matches `(snow, maxbas)`.

        Raises:
            ValueError: The count does not match.
        """
        validate_parameter_count(self.values, self.snow, self.maxbas)

    @property
    def count(self) -> int:
        """int: Number of parameters the set carries. See :func:`parameter_count`.

        The width rule is enforced when the set is built, so this reads back what it
        settled on -- for a distributed set, the length of the trailing axis rather than
        the number of cells.

        Examples:
            - A lumped set is a flat vector, so the count is its length:
                ```python
                >>> import numpy as np
                >>> from hapi.conceptual import ParameterSet
                >>> ParameterSet(np.ones(12), snow=False, maxbas=False).count
                12

                ```
            - A distributed set counts the parameters per cell, not the cells:
                ```python
                >>> import numpy as np
                >>> from hapi.conceptual import ParameterSet
                >>> cube = np.ones((13, 14, 12))
                >>> ParameterSet(cube, snow=False, maxbas=False).count
                12

                ```
        """
        return parameter_count(self.values)

    def with_values(self, values: np.ndarray | list) -> ParameterSet:
        """Return the same configuration with a different parameter array.

        Args:
            values: The replacement parameter array.

        Returns:
            ParameterSet: A new set carrying `values`, width already checked.

        Raises:
            ValueError: The replacement does not carry the required count.
        """
        return replace(self, values=values)

__post_init__() #

Check the width matches (snow, maxbas).

Raises:

Type Description
ValueError

The count does not match.

Source code in src/hapi/conceptual.py
191
192
193
194
195
196
197
def __post_init__(self):
    """Check the width matches `(snow, maxbas)`.

    Raises:
        ValueError: The count does not match.
    """
    validate_parameter_count(self.values, self.snow, self.maxbas)

count: int property #

int: Number of parameters the set carries. See :func:parameter_count.

The width rule is enforced when the set is built, so this reads back what it settled on -- for a distributed set, the length of the trailing axis rather than the number of cells.

Examples:

  • A lumped set is a flat vector, so the count is its length:
    >>> import numpy as np
    >>> from hapi.conceptual import ParameterSet
    >>> ParameterSet(np.ones(12), snow=False, maxbas=False).count
    12
    
  • A distributed set counts the parameters per cell, not the cells:
    >>> import numpy as np
    >>> from hapi.conceptual import ParameterSet
    >>> cube = np.ones((13, 14, 12))
    >>> ParameterSet(cube, snow=False, maxbas=False).count
    12
    

with_values(values: np.ndarray | list) -> ParameterSet #

Return the same configuration with a different parameter array.

Parameters:

Name Type Description Default
values ndarray | list

The replacement parameter array.

required

Returns:

Type Description
ParameterSet

A new set carrying values, width already checked.

Raises:

Type Description
ValueError

The replacement does not carry the required count.

Source code in src/hapi/conceptual.py
228
229
230
231
232
233
234
235
236
237
238
239
240
def with_values(self, values: np.ndarray | list) -> ParameterSet:
    """Return the same configuration with a different parameter array.

    Args:
        values: The replacement parameter array.

    Returns:
        ParameterSet: A new set carrying `values`, width already checked.

    Raises:
        ValueError: The replacement does not carry the required count.
    """
    return replace(self, values=values)

ConceptualModelSetup#

hapi.conceptual.ConceptualModelSetup dataclass #

The conceptual model instance and the state it starts from.

Exactly what read_lumped_model produces. Kept apart from :class:ParameterSet because the two are read independently and either may be read first -- pairing them would force a half-built object to exist, which is the thing this refactor is removing.

Attributes:

Name Type Description
model BaseConceptualModel

The conceptual model instance whose simulate is called per cell.

area float | int

Catchment area in km2.

initial_cond list

Initial state values [sp, sm, uz, lz, wc].

q_init float | None

Initial discharge in m3/s, or None to let the model choose.

Examples:

>>> from hapi.conceptual import ConceptualModelSetup
>>> from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92
>>> setup = ConceptualModelSetup(
...     HBVBergestrom92(), 1530.0, [0, 10, 10, 10, 0], q_init=5.0
... )
>>> setup.area, setup.q_init
(1530.0, 5.0)
Source code in src/hapi/conceptual.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
@dataclass(frozen=True)
class ConceptualModelSetup:
    """The conceptual model instance and the state it starts from.

    Exactly what `read_lumped_model` produces. Kept apart from :class:`ParameterSet` because
    the two are read independently and either may be read first -- pairing them would force a
    half-built object to exist, which is the thing this refactor is removing.

    Attributes:
        model: The conceptual model instance whose `simulate` is called per cell.
        area: Catchment area in km2.
        initial_cond: Initial state values `[sp, sm, uz, lz, wc]`.
        q_init: Initial discharge in m3/s, or None to let the model choose.

    Examples:
        ```python
        >>> from hapi.conceptual import ConceptualModelSetup
        >>> from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92
        >>> setup = ConceptualModelSetup(
        ...     HBVBergestrom92(), 1530.0, [0, 10, 10, 10, 0], q_init=5.0
        ... )
        >>> setup.area, setup.q_init
        (1530.0, 5.0)

        ```
    """

    model: BaseConceptualModel
    area: float | int
    initial_cond: list
    q_init: float | None = None

    def __post_init__(self):
        """Check the initial state and discharge.

        Raises:
            TypeError: `initial_cond` is not a list, or `q_init` is neither None nor a float.
            ValueError: `initial_cond` does not hold five values.
        """
        validate_initial_cond(self.initial_cond)
        validate_q_init(self.q_init)

__post_init__() #

Check the initial state and discharge.

Raises:

Type Description
TypeError

initial_cond is not a list, or q_init is neither None nor a float.

ValueError

initial_cond does not hold five values.

Source code in src/hapi/conceptual.py
275
276
277
278
279
280
281
282
283
def __post_init__(self):
    """Check the initial state and discharge.

    Raises:
        TypeError: `initial_cond` is not a list, or `q_init` is neither None nor a float.
        ValueError: `initial_cond` does not hold five values.
    """
    validate_initial_cond(self.initial_cond)
    validate_q_init(self.q_init)

ParameterBounds#

hapi.conceptual.ParameterBounds dataclass #

The search space a calibration explores, and the configuration it explores it under.

Exactly what read_parameters_bound produces. It carries the same (snow, maxbas) pair as :class:ParameterSet because a calibration has no parameter file to read it from -- the bounds are where the configuration enters, and every trial vector the optimiser produces is checked against it.

Attributes:

Name Type Description
lower ndarray | list

Lower bound per element of the optimiser's search vector.

upper ndarray | list

Upper bound per element of the same vector.

snow bool

Whether the snow routine runs.

maxbas bool

Whether the parameter vector carries a MAXBAS value.

Examples:

>>> from hapi.conceptual import ParameterBounds
>>> bounds = ParameterBounds([0.0] * 12, [1.0] * 12)
>>> len(bounds)
12
Source code in src/hapi/conceptual.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
@dataclass(frozen=True)
class ParameterBounds:
    """The search space a calibration explores, and the configuration it explores it under.

    Exactly what `read_parameters_bound` produces. It carries the same `(snow, maxbas)` pair
    as :class:`ParameterSet` because a calibration has no parameter file to read it from --
    the bounds are where the configuration enters, and every trial vector the optimiser
    produces is checked against it.

    Attributes:
        lower: Lower bound per element of the optimiser's search vector.
        upper: Upper bound per element of the same vector.
        snow: Whether the snow routine runs.
        maxbas: Whether the parameter vector carries a MAXBAS value.

    Examples:
        ```python
        >>> from hapi.conceptual import ParameterBounds
        >>> bounds = ParameterBounds([0.0] * 12, [1.0] * 12)
        >>> len(bounds)
        12

        ```
    """

    lower: np.ndarray | list
    upper: np.ndarray | list
    snow: bool = False
    maxbas: bool = False

    def __post_init__(self):
        """Check the two bounds describe the same parameters.

        Raises:
            ValueError: The bounds are different lengths.
        """
        if len(self.lower) != len(self.upper):
            raise ValueError(
                f"the length of UB should be the same as LB, got {len(self.upper)} and "
                f"{len(self.lower)}"
            )
        # No width rule here, deliberately. These bounds delimit the *optimiser's* flat
        # search vector, whose length is the spatial distribution's `ParametersNO` --
        # `no_elem * no_parameters (+ no_lumped_par)`, 980 for a totally distributed Coello
        # run and 243 for the HRU one. A `ParameterSet` is a different thing: the parameters
        # the conceptual model reads, 12 per cell. The two coincide only for a lumped
        # calibration, where the trial vector *is* the parameter set, and
        # `Calibration.calibrate_lumped` checks it there.
        object.__setattr__(self, "lower", np.array(self.lower))
        object.__setattr__(self, "upper", np.array(self.upper))

    def __len__(self) -> int:
        """int: Number of parameters being calibrated."""
        return len(self.lower)

__len__() -> int #

int: Number of parameters being calibrated.

Source code in src/hapi/conceptual.py
337
338
339
def __len__(self) -> int:
    """int: Number of parameters being calibrated."""
    return len(self.lower)

__post_init__() #

Check the two bounds describe the same parameters.

Raises:

Type Description
ValueError

The bounds are different lengths.

Source code in src/hapi/conceptual.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def __post_init__(self):
    """Check the two bounds describe the same parameters.

    Raises:
        ValueError: The bounds are different lengths.
    """
    if len(self.lower) != len(self.upper):
        raise ValueError(
            f"the length of UB should be the same as LB, got {len(self.upper)} and "
            f"{len(self.lower)}"
        )
    # No width rule here, deliberately. These bounds delimit the *optimiser's* flat
    # search vector, whose length is the spatial distribution's `ParametersNO` --
    # `no_elem * no_parameters (+ no_lumped_par)`, 980 for a totally distributed Coello
    # run and 243 for the HRU one. A `ParameterSet` is a different thing: the parameters
    # the conceptual model reads, 12 per cell. The two coincide only for a lumped
    # calibration, where the trial vector *is* the parameter set, and
    # `Calibration.calibrate_lumped` checks it there.
    object.__setattr__(self, "lower", np.array(self.lower))
    object.__setattr__(self, "upper", np.array(self.upper))

Parameter-count helpers#

hapi.conceptual.parameter_count(parameters: np.ndarray | list) -> int #

Count the parameters a set carries, whatever shape it is stored in.

A distributed set is a (rows, cols, n) array and a lumped one a flat sequence, so the count is read from the shape rather than from a mode flag the caller has to supply -- which is how the check used to depend on spatial_resolution.

Parameters:

Name Type Description Default
parameters ndarray | list

The parameter set, 3D for distributed or 1D for lumped.

required

Returns:

Type Description
int

Number of parameters per cell (distributed) or in total (lumped).

Examples:

>>> import numpy as np
>>> from hapi.conceptual import parameter_count
>>> parameter_count(np.zeros((13, 14, 12)))
12
>>> parameter_count([1.0] * 12)
12
Source code in src/hapi/conceptual.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def parameter_count(parameters: np.ndarray | list) -> int:
    """Count the parameters a set carries, whatever shape it is stored in.

    A distributed set is a `(rows, cols, n)` array and a lumped one a flat sequence, so the
    count is read from the shape rather than from a mode flag the caller has to supply --
    which is how the check used to depend on `spatial_resolution`.

    Args:
        parameters: The parameter set, 3D for distributed or 1D for lumped.

    Returns:
        int: Number of parameters per cell (distributed) or in total (lumped).

    Examples:
        ```python
        >>> import numpy as np
        >>> from hapi.conceptual import parameter_count
        >>> parameter_count(np.zeros((13, 14, 12)))
        12
        >>> parameter_count([1.0] * 12)
        12

        ```
    """
    array = np.asarray(parameters)
    return array.shape[2] if array.ndim == 3 else len(array)

hapi.conceptual.validate_parameter_count(parameters: np.ndarray | list, snow: bool, maxbas: bool) -> None #

Check a parameter set carries the count (snow, maxbas) requires.

Split out of the spec's constructor so a builder can run it the moment the parameters and the configuration are both known -- which is inside read_parameters, before the conceptual model itself has been read. Waiting for the whole spec would move a wrong-width error to whichever later call completed it.

Parameters:

Name Type Description Default
parameters ndarray | list

The parameter set.

required
snow bool

Whether the snow routine runs.

required
maxbas bool

Whether the set carries a MAXBAS value.

required

Raises:

Type Description
ValueError

The count does not match.

Examples:

>>> import numpy as np
>>> from hapi.conceptual import validate_parameter_count
>>> validate_parameter_count(np.zeros((2, 2, 5)), snow=False, maxbas=False)
Traceback (most recent call last):
    ...
ValueError: a model with snow=False, maxbas=False takes 12 parameters, got 5
Source code in src/hapi/conceptual.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def validate_parameter_count(
    parameters: np.ndarray | list, snow: bool, maxbas: bool
) -> None:
    """Check a parameter set carries the count `(snow, maxbas)` requires.

    Split out of the spec's constructor so a builder can run it the moment the parameters and
    the configuration are both known -- which is inside `read_parameters`, before the
    conceptual model itself has been read. Waiting for the whole spec would move a
    wrong-width error to whichever later call completed it.

    Args:
        parameters: The parameter set.
        snow: Whether the snow routine runs.
        maxbas: Whether the set carries a MAXBAS value.

    Raises:
        ValueError: The count does not match.

    Examples:
        ```python
        >>> import numpy as np
        >>> from hapi.conceptual import validate_parameter_count
        >>> validate_parameter_count(np.zeros((2, 2, 5)), snow=False, maxbas=False)
        Traceback (most recent call last):
            ...
        ValueError: a model with snow=False, maxbas=False takes 12 parameters, got 5

        ```
    """
    expected = PARAMETER_COUNTS[(bool(snow), bool(maxbas))]
    actual = parameter_count(parameters)
    if actual != expected:
        raise ValueError(
            f"a model with snow={bool(snow)}, maxbas={bool(maxbas)} takes {expected} "
            f"parameters, got {actual}"
        )