Skip to content

Simulation period#

Six attributes on Catchment used to describe one thing: start, end and temporal_resolution were given, and date_index, dt and conversion_factor were derived from them in the constructor and then stored beside them as if they were independent. Storing a derivation is how the three drift apart — reassigning end left date_index describing the old span, with nothing to notice — and it is why the same pd.date_range branch was written out four times across the package.

SimulationPeriod holds the three inputs and derives the rest on read, so they cannot disagree. It is frozen: a run covers the period it was built for, and a model that needs a different one gets a new period rather than a mutated one.

model.period.date_index      # one entry per step
model.period.days            # how many steps
len(model.period)            # the same number

SimulationPeriod#

hapi.period.SimulationPeriod dataclass #

The span a run covers, and the calendar and unit factors it implies.

Attributes:

Name Type Description
start datetime

First step of the simulation.

end datetime

Last step of the simulation.

temporal_resolution TemporalResolution

"daily" or "hourly", lower-cased on construction.

Examples:

  • The calendar is derived, so it always matches the span:
    >>> from hapi.period import SimulationPeriod
    >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-10")
    >>> len(period)
    10
    >>> period.date_index[0].strftime("%Y-%m-%d")
    '2009-01-01'
    
  • An hourly period covers the same span with a different step:
    >>> from hapi.period import SimulationPeriod
    >>> hourly = SimulationPeriod.parse(
    ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
    ... )
    >>> len(hourly)
    25
    >>> round(hourly.conversion_factor, 1)
    3.6
    
  • It is frozen, so a derived value can never be left describing a different span:
    >>> from hapi.period import SimulationPeriod
    >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-10")
    >>> period.end = "2010-01-01"  # doctest: +ELLIPSIS
    Traceback (most recent call last):
        ...
    dataclasses.FrozenInstanceError: cannot assign to field 'end'
    
