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.
Files changed (77) hide show
  1. {bulum-0.3.4/src/bulum.egg-info → bulum-0.3.5}/PKG-INFO +1 -1
  2. bulum-0.3.5/src/bulum/stats/negflo.py +593 -0
  3. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/stochastic_data_check.py +1 -1
  4. bulum-0.3.5/src/bulum/stats/tests/test_negflo.py +643 -0
  5. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/tests/test_stats.py +22 -6
  6. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/version.py +1 -1
  7. {bulum-0.3.4 → bulum-0.3.5/src/bulum.egg-info}/PKG-INFO +1 -1
  8. bulum-0.3.4/src/bulum/stats/negflo.py +0 -783
  9. bulum-0.3.4/src/bulum/stats/tests/test_negflo.py +0 -359
  10. {bulum-0.3.4 → bulum-0.3.5}/LICENSE.md +0 -0
  11. {bulum-0.3.4 → bulum-0.3.5}/README.md +0 -0
  12. {bulum-0.3.4 → bulum-0.3.5}/pyproject.toml +0 -0
  13. {bulum-0.3.4 → bulum-0.3.5}/setup.cfg +0 -0
  14. {bulum-0.3.4 → bulum-0.3.5}/setup.py +0 -0
  15. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/__init__.py +0 -0
  16. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/__init__.py +0 -0
  17. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/clim.py +0 -0
  18. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/clim/tests/test_clim.py +0 -0
  19. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/demo.py +0 -0
  20. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/__init__.py +0 -0
  21. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/csv_io.py +0 -0
  22. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/general_io.py +0 -0
  23. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/idx_io.py +0 -0
  24. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/iqqm_out_reader.py +0 -0
  25. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/lqn_io.py +0 -0
  26. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/res_csv_io.py +0 -0
  27. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_csv_io.py +0 -0
  28. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_general_io.py +0 -0
  29. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_idx_io.py +0 -0
  30. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_iqqm_out_reader.py +0 -0
  31. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_lqn_io.py +0 -0
  32. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/io/tests/test_res_csv_io.py +0 -0
  33. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/maps/__init__.py +0 -0
  34. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/maps/station_maps.py +0 -0
  35. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/__init__.py +0 -0
  36. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/altair_plots.py +0 -0
  37. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/ensemble_altair_plots.py +0 -0
  38. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/node_diagrams.py +0 -0
  39. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/plot_functions.py +0 -0
  40. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/plotly_helpers.py +0 -0
  41. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/tests/__init__.py +0 -0
  42. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/plots/tests/test_plot_functions.py +0 -0
  43. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/__init__.py +0 -0
  44. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/aggregate_stats.py +0 -0
  45. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/ensemble_stats.py +0 -0
  46. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/__init__.py +0 -0
  47. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/reportcard_data.py +0 -0
  48. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/reportcard_plot.py +0 -0
  49. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/qastat2s/tests/test_qastat2s.py +0 -0
  50. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/reliability_stats_class.py +0 -0
  51. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/storage_level_assessment.py +0 -0
  52. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/__init__.py +0 -0
  53. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/swflo2s.py +0 -0
  54. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/swflo2s/tests/test_swflo2s.py +0 -0
  55. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stats/tests/test_storage_level_assessment.py +0 -0
  56. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/__init__.py +0 -0
  57. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/analyse.py +0 -0
  58. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/generate.py +0 -0
  59. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/stoch/tests/test_stoch.py +0 -0
  60. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/test_circular_import.py +0 -0
  61. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/__init__.py +0 -0
  62. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/tests/test_trans.py +0 -0
  63. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/trans/transformers.py +0 -0
  64. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/__init__.py +0 -0
  65. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/dataframe_extensions.py +0 -0
  66. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/dataframe_functions.py +0 -0
  67. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/datetime_functions.py +0 -0
  68. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/interpolation.py +0 -0
  69. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_ensembles.py +0 -0
  70. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_interpolation.py +0 -0
  71. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_parametrized.py +0 -0
  72. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_tsdf.py +0 -0
  73. {bulum-0.3.4 → bulum-0.3.5}/src/bulum/utils/tests/test_utils.py +0 -0
  74. {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/SOURCES.txt +0 -0
  75. {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/dependency_links.txt +0 -0
  76. {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/requires.txt +0 -0
  77. {bulum-0.3.4 → bulum-0.3.5}/src/bulum.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bulum
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Open source python library for assessing hydrologic model results in Queensland
5
5
  Home-page: https://github.com/odhydrology/bulum
6
6
  Author: Chas Egan
@@ -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()