bulum 0.3.4__tar.gz → 0.3.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bulum-0.3.4/src/bulum.egg-info → bulum-0.3.5}/PKG-INFO +1 -1
- bulum-0.3.5/src/bulum/stats/negflo.py +593 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/stochastic_data_check.py +1 -1
- bulum-0.3.5/src/bulum/stats/tests/test_negflo.py +643 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/tests/test_stats.py +22 -6
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/version.py +1 -1
- {bulum-0.3.4 → bulum-0.3.5/src/bulum.egg-info}/PKG-INFO +1 -1
- bulum-0.3.4/src/bulum/stats/negflo.py +0 -783
- bulum-0.3.4/src/bulum/stats/tests/test_negflo.py +0 -359
- {bulum-0.3.4 → bulum-0.3.5}/LICENSE.md +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/README.md +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/pyproject.toml +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/setup.cfg +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/setup.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/clim.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/tests/test_clim.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/demo.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/csv_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/general_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/idx_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/iqqm_out_reader.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/lqn_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/res_csv_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_csv_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_general_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_idx_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_iqqm_out_reader.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_lqn_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_res_csv_io.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/maps/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/maps/station_maps.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/altair_plots.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/ensemble_altair_plots.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/node_diagrams.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/plot_functions.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/plotly_helpers.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/tests/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/tests/test_plot_functions.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/aggregate_stats.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/ensemble_stats.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/reportcard_data.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/reportcard_plot.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/tests/test_qastat2s.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/reliability_stats_class.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/storage_level_assessment.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/swflo2s.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/tests/test_swflo2s.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/tests/test_storage_level_assessment.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/analyse.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/generate.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/tests/test_stoch.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/test_circular_import.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/tests/test_trans.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/transformers.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/__init__.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/dataframe_extensions.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/dataframe_functions.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/datetime_functions.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/interpolation.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_ensembles.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_interpolation.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_parametrized.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_tsdf.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_utils.py +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/SOURCES.txt +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/dependency_links.txt +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/requires.txt +0 -0
- {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bulum implementation of NEGFLO.
|
|
3
|
+
|
|
4
|
+
NEGFLO redistributes the negative flows in a residual flow series (e.g.
|
|
5
|
+
downstream gauged flow minus routed upstream flow) by reducing positive flows,
|
|
6
|
+
so that the negatives can be set to zero without inflating the total volume.
|
|
7
|
+
|
|
8
|
+
Concepts
|
|
9
|
+
--------
|
|
10
|
+
Flow event
|
|
11
|
+
A maximal run of consecutive days with flow above the flow limit. Days with
|
|
12
|
+
missing data (NaN) break events.
|
|
13
|
+
Negative volume
|
|
14
|
+
The total of the negative flows between two consecutive events (or before
|
|
15
|
+
the first event, or after the last).
|
|
16
|
+
Absorbing
|
|
17
|
+
An event absorbs a negative volume by reducing each flow's excess above the
|
|
18
|
+
flow limit in proportion, i.e. by a factor of
|
|
19
|
+
``1 - negative volume / total excess``. Flows are never reduced below the
|
|
20
|
+
flow limit. If the event's total excess is too small, all its flows are set
|
|
21
|
+
to the flow limit and the remainder is left over.
|
|
22
|
+
Unallocated negatives
|
|
23
|
+
Negative volume that was not absorbed by any event. It is reported as a
|
|
24
|
+
negative number per column in :attr:`Negflo.unallocated_negatives`, so
|
|
25
|
+
that for every method::
|
|
26
|
+
|
|
27
|
+
sum(result) == sum(residual) - unallocated negatives
|
|
28
|
+
|
|
29
|
+
i.e. the result totals more than the residual by the unallocated volume.
|
|
30
|
+
Missing data
|
|
31
|
+
Missing days (NaN) contribute nothing to any total, break flow events,
|
|
32
|
+
and remain NaN in every result. Negative volumes are carried across gaps
|
|
33
|
+
exactly as they are across days of zero flow.
|
|
34
|
+
|
|
35
|
+
After smoothing, all remaining negative flows are set to zero. Flows between
|
|
36
|
+
zero and the flow limit that are not part of an event are left unchanged.
|
|
37
|
+
|
|
38
|
+
Differences from NEGFLO6
|
|
39
|
+
------------------------
|
|
40
|
+
This implementation follows the NEGFLO6 documentation and C source, except:
|
|
41
|
+
|
|
42
|
+
* Missing data is excluded rather than treated as a measured flow of zero
|
|
43
|
+
(which in NEGFLO6 created spurious negative residuals equal to the modelled
|
|
44
|
+
flow).
|
|
45
|
+
* The flow limit is applied (SM2-SM5, SM7). NEGFLO6 silently ignored positive
|
|
46
|
+
flow limits.
|
|
47
|
+
* Results are not rounded to whole numbers.
|
|
48
|
+
* SM2 and SM3 smooth an event that is still open at the end of the series.
|
|
49
|
+
NEGFLO6 skipped smoothing when that was the only event.
|
|
50
|
+
* SM4's carry-over is calculated correctly. NEGFLO6 had a typo that reduced
|
|
51
|
+
each event by the wrong negative total, and reused the previous event's
|
|
52
|
+
total for an event still open at the end of the series.
|
|
53
|
+
* SM6 only clips days outside every segment. NEGFLO6 could instead change them
|
|
54
|
+
using totals left over from SM2 when the segments did not cover the whole
|
|
55
|
+
period.
|
|
56
|
+
* SM7 is a method in its own right using a non-negative flow limit, rather than
|
|
57
|
+
being triggered by a negative flow limit. An event may absorb negative
|
|
58
|
+
volumes from both sides, and an event still open at the end of the series is
|
|
59
|
+
compared like any other. NEGFLO6 dropped one of the two volumes when an event
|
|
60
|
+
was chosen by both, and never chose an open final event.
|
|
61
|
+
|
|
62
|
+
References
|
|
63
|
+
----------
|
|
64
|
+
Specification: https://qldhyd.atlassian.net/wiki/spaces/MET/pages/524386/Negflo
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
import logging
|
|
68
|
+
import os
|
|
69
|
+
from collections.abc import Callable, Hashable, Sequence
|
|
70
|
+
from enum import Enum
|
|
71
|
+
from itertools import pairwise
|
|
72
|
+
|
|
73
|
+
import numpy as np
|
|
74
|
+
import pandas as pd
|
|
75
|
+
|
|
76
|
+
from bulum.utils import TimeseriesDataframe
|
|
77
|
+
|
|
78
|
+
__all__ = ["Negflo", "NegfloAnalysisType"]
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
# Relative tolerance below which a volume difference is treated as rounding
|
|
83
|
+
# error, so that e.g. an exactly balanced event leaves zeros, not 1e-17s.
|
|
84
|
+
_RTOL = 1e-9
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NegfloAnalysisType(Enum):
|
|
88
|
+
"""Negflo class keeps track of most recent analysis performed."""
|
|
89
|
+
RAW = -1
|
|
90
|
+
CLIPPED = 0
|
|
91
|
+
SMOOTHED_ALL = 1 # sm1
|
|
92
|
+
SMOOTHED_FORWARD = 2 # sm2
|
|
93
|
+
SMOOTHED_FORWARD_NO_CARRY = 3 # sm3
|
|
94
|
+
SMOOTHED_BACKWARD = 4 # sm4
|
|
95
|
+
SMOOTHED_BACKWARD_NO_CARRY = 5 # sm5
|
|
96
|
+
SMOOTHED_SEGMENTS = 6 # sm6
|
|
97
|
+
SMOOTHED_NEG_LIM = 7 # sm7 (named after NEGFLO6's negative flow limit switch)
|
|
98
|
+
|
|
99
|
+
def to_file_extension(self) -> str:
|
|
100
|
+
"""Gives the corresponding file extension for the analysis type."""
|
|
101
|
+
match self:
|
|
102
|
+
case NegfloAnalysisType.RAW:
|
|
103
|
+
return ".rw1"
|
|
104
|
+
case NegfloAnalysisType.CLIPPED:
|
|
105
|
+
return ".cl1"
|
|
106
|
+
case NegfloAnalysisType.SMOOTHED_ALL:
|
|
107
|
+
return ".sm1"
|
|
108
|
+
case NegfloAnalysisType.SMOOTHED_FORWARD:
|
|
109
|
+
return ".sm2"
|
|
110
|
+
case NegfloAnalysisType.SMOOTHED_FORWARD_NO_CARRY:
|
|
111
|
+
return ".sm3"
|
|
112
|
+
case NegfloAnalysisType.SMOOTHED_BACKWARD:
|
|
113
|
+
return ".sm4"
|
|
114
|
+
case NegfloAnalysisType.SMOOTHED_BACKWARD_NO_CARRY:
|
|
115
|
+
return ".sm5"
|
|
116
|
+
case NegfloAnalysisType.SMOOTHED_SEGMENTS:
|
|
117
|
+
return ".sm6"
|
|
118
|
+
case NegfloAnalysisType.SMOOTHED_NEG_LIM:
|
|
119
|
+
return ".sm7"
|
|
120
|
+
case _:
|
|
121
|
+
raise ValueError(f"Unhandled/invalid enum, {self}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Series-level algorithms.
|
|
126
|
+
#
|
|
127
|
+
# Each smoothing function takes a 1-D float array (NaN = missing), modifies it
|
|
128
|
+
# in place, and returns the negative volume (as a positive number) it could not
|
|
129
|
+
# absorb. Negative flows are left in place; the caller sets them to zero.
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _negative_total(x: np.ndarray) -> float:
|
|
133
|
+
"""Total magnitude of the negative flows in `x`."""
|
|
134
|
+
return float(-x[x < 0].sum())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _find_events(x: np.ndarray, flow_limit: float) -> list[np.ndarray]:
|
|
138
|
+
"""Indices of each flow event, i.e. maximal run of consecutive days with
|
|
139
|
+
flow above `flow_limit`. Missing days are never part of an event."""
|
|
140
|
+
above = np.concatenate(([0], (x > flow_limit).astype(np.int8), [0]))
|
|
141
|
+
edges = np.diff(above)
|
|
142
|
+
starts = np.flatnonzero(edges == 1)
|
|
143
|
+
ends = np.flatnonzero(edges == -1)
|
|
144
|
+
return [np.arange(start, end) for start, end in zip(starts, ends, strict=True)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _negative_volumes(x: np.ndarray, events: list[np.ndarray]) -> list[float]:
|
|
148
|
+
"""Negative volume preceding each event, followed by the volume after the
|
|
149
|
+
last event (so ``len(events) + 1`` values).
|
|
150
|
+
|
|
151
|
+
Requires a non-negative flow limit, so that events contain no negatives.
|
|
152
|
+
"""
|
|
153
|
+
cuts = [0, *(event[0] for event in events), len(x)]
|
|
154
|
+
return [_negative_total(x[start:end]) for start, end in pairwise(cuts)]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _absorb(x: np.ndarray, idx: np.ndarray, volume: float, flow_limit: float) -> float:
|
|
158
|
+
"""Reduce the flows at `idx` (all above `flow_limit`) by a total of
|
|
159
|
+
`volume`, in proportion to their excess above `flow_limit`.
|
|
160
|
+
|
|
161
|
+
Returns the part of `volume` that could not be absorbed.
|
|
162
|
+
"""
|
|
163
|
+
if volume <= 0 or len(idx) == 0:
|
|
164
|
+
return volume
|
|
165
|
+
excess = x[idx] - flow_limit
|
|
166
|
+
total_excess = excess.sum()
|
|
167
|
+
if volume >= total_excess * (1 - _RTOL):
|
|
168
|
+
x[idx] = flow_limit
|
|
169
|
+
leftover = volume - total_excess
|
|
170
|
+
return leftover if leftover > _RTOL * total_excess else 0.0
|
|
171
|
+
x[idx] = flow_limit + excess * (1 - volume / total_excess)
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _smooth_all(x: np.ndarray) -> float:
|
|
176
|
+
"""SM1: all negatives absorbed by all positive flows (flow limit of zero)."""
|
|
177
|
+
return _absorb(x, np.flatnonzero(x > 0), _negative_total(x), 0.0)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _smooth_forward(x: np.ndarray, flow_limit: float, carry: bool) -> float:
|
|
181
|
+
"""SM2/SM3: each event absorbs the negative volume preceding it."""
|
|
182
|
+
events = _find_events(x, flow_limit)
|
|
183
|
+
volumes = _negative_volumes(x, events)
|
|
184
|
+
carried = lost = 0.0
|
|
185
|
+
for event, volume in zip(events, volumes[:-1], strict=True):
|
|
186
|
+
leftover = _absorb(x, event, carried + volume, flow_limit)
|
|
187
|
+
if carry:
|
|
188
|
+
carried = leftover
|
|
189
|
+
else:
|
|
190
|
+
lost += leftover
|
|
191
|
+
return carried + lost + volumes[-1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _smooth_backward(x: np.ndarray, flow_limit: float, carry: bool) -> float:
|
|
195
|
+
"""SM4/SM5: each event absorbs the negative volume following it.
|
|
196
|
+
|
|
197
|
+
With `carry`, whatever an event cannot absorb is carried forward to the
|
|
198
|
+
next event. Negatives before the first event have no preceding event, so
|
|
199
|
+
they are not redistributed.
|
|
200
|
+
"""
|
|
201
|
+
events = _find_events(x, flow_limit)
|
|
202
|
+
volumes = _negative_volumes(x, events)
|
|
203
|
+
carried, lost = 0.0, volumes[0]
|
|
204
|
+
for event, volume in zip(events, volumes[1:], strict=True):
|
|
205
|
+
leftover = _absorb(x, event, carried + volume, flow_limit)
|
|
206
|
+
if carry:
|
|
207
|
+
carried = leftover
|
|
208
|
+
else:
|
|
209
|
+
lost += leftover
|
|
210
|
+
return carried + lost
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _smooth_adjacent(x: np.ndarray, flow_limit: float) -> float:
|
|
214
|
+
"""SM7: each negative volume is absorbed by the larger (by excess volume)
|
|
215
|
+
of the events either side of it; the preceding event wins ties."""
|
|
216
|
+
events = _find_events(x, flow_limit)
|
|
217
|
+
volumes = _negative_volumes(x, events)
|
|
218
|
+
if not events:
|
|
219
|
+
return volumes[0]
|
|
220
|
+
excess = [(x[event] - flow_limit).sum() for event in events]
|
|
221
|
+
to_absorb = np.zeros(len(events))
|
|
222
|
+
for k, volume in enumerate(volumes):
|
|
223
|
+
# volumes[k] lies between events k-1 and k
|
|
224
|
+
neighbours = [j for j in (k - 1, k) if 0 <= j < len(events)]
|
|
225
|
+
to_absorb[max(neighbours, key=lambda j: excess[j])] += volume
|
|
226
|
+
return sum(_absorb(x, event, volume, flow_limit)
|
|
227
|
+
for event, volume in zip(events, to_absorb, strict=True))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _smooth_segments(x: np.ndarray, segment_masks: Sequence[np.ndarray]) -> float:
|
|
231
|
+
"""SM6: SM1 applied within each segment. Days outside every segment are
|
|
232
|
+
not smoothed, so their negatives are lost."""
|
|
233
|
+
covered = np.zeros(len(x), dtype=bool)
|
|
234
|
+
lost = 0.0
|
|
235
|
+
for mask in segment_masks:
|
|
236
|
+
segment = x[mask]
|
|
237
|
+
lost += _smooth_all(segment)
|
|
238
|
+
x[mask] = segment
|
|
239
|
+
covered |= mask
|
|
240
|
+
return lost + _negative_total(x[~covered])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _generate_segments(start: pd.Timestamp, last_date: pd.Timestamp,
|
|
244
|
+
frequency: pd.DateOffset) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
|
|
245
|
+
"""Consecutive segments of length `frequency` from `start`, covering
|
|
246
|
+
`last_date`."""
|
|
247
|
+
segments = []
|
|
248
|
+
while start <= last_date:
|
|
249
|
+
next_start = start + frequency
|
|
250
|
+
segments.append((start, next_start - pd.Timedelta(days=1)))
|
|
251
|
+
start = next_start
|
|
252
|
+
return segments
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _as_dates(index: pd.Index) -> pd.DatetimeIndex:
|
|
256
|
+
"""`index` as dates, checking it is non-empty, date-like, sorted and
|
|
257
|
+
unique (as segments are defined by date)."""
|
|
258
|
+
if len(index) == 0:
|
|
259
|
+
raise ValueError("The residual has no data.")
|
|
260
|
+
if pd.api.types.is_numeric_dtype(index):
|
|
261
|
+
raise ValueError(f"Expected an index of dates, got {index.dtype} values.")
|
|
262
|
+
dates = pd.DatetimeIndex(pd.to_datetime(index))
|
|
263
|
+
if not (dates.is_monotonic_increasing and dates.is_unique):
|
|
264
|
+
raise ValueError("Expected the dates in the index to be sorted and unique.")
|
|
265
|
+
return dates
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Public interface
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
class Negflo:
|
|
273
|
+
"""Bulum implementation of NEGFLO.
|
|
274
|
+
|
|
275
|
+
Each analysis method (:meth:`rw1`, :meth:`cl1`, :meth:`sm1` ... :meth:`sm7`)
|
|
276
|
+
works from the residual supplied on construction, independently of any
|
|
277
|
+
previous call, and returns the result as a new dataframe. Columns are
|
|
278
|
+
processed independently. See the module documentation for the concepts
|
|
279
|
+
and the treatment of missing data.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
df_residual : pd.DataFrame or TimeseriesDataframe
|
|
284
|
+
Residual flows, one series per column, in time order. Missing data
|
|
285
|
+
should be NaN. Values are converted to float.
|
|
286
|
+
flow_limit : float, default 0.0
|
|
287
|
+
Non-negative flow limit (see :attr:`flow_limit`).
|
|
288
|
+
segments : sequence of (start, end), optional
|
|
289
|
+
Inclusive date ranges for :meth:`sm6`.
|
|
290
|
+
|
|
291
|
+
Raises
|
|
292
|
+
------
|
|
293
|
+
ValueError
|
|
294
|
+
If `flow_limit` is negative.
|
|
295
|
+
|
|
296
|
+
Attributes
|
|
297
|
+
----------
|
|
298
|
+
flow_limit : float
|
|
299
|
+
Flows at or below this limit never absorb negative flow (SM2-SM5, SM7).
|
|
300
|
+
SM1 and SM6 do not use it.
|
|
301
|
+
segments : list of (start, end) tuples, optional
|
|
302
|
+
Inclusive date ranges used by :meth:`sm6`.
|
|
303
|
+
unallocated_negatives : dict
|
|
304
|
+
Negative volume (as a negative number) that the most recent analysis
|
|
305
|
+
could not redistribute, per column.
|
|
306
|
+
df_name : str or None
|
|
307
|
+
Name of the input TimeseriesDataframe, used to name output files.
|
|
308
|
+
None for other inputs, including those constructed with
|
|
309
|
+
:meth:`from_flows`, whose files are named ``result.*``.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self,
|
|
313
|
+
df_residual: pd.DataFrame | TimeseriesDataframe,
|
|
314
|
+
flow_limit: float = 0.0,
|
|
315
|
+
*,
|
|
316
|
+
segments: Sequence[tuple[pd.Timestamp, pd.Timestamp]] | None = None):
|
|
317
|
+
if flow_limit < 0:
|
|
318
|
+
raise ValueError(f"Expected a non-negative flow limit, got {flow_limit}.")
|
|
319
|
+
self.df_name: str | None = (
|
|
320
|
+
df_residual.name if isinstance(df_residual, TimeseriesDataframe) else None)
|
|
321
|
+
self.flow_limit = flow_limit
|
|
322
|
+
self.segments = segments
|
|
323
|
+
self.unallocated_negatives: dict[Hashable, float] = {}
|
|
324
|
+
self._df_residual_raw = df_residual.astype(float)
|
|
325
|
+
self._df_residual = self._df_residual_raw.copy()
|
|
326
|
+
self._analysis_type = NegfloAnalysisType.RAW
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def from_flows(cls,
|
|
330
|
+
df_measured: pd.DataFrame,
|
|
331
|
+
df_modelled: pd.DataFrame,
|
|
332
|
+
flow_limit: float = 0.0,
|
|
333
|
+
*,
|
|
334
|
+
segments: Sequence[tuple[pd.Timestamp, pd.Timestamp]] | None = None
|
|
335
|
+
) -> "Negflo":
|
|
336
|
+
"""Construct from measured (e.g. downstream gauged) and modelled (e.g.
|
|
337
|
+
routed upstream) flows, computing the residual as measured - modelled.
|
|
338
|
+
|
|
339
|
+
The dataframes are aligned on their index; a day is missing in the
|
|
340
|
+
residual if it is missing (or absent) in either input.
|
|
341
|
+
|
|
342
|
+
Parameters
|
|
343
|
+
----------
|
|
344
|
+
df_measured, df_modelled : pd.DataFrame
|
|
345
|
+
Flows with matching column names.
|
|
346
|
+
flow_limit, segments
|
|
347
|
+
See :class:`Negflo`.
|
|
348
|
+
"""
|
|
349
|
+
if set(df_measured.columns) != set(df_modelled.columns):
|
|
350
|
+
raise ValueError("Measured and modelled flows must have the same columns. Got "
|
|
351
|
+
f"{list(df_measured.columns)} and {list(df_modelled.columns)}.")
|
|
352
|
+
return cls(df_measured - df_modelled[df_measured.columns], flow_limit, segments=segments)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def df_residual(self) -> pd.DataFrame:
|
|
356
|
+
"""The result of the most recent analysis (read-only copy)."""
|
|
357
|
+
return self._df_residual.copy()
|
|
358
|
+
|
|
359
|
+
def _run(self, analysis_type: NegfloAnalysisType,
|
|
360
|
+
smooth: Callable[[np.ndarray], float]) -> pd.DataFrame:
|
|
361
|
+
"""Apply `smooth` to each column of the raw residual, set remaining
|
|
362
|
+
negatives to zero, and record the unallocated negatives."""
|
|
363
|
+
self._analysis_type = analysis_type
|
|
364
|
+
self.unallocated_negatives = {}
|
|
365
|
+
result = self._df_residual_raw.copy()
|
|
366
|
+
for i, column in enumerate(result.columns):
|
|
367
|
+
x = result.iloc[:, i].to_numpy(dtype=float, copy=True)
|
|
368
|
+
unallocated = float(smooth(x))
|
|
369
|
+
x[x < 0] = 0.0
|
|
370
|
+
result.iloc[:, i] = x
|
|
371
|
+
self.unallocated_negatives[column] = -unallocated if unallocated else 0.0
|
|
372
|
+
if unallocated and analysis_type is not NegfloAnalysisType.CLIPPED:
|
|
373
|
+
logger.info("%s: %s of negative flow could not be redistributed in %s.",
|
|
374
|
+
column, unallocated, analysis_type.name)
|
|
375
|
+
self._df_residual = result
|
|
376
|
+
return result.copy()
|
|
377
|
+
|
|
378
|
+
def rw1(self) -> pd.DataFrame:
|
|
379
|
+
"""The raw residual, unchanged.
|
|
380
|
+
|
|
381
|
+
Returns
|
|
382
|
+
-------
|
|
383
|
+
pd.DataFrame
|
|
384
|
+
The raw residual dataframe.
|
|
385
|
+
"""
|
|
386
|
+
self._analysis_type = NegfloAnalysisType.RAW
|
|
387
|
+
self.unallocated_negatives = dict.fromkeys(self._df_residual_raw.columns, 0.0)
|
|
388
|
+
self._df_residual = self._df_residual_raw.copy()
|
|
389
|
+
return self._df_residual.copy()
|
|
390
|
+
|
|
391
|
+
def cl1(self) -> pd.DataFrame:
|
|
392
|
+
"""Set all negative flows to zero.
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
pd.DataFrame
|
|
397
|
+
The clipped residual dataframe.
|
|
398
|
+
"""
|
|
399
|
+
return self._run(NegfloAnalysisType.CLIPPED, _negative_total)
|
|
400
|
+
|
|
401
|
+
def sm1(self) -> pd.DataFrame:
|
|
402
|
+
"""Redistribute negative flows across the whole period.
|
|
403
|
+
|
|
404
|
+
All negative flows are set to zero and all positive flows are factored
|
|
405
|
+
by::
|
|
406
|
+
|
|
407
|
+
1 - abs(Total of the negative flows)/(Total of the positive flows)
|
|
408
|
+
|
|
409
|
+
This preserves the variability of the flows and the total volume, and
|
|
410
|
+
is most useful for preserving storage behaviour if the reach flows into
|
|
411
|
+
a dam. The flow limit is not used (i.e. it is zero).
|
|
412
|
+
|
|
413
|
+
Returns
|
|
414
|
+
-------
|
|
415
|
+
pd.DataFrame
|
|
416
|
+
The smoothed residual dataframe.
|
|
417
|
+
"""
|
|
418
|
+
return self._run(NegfloAnalysisType.SMOOTHED_ALL, _smooth_all)
|
|
419
|
+
|
|
420
|
+
def sm2(self) -> pd.DataFrame:
|
|
421
|
+
"""Redistribute negative flows into the following flow event, with
|
|
422
|
+
carry-over.
|
|
423
|
+
|
|
424
|
+
Each flow event (a run of flows above the flow limit) absorbs the
|
|
425
|
+
negative flows preceding it, reducing the flows' excess above the flow
|
|
426
|
+
limit by the factor::
|
|
427
|
+
|
|
428
|
+
1 - abs(Total of the preceding negative flows)/(Total excess of the event)
|
|
429
|
+
|
|
430
|
+
If an event is not large enough, its flows are set to the flow limit
|
|
431
|
+
and the remaining negative flow is carried over to the next event.
|
|
432
|
+
|
|
433
|
+
This is most useful if the negative flows mainly occur on the rising
|
|
434
|
+
limb of the hydrograph. With a flow limit of zero, the mean flow is
|
|
435
|
+
close to the measured mean, but small flow peaks can be eliminated.
|
|
436
|
+
A higher flow limit preserves these peaks but can severely reduce high
|
|
437
|
+
flows, giving a ranked flow plot with a notch at the flow limit.
|
|
438
|
+
|
|
439
|
+
Returns
|
|
440
|
+
-------
|
|
441
|
+
pd.DataFrame
|
|
442
|
+
The smoothed residual dataframe.
|
|
443
|
+
"""
|
|
444
|
+
return self._run(NegfloAnalysisType.SMOOTHED_FORWARD,
|
|
445
|
+
lambda x: _smooth_forward(x, self.flow_limit, carry=True))
|
|
446
|
+
|
|
447
|
+
def sm3(self) -> pd.DataFrame:
|
|
448
|
+
"""As :meth:`sm2`, but negative flow an event cannot absorb is not
|
|
449
|
+
carried over to the next event.
|
|
450
|
+
|
|
451
|
+
Returns
|
|
452
|
+
-------
|
|
453
|
+
pd.DataFrame
|
|
454
|
+
The smoothed residual dataframe.
|
|
455
|
+
"""
|
|
456
|
+
return self._run(NegfloAnalysisType.SMOOTHED_FORWARD_NO_CARRY,
|
|
457
|
+
lambda x: _smooth_forward(x, self.flow_limit, carry=False))
|
|
458
|
+
|
|
459
|
+
def sm4(self) -> pd.DataFrame:
|
|
460
|
+
"""Redistribute negative flows into the preceding flow event, with
|
|
461
|
+
carry-over.
|
|
462
|
+
|
|
463
|
+
As :meth:`sm2`, except each event absorbs the negative flows following
|
|
464
|
+
it. Negative flow an event cannot absorb is carried over to the next
|
|
465
|
+
event. Negative flow before the first event has no preceding event, so
|
|
466
|
+
it is not redistributed (as in NEGFLO6). This is most useful if the
|
|
467
|
+
negative flows mainly occur on the falling limb of the hydrograph.
|
|
468
|
+
|
|
469
|
+
Returns
|
|
470
|
+
-------
|
|
471
|
+
pd.DataFrame
|
|
472
|
+
The smoothed residual dataframe.
|
|
473
|
+
"""
|
|
474
|
+
return self._run(NegfloAnalysisType.SMOOTHED_BACKWARD,
|
|
475
|
+
lambda x: _smooth_backward(x, self.flow_limit, carry=True))
|
|
476
|
+
|
|
477
|
+
def sm5(self) -> pd.DataFrame:
|
|
478
|
+
"""As :meth:`sm4`, but negative flow an event cannot absorb (or that
|
|
479
|
+
precedes the first event) is not carried over.
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
pd.DataFrame
|
|
484
|
+
The smoothed residual dataframe.
|
|
485
|
+
"""
|
|
486
|
+
return self._run(NegfloAnalysisType.SMOOTHED_BACKWARD_NO_CARRY,
|
|
487
|
+
lambda x: _smooth_backward(x, self.flow_limit, carry=False))
|
|
488
|
+
|
|
489
|
+
def sm6(self, *, use_predefined_segments: bool = True,
|
|
490
|
+
sampling_frequency: pd.DateOffset | None = None,
|
|
491
|
+
sampling_start_date: pd.Timestamp | None = None) -> pd.DataFrame:
|
|
492
|
+
"""Redistribute negative flows within each segment, as per :meth:`sm1`.
|
|
493
|
+
|
|
494
|
+
Negative flows outside every segment are set to zero. As with
|
|
495
|
+
:meth:`sm1`, the flow limit is not used.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
use_predefined_segments : bool, default True
|
|
500
|
+
Use :attr:`segments` if they exist. Otherwise segments are
|
|
501
|
+
generated from `sampling_frequency` and `sampling_start_date`.
|
|
502
|
+
sampling_frequency : pd.DateOffset, optional
|
|
503
|
+
Length of generated segments. Defaults to one year.
|
|
504
|
+
sampling_start_date : pd.Timestamp, optional
|
|
505
|
+
Start of the first generated segment. Defaults to the start of the
|
|
506
|
+
data.
|
|
507
|
+
|
|
508
|
+
Returns
|
|
509
|
+
-------
|
|
510
|
+
pd.DataFrame
|
|
511
|
+
The smoothed residual dataframe.
|
|
512
|
+
|
|
513
|
+
Raises
|
|
514
|
+
------
|
|
515
|
+
ValueError
|
|
516
|
+
If the residual is empty, its index is not sorted, unique dates, or
|
|
517
|
+
a segment ends before it starts or overlaps another.
|
|
518
|
+
|
|
519
|
+
See Also
|
|
520
|
+
--------
|
|
521
|
+
sm1
|
|
522
|
+
"""
|
|
523
|
+
dates = _as_dates(self._df_residual_raw.index)
|
|
524
|
+
if use_predefined_segments and self.segments is not None:
|
|
525
|
+
segments = [(pd.Timestamp(start), pd.Timestamp(end)) for start, end in self.segments]
|
|
526
|
+
else:
|
|
527
|
+
segments = _generate_segments(
|
|
528
|
+
pd.Timestamp(dates[0] if sampling_start_date is None else sampling_start_date),
|
|
529
|
+
dates[-1],
|
|
530
|
+
pd.DateOffset(years=1) if sampling_frequency is None else sampling_frequency)
|
|
531
|
+
|
|
532
|
+
masks: list[np.ndarray] = []
|
|
533
|
+
for start, end in segments:
|
|
534
|
+
if end < start:
|
|
535
|
+
raise ValueError(f"Segment ends before it starts: {(start, end)}")
|
|
536
|
+
mask = np.asarray((dates >= start) & (dates <= end))
|
|
537
|
+
if any((mask & other).any() for other in masks):
|
|
538
|
+
raise ValueError(f"Segment overlaps another segment: {(start, end)}")
|
|
539
|
+
masks.append(mask)
|
|
540
|
+
|
|
541
|
+
return self._run(NegfloAnalysisType.SMOOTHED_SEGMENTS,
|
|
542
|
+
lambda x: _smooth_segments(x, masks))
|
|
543
|
+
|
|
544
|
+
def sm7(self) -> pd.DataFrame:
|
|
545
|
+
"""Redistribute negative flows into the larger adjacent flow event.
|
|
546
|
+
|
|
547
|
+
The negative flows between two flow events are absorbed (as per
|
|
548
|
+
:meth:`sm2`) by whichever of the two events has the larger total
|
|
549
|
+
excess above the flow limit; the preceding event wins ties. Negative
|
|
550
|
+
flows before the first event or after the last are absorbed by that
|
|
551
|
+
event. Negative flow an event cannot absorb is not carried over.
|
|
552
|
+
|
|
553
|
+
Returns
|
|
554
|
+
-------
|
|
555
|
+
pd.DataFrame
|
|
556
|
+
The smoothed residual dataframe.
|
|
557
|
+
"""
|
|
558
|
+
return self._run(NegfloAnalysisType.SMOOTHED_NEG_LIM,
|
|
559
|
+
lambda x: _smooth_adjacent(x, self.flow_limit))
|
|
560
|
+
|
|
561
|
+
def run_all(self, folder: str = ".") -> None:
|
|
562
|
+
"""Runs all analyses on the residual, saving each to ``folder``.
|
|
563
|
+
|
|
564
|
+
Requires an index of dates, for :meth:`sm6`.
|
|
565
|
+
"""
|
|
566
|
+
for analysis in (self.rw1, self.cl1, self.sm1, self.sm2, self.sm3,
|
|
567
|
+
self.sm4, self.sm5, self.sm6, self.sm7):
|
|
568
|
+
analysis()
|
|
569
|
+
self.to_file(folder=folder)
|
|
570
|
+
|
|
571
|
+
def to_file(self, *, out_filename: str | None = None, folder: str | None = None) -> None:
|
|
572
|
+
"""Saves the result of the most recent analysis to a csv file. Missing
|
|
573
|
+
data is written as an empty value.
|
|
574
|
+
|
|
575
|
+
Parameters
|
|
576
|
+
----------
|
|
577
|
+
out_filename : str, optional
|
|
578
|
+
Explicit output path. If it has no extension, the extension of the
|
|
579
|
+
current analysis type is added. If omitted, the filename is derived
|
|
580
|
+
from ``df_name`` and the current analysis type extension.
|
|
581
|
+
folder : str, optional
|
|
582
|
+
Directory in which to place the auto-named file. Ignored when
|
|
583
|
+
``out_filename`` is supplied.
|
|
584
|
+
"""
|
|
585
|
+
if out_filename is None:
|
|
586
|
+
out_filename = (
|
|
587
|
+
("result" if self.df_name is None else self.df_name)
|
|
588
|
+
+ self._analysis_type.to_file_extension())
|
|
589
|
+
if folder is not None:
|
|
590
|
+
out_filename = os.path.join(folder, out_filename)
|
|
591
|
+
elif not os.path.splitext(out_filename)[1]:
|
|
592
|
+
out_filename += self._analysis_type.to_file_extension()
|
|
593
|
+
self.df_residual.to_csv(out_filename)
|
|
@@ -408,7 +408,7 @@ class StochasticDataComparison:
|
|
|
408
408
|
subset = self.cropped_df_ann[ds]
|
|
409
409
|
|
|
410
410
|
stats[ds]['annual']['mean'] = subset.mean()
|
|
411
|
-
stats[ds]['annual']['stddev'] = subset.apply(lambda x: np.std)
|
|
411
|
+
stats[ds]['annual']['stddev'] = subset.apply(lambda x: np.std(x))
|
|
412
412
|
stats[ds]['annual']['skew'] = subset.skew()
|
|
413
413
|
stats[ds]['annual']['coeffofvar'] = subset.apply(lambda x: np.std(x)/np.mean(x))
|
|
414
414
|
stats[ds]['annual']['min'] = subset.min()
|