Source code in src/hapi/period.py
 34
 35
 36
 37
 38
 39
 40
 41
 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
 68
 69
 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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
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
241
242
243
244
245
246
247
248
@dataclass(frozen=True)
class SimulationPeriod:
    """The span a run covers, and the calendar and unit factors it implies.

    Attributes:
        start: First step of the simulation.
        end: Last step of the simulation.
        temporal_resolution: `"daily"` or `"hourly"`, lower-cased on construction.

    Examples:
        - The calendar is derived, so it always matches the span:
            ```python
            >>> from hapi.period import SimulationPeriod
            >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-10")
            >>> len(period)
            10
            >>> period.date_index[0].strftime("%Y-%m-%d")
            '2009-01-01'

            ```
        - An hourly period covers the same span with a different step:
            ```python
            >>> from hapi.period import SimulationPeriod
            >>> hourly = SimulationPeriod.parse(
            ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
            ... )
            >>> len(hourly)
            25
            >>> round(hourly.conversion_factor, 1)
            3.6

            ```
        - It is frozen, so a derived value can never be left describing a different span:
            ```python
            >>> from hapi.period import SimulationPeriod
            >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-10")
            >>> period.end = "2010-01-01"  # doctest: +ELLIPSIS
            Traceback (most recent call last):
                ...
            dataclasses.FrozenInstanceError: cannot assign to field 'end'

            ```
    """

    start: dt.datetime
    end: dt.datetime
    temporal_resolution: TemporalResolution = "daily"

    def __post_init__(self):
        """Normalise the resolution and check the span runs forwards.

        Raises:
            TypeError: `temporal_resolution` is not a string.
            ValueError: The resolution is not one of :data:`RESOLUTIONS`, or `end` is before
                `start`.
        """
        if not isinstance(self.temporal_resolution, str):
            raise TypeError(
                f"temporal_resolution must be a string, got "
                f"{type(self.temporal_resolution).__name__}"
            )
        resolution = self.temporal_resolution.lower()
        if resolution not in RESOLUTIONS:
            raise ValueError(
                f"available temporal resolutions are {', '.join(map(repr, RESOLUTIONS))}, "
                f"got {self.temporal_resolution!r}"
            )
        object.__setattr__(self, "temporal_resolution", resolution)

        # A backwards span produces an empty `date_index`, which then fails much later as a
        # zero-length driver mismatch that names neither date.
        if self.end < self.start:
            raise ValueError(
                f"the simulation ends before it starts: {self.start:%Y-%m-%d} to "
                f"{self.end:%Y-%m-%d}"
            )

    @classmethod
    def parse(
        cls,
        start: str,
        end: str,
        fmt: str = "%Y-%m-%d",
        temporal_resolution: str = "Daily",
    ) -> SimulationPeriod:
        """Build a period from the string dates a configuration or a script supplies.

        Args:
            start: Start date.
            end: End date.
            fmt: `strptime` format both dates are read with.
            temporal_resolution: `"Daily"` or `"Hourly"`, matched case-insensitively.

        Returns:
            SimulationPeriod: The parsed period.

        Raises:
            ValueError: A date does not match `fmt`, or the span runs backwards.

        Examples:
            ```python
            >>> from hapi.period import SimulationPeriod
            >>> SimulationPeriod.parse("01/2009/01", "10/2009/01", fmt="%d/%Y/%m").days
            10

            ```
        """
        return cls(
            dt.datetime.strptime(start, fmt),
            dt.datetime.strptime(end, fmt),
            temporal_resolution,  # type: ignore[arg-type]
        )

    @property
    def freq(self) -> str:
        """str: The pandas offset alias for this resolution.

        Examples:
            - Each supported resolution maps to the alias `pd.date_range` expects:
                ```python
                >>> from hapi.period import SimulationPeriod
                >>> SimulationPeriod.parse("2009-01-01", "2009-01-10").freq
                'D'
                >>> SimulationPeriod.parse(
                ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
                ... ).freq
                'h'

                ```
        """
        return RESOLUTIONS[self.temporal_resolution]

    @cached_property
    def date_index(self) -> pd.DatetimeIndex:
        """pandas.DatetimeIndex: One entry per step, from :attr:`start` to :attr:`end`.

        Derived rather than stored: this is the value that used to be computed in the
        constructor and could then outlive a change to the span it described. Cached rather
        than rebuilt, which the frozen class makes safe -- the inputs it derives from cannot
        change, so the cache cannot go stale. It is read once per `from_model` (so once per
        calibration trial) and twice per `SimulationResults._step_bounds` call.

        Examples:
            - One entry per step, inclusive of both ends:
                ```python
                >>> from hapi.period import SimulationPeriod
                >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-05")
                >>> [step.strftime("%m-%d") for step in period.date_index]
                ['01-01', '01-02', '01-03', '01-04', '01-05']

                ```
            - Built once and handed back, because the span it describes cannot change:
                ```python
                >>> from hapi.period import SimulationPeriod
                >>> period = SimulationPeriod.parse("2009-01-01", "2009-12-31")
                >>> period.date_index is period.date_index
                True

                ```
        """
        return pd.date_range(self.start, self.end, freq=self.freq)

    @property
    def days(self) -> int:
        """int: Number of steps the period covers."""
        return len(self.date_index)

    @property
    def conversion_factor(self) -> float:
        """float: Depth-to-discharge factor -- mm over the catchment to m3/s at this step.

        It is the number of seconds in a step divided by 1000, so an hourly step is a
        twenty-fourth of a daily one.

        Examples:
            - The two resolutions differ by exactly a factor of 24:
                ```python
                >>> from hapi.period import SimulationPeriod
                >>> daily = SimulationPeriod.parse("2009-01-01", "2009-01-10")
                >>> hourly = SimulationPeriod.parse(
                ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
                ... )
                >>> daily.conversion_factor
                86.4
                >>> round(daily.conversion_factor / hourly.conversion_factor, 1)
                24.0

                ```
        """
        return (
            CONVERSION_FACTOR
            if self.temporal_resolution == "daily"
            else (CONVERSION_FACTOR / 24)
        )

    @property
    def dt(self) -> float:
        """float: The routing time-step factor.

        One for both resolutions today. It is a property rather than a stored `1` so the
        Muskingum routing has a single place to read it from; whether an hourly run should
        route with a different value is an open question, filed as issue #218 rather than
        silently answered here.

        Note that the catchment routing reads this while the lake paths in
        :mod:`hapi.wrapper` pass :attr:`conversion_factor` (86.4 daily) into the same `dt`
        parameter of the same `Routing.muskingum_v`. Both predate this class and neither was
        changed when it was extracted, so one of the two is routing on a time step 86.4x off
        from the other. Which one is the same physics question as issue #218.
        """
        return 1.0

    def __len__(self) -> int:
        """int: Number of steps, so `len(period)` reads as the span."""
        return self.days

__len__() -> int #

int: Number of steps, so len(period) reads as the span.

Source code in src/hapi/period.py
246
247
248
def __len__(self) -> int:
    """int: Number of steps, so `len(period)` reads as the span."""
    return self.days

__post_init__() #

Normalise the resolution and check the span runs forwards.

Raises:

Type Description
TypeError

temporal_resolution is not a string.

ValueError

The resolution is not one of :data:RESOLUTIONS, or end is before start.

Source code in src/hapi/period.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def __post_init__(self):
    """Normalise the resolution and check the span runs forwards.

    Raises:
        TypeError: `temporal_resolution` is not a string.
        ValueError: The resolution is not one of :data:`RESOLUTIONS`, or `end` is before
            `start`.
    """
    if not isinstance(self.temporal_resolution, str):
        raise TypeError(
            f"temporal_resolution must be a string, got "
            f"{type(self.temporal_resolution).__name__}"
        )
    resolution = self.temporal_resolution.lower()
    if resolution not in RESOLUTIONS:
        raise ValueError(
            f"available temporal resolutions are {', '.join(map(repr, RESOLUTIONS))}, "
            f"got {self.temporal_resolution!r}"
        )
    object.__setattr__(self, "temporal_resolution", resolution)

    # A backwards span produces an empty `date_index`, which then fails much later as a
    # zero-length driver mismatch that names neither date.
    if self.end < self.start:
        raise ValueError(
            f"the simulation ends before it starts: {self.start:%Y-%m-%d} to "
            f"{self.end:%Y-%m-%d}"
        )

conversion_factor: float property #

float: Depth-to-discharge factor -- mm over the catchment to m3/s at this step.

It is the number of seconds in a step divided by 1000, so an hourly step is a twenty-fourth of a daily one.

Examples:

  • The two resolutions differ by exactly a factor of 24:
    >>> from hapi.period import SimulationPeriod
    >>> daily = SimulationPeriod.parse("2009-01-01", "2009-01-10")
    >>> hourly = SimulationPeriod.parse(
    ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
    ... )
    >>> daily.conversion_factor
    86.4
    >>> round(daily.conversion_factor / hourly.conversion_factor, 1)
    24.0
    

date_index: pd.DatetimeIndex cached property #

pandas.DatetimeIndex: One entry per step, from :attr:start to :attr:end.

Derived rather than stored: this is the value that used to be computed in the constructor and could then outlive a change to the span it described. Cached rather than rebuilt, which the frozen class makes safe -- the inputs it derives from cannot change, so the cache cannot go stale. It is read once per from_model (so once per calibration trial) and twice per SimulationResults._step_bounds call.

Examples:

  • One entry per step, inclusive of both ends:
    >>> from hapi.period import SimulationPeriod
    >>> period = SimulationPeriod.parse("2009-01-01", "2009-01-05")
    >>> [step.strftime("%m-%d") for step in period.date_index]
    ['01-01', '01-02', '01-03', '01-04', '01-05']
    
  • Built once and handed back, because the span it describes cannot change:
    >>> from hapi.period import SimulationPeriod
    >>> period = SimulationPeriod.parse("2009-01-01", "2009-12-31")
    >>> period.date_index is period.date_index
    True
    

days: int property #

int: Number of steps the period covers.

dt: float property #

float: The routing time-step factor.

One for both resolutions today. It is a property rather than a stored 1 so the Muskingum routing has a single place to read it from; whether an hourly run should route with a different value is an open question, filed as issue #218 rather than silently answered here.

Note that the catchment routing reads this while the lake paths in :mod:hapi.wrapper pass :attr:conversion_factor (86.4 daily) into the same dt parameter of the same Routing.muskingum_v. Both predate this class and neither was changed when it was extracted, so one of the two is routing on a time step 86.4x off from the other. Which one is the same physics question as issue #218.

freq: str property #

str: The pandas offset alias for this resolution.

Examples:

  • Each supported resolution maps to the alias pd.date_range expects:
    >>> from hapi.period import SimulationPeriod
    >>> SimulationPeriod.parse("2009-01-01", "2009-01-10").freq
    'D'
    >>> SimulationPeriod.parse(
    ...     "2009-01-01", "2009-01-02", temporal_resolution="Hourly"
    ... ).freq
    'h'
    

parse(start: str, end: str, fmt: str = '%Y-%m-%d', temporal_resolution: str = 'Daily') -> SimulationPeriod classmethod #

Build a period from the string dates a configuration or a script supplies.

Parameters:

Name Type Description Default
start str

Start date.

required
end str

End date.

required
fmt str

strptime format both dates are read with.

'%Y-%m-%d'
temporal_resolution str

"Daily" or "Hourly", matched case-insensitively.

'Daily'

Returns:

Type Description
SimulationPeriod

The parsed period.

Raises:

Type Description
ValueError

A date does not match fmt, or the span runs backwards.

Examples:

>>> from hapi.period import SimulationPeriod
>>> SimulationPeriod.parse("01/2009/01", "10/2009/01", fmt="%d/%Y/%m").days
10
Source code in src/hapi/period.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
@classmethod
def parse(
    cls,
    start: str,
    end: str,
    fmt: str = "%Y-%m-%d",
    temporal_resolution: str = "Daily",
) -> SimulationPeriod:
    """Build a period from the string dates a configuration or a script supplies.

    Args:
        start: Start date.
        end: End date.
        fmt: `strptime` format both dates are read with.
        temporal_resolution: `"Daily"` or `"Hourly"`, matched case-insensitively.

    Returns:
        SimulationPeriod: The parsed period.

    Raises:
        ValueError: A date does not match `fmt`, or the span runs backwards.

    Examples:
        ```python
        >>> from hapi.period import SimulationPeriod
        >>> SimulationPeriod.parse("01/2009/01", "10/2009/01", fmt="%d/%Y/%m").days
        10

        ```
    """
    return cls(
        dt.datetime.strptime(start, fmt),
        dt.datetime.strptime(end, fmt),
        temporal_resolution,  # type: ignore[arg-type]
    )