rainfallqc 0.0.9__py3-none-any.whl
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.
- rainfallqc/__init__.py +22 -0
- rainfallqc/checks/__init__.py +1 -0
- rainfallqc/checks/comparison_checks.py +342 -0
- rainfallqc/checks/gauge_checks.py +234 -0
- rainfallqc/checks/neighbourhood_checks.py +341 -0
- rainfallqc/checks/timeseries_checks.py +1042 -0
- rainfallqc/core/__init__.py +1 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_CDD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_CWD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_PRCPTOT_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_R99p_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_Rx1day_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/RawData_HADEX2_SDII_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- rainfallqc/data/ETCCDI/__init__.py +1 -0
- rainfallqc/data/__init__.py +1 -0
- rainfallqc/qc_frameworks/__init__.py +1 -0
- rainfallqc/qc_frameworks/apply_qc_framework.py +63 -0
- rainfallqc/qc_frameworks/inbuilt_qc_frameworks.py +25 -0
- rainfallqc/utils/__init__.py +1 -0
- rainfallqc/utils/data_readers.py +713 -0
- rainfallqc/utils/data_utils.py +259 -0
- rainfallqc/utils/neighbourhood_utils.py +355 -0
- rainfallqc/utils/spatial_utils.py +40 -0
- rainfallqc/utils/stats.py +260 -0
- rainfallqc-0.0.9.dist-info/METADATA +201 -0
- rainfallqc-0.0.9.dist-info/RECORD +28 -0
- rainfallqc-0.0.9.dist-info/WHEEL +4 -0
- rainfallqc-0.0.9.dist-info/licenses/LICENSE +33 -0
rainfallqc/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Top-level package for RainfallQC."""
|
|
2
|
+
|
|
3
|
+
__author__ = """Tom Keel"""
|
|
4
|
+
__email__ = "tomkee@ceh.ac.uk"
|
|
5
|
+
__version__ = "0.0.9"
|
|
6
|
+
|
|
7
|
+
from rainfallqc.checks import (
|
|
8
|
+
comparison_checks,
|
|
9
|
+
gauge_checks,
|
|
10
|
+
neighbourhood_checks,
|
|
11
|
+
timeseries_checks,
|
|
12
|
+
)
|
|
13
|
+
from rainfallqc.qc_frameworks import apply_qc_framework
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"apply_qc_framework",
|
|
17
|
+
"comparison_checks",
|
|
18
|
+
"gauge_checks",
|
|
19
|
+
"neighbourhood_checks",
|
|
20
|
+
"timeseries_checks",
|
|
21
|
+
"apply_qc_framework",
|
|
22
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Quality checks."""
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Quality control checks relying on comparison with a benchmark dataset.
|
|
4
|
+
|
|
5
|
+
Comparison checks are defined as QC checks that: "detect abnormalities in rainfall record based on benchmarks."
|
|
6
|
+
|
|
7
|
+
Classes and functions ordered by appearance in IntenseQC framework.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import polars as pl
|
|
12
|
+
import xarray as xr
|
|
13
|
+
|
|
14
|
+
from rainfallqc.utils import data_readers, neighbourhood_utils, stats
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_annual_exceedance_etccdi_r99p(
|
|
18
|
+
data: pl.DataFrame, rain_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
19
|
+
) -> list:
|
|
20
|
+
"""
|
|
21
|
+
Check annual exceedance of maximum R99p from ETCCDI dataset.
|
|
22
|
+
|
|
23
|
+
This is QC8 from the IntenseQC framework.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
data :
|
|
28
|
+
Rainfall data
|
|
29
|
+
rain_col :
|
|
30
|
+
Column with rainfall data
|
|
31
|
+
gauge_lat :
|
|
32
|
+
latitude of the rain gauge
|
|
33
|
+
gauge_lon :
|
|
34
|
+
longitude of the rain gauge
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
flag_list :
|
|
39
|
+
List of flags
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
# 1. Load R99p data
|
|
43
|
+
etccdi_r99p = data_readers.load_etccdi_data(etccdi_var="R99p")
|
|
44
|
+
|
|
45
|
+
# 2. Get nearest local R99p value to the gauge coordinates
|
|
46
|
+
nearby_etccdi_r99p = neighbourhood_utils.get_nearest_etccdi_val_to_gauge(etccdi_r99p, gauge_lat, gauge_lon)
|
|
47
|
+
|
|
48
|
+
# 3. Get sum of rainfall above the 99th percentile per year
|
|
49
|
+
sum_rainfall_above_99percentile_per_year = get_sum_rainfall_above_percentile_per_year(
|
|
50
|
+
data, rain_col, percentile=0.99
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 4. Get flags of exceedance for R99p variable where the 0.99 percentile sum is more than ETCCDI max
|
|
54
|
+
exceedance_flags = flag_exceedance_of_max_etccdi_variable(
|
|
55
|
+
sum_rainfall_above_99percentile_per_year, rain_col, nearby_etccdi_r99p, etccdi_var="R99p"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return exceedance_flags
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_annual_exceedance_etccdi_prcptot(
|
|
62
|
+
data: pl.DataFrame, rain_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
63
|
+
) -> list:
|
|
64
|
+
"""
|
|
65
|
+
Check annual exceedance of maximum PRCPTOT from ETCCDI dataset.
|
|
66
|
+
|
|
67
|
+
This is QC9 from the IntenseQC framework.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
data :
|
|
72
|
+
Rainfall data
|
|
73
|
+
rain_col :
|
|
74
|
+
Column with rainfall data
|
|
75
|
+
gauge_lat :
|
|
76
|
+
latitude of the rain gauge
|
|
77
|
+
gauge_lon :
|
|
78
|
+
longitude of the rain gauge
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
exceedance_flags :
|
|
83
|
+
List of flags (see `exceedance_flagger` function)
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
# 1. Load PRCPTOT data
|
|
87
|
+
etccdi_prcptot = data_readers.load_etccdi_data(etccdi_var="PRCPTOT")
|
|
88
|
+
|
|
89
|
+
# 2. Get nearest local PRCPTOT value to the gauge coordinates
|
|
90
|
+
nearby_etccdi_prcptot = neighbourhood_utils.get_nearest_etccdi_val_to_gauge(etccdi_prcptot, gauge_lat, gauge_lon)
|
|
91
|
+
|
|
92
|
+
# 3. Get sum of rainfall above the 99th percentile per year
|
|
93
|
+
sum_rainfall_above_99percentile_per_year = get_sum_rainfall_above_percentile_per_year(
|
|
94
|
+
data, rain_col, percentile=0.99
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# 4. Get flags of exceedance for PRCPTOT variable where the 0.99 percentile sum is more than ETCCDI max
|
|
98
|
+
exceedance_flags = flag_exceedance_of_max_etccdi_variable(
|
|
99
|
+
sum_rainfall_above_99percentile_per_year, rain_col, nearby_etccdi_prcptot, etccdi_var="PRCPTOT"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return exceedance_flags
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_exceedance_of_rainfall_world_record(data: pl.DataFrame, rain_col: str, time_res: str) -> pl.DataFrame:
|
|
106
|
+
"""
|
|
107
|
+
Check exceedance of rainfall world record.
|
|
108
|
+
|
|
109
|
+
See Also `utils/stats.py` from world record sources.
|
|
110
|
+
|
|
111
|
+
This is QC10 from the IntenseQC framework.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
data :
|
|
116
|
+
Rainfall data
|
|
117
|
+
rain_col :
|
|
118
|
+
Column with rainfall data
|
|
119
|
+
time_res :
|
|
120
|
+
Time resolution
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
data_w_flags:
|
|
125
|
+
Rainfall data with exceedance of World Record (see `flag_exceedance_of_ref_val_as_col` function)
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
rainfall_world_records = stats.get_rainfall_world_records()
|
|
129
|
+
return flag_exceedance_of_ref_val_as_col(
|
|
130
|
+
data, rain_col, ref_val=rainfall_world_records[time_res], new_col_name="world_record_check"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_annual_exceedance_etccdi_rx1day(
|
|
135
|
+
data: pl.DataFrame, rain_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
136
|
+
) -> pl.DataFrame:
|
|
137
|
+
"""
|
|
138
|
+
Check exceedance of rainfall world record.
|
|
139
|
+
|
|
140
|
+
See Also `utils/stats.py` from world record sources.
|
|
141
|
+
|
|
142
|
+
This is QC11 from the IntenseQC framework.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
data :
|
|
147
|
+
Rainfall data
|
|
148
|
+
rain_col :
|
|
149
|
+
Column with rainfall data
|
|
150
|
+
gauge_lat :
|
|
151
|
+
latitude of the rain gauge
|
|
152
|
+
gauge_lon :
|
|
153
|
+
longitude of the rain gauge
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
data_w_flags:
|
|
158
|
+
Rainfall data with exceedance of Rx1day Record (see `flag_exceedance_of_ref_val_as_col` function)
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
# 1. Load Rx1day data
|
|
162
|
+
etccdi_rx1day = data_readers.load_etccdi_data(etccdi_var="Rx1day")
|
|
163
|
+
|
|
164
|
+
# 2. Get nearest local Rx1day value to the gauge coordinates
|
|
165
|
+
nearby_etccdi_rx1day = neighbourhood_utils.get_nearest_etccdi_val_to_gauge(etccdi_rx1day, gauge_lat, gauge_lon)
|
|
166
|
+
|
|
167
|
+
# 3. Get local maximum ETCCDI value
|
|
168
|
+
max_nearby_etccdi_rx1day = np.max(nearby_etccdi_rx1day["Rx1day"])
|
|
169
|
+
|
|
170
|
+
# 4. Flag exceedance of max ETCCDI value
|
|
171
|
+
return flag_exceedance_of_ref_val_as_col(
|
|
172
|
+
data, rain_col, ref_val=max_nearby_etccdi_rx1day, new_col_name="rx1day_check"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_sum_rainfall_above_percentile_per_year(
|
|
177
|
+
data: pl.DataFrame,
|
|
178
|
+
rain_col: str,
|
|
179
|
+
percentile: float,
|
|
180
|
+
) -> pl.DataFrame:
|
|
181
|
+
"""
|
|
182
|
+
Check annual exceedance of maximum PRCPTOT from ETCCDI dataset.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
data :
|
|
187
|
+
Rainfall data
|
|
188
|
+
rain_col :
|
|
189
|
+
Column with rainfall data
|
|
190
|
+
percentile :
|
|
191
|
+
nth percentile to check for values above
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
exceedance_flags :
|
|
196
|
+
List of flags (see `exceedance_flagger` function)
|
|
197
|
+
|
|
198
|
+
"""
|
|
199
|
+
# 1. Add a daily year column to the data
|
|
200
|
+
data = add_daily_year_col(data)
|
|
201
|
+
|
|
202
|
+
# 2. Calculate percentiles
|
|
203
|
+
data_percentiles = data.group_by("year").agg(
|
|
204
|
+
pl.col(rain_col).fill_nan(0.0).quantile(percentile).alias("percentile")
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# 3. Join percentiles back to the main DataFrame
|
|
208
|
+
data_yearly_percentiles = data.join(data_percentiles, on="year").fill_nan(0.0)
|
|
209
|
+
|
|
210
|
+
# 4. Filter values above the nth percentile
|
|
211
|
+
data_above_annual_percentile = data_yearly_percentiles.filter(pl.col(rain_col) > pl.col("percentile"))
|
|
212
|
+
|
|
213
|
+
# 5. Get number of values per year above nth percentile
|
|
214
|
+
sum_rainfall_above_percentile = data_above_annual_percentile.group_by_dynamic("time", every="1y").agg(
|
|
215
|
+
pl.col(rain_col).sum()
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return sum_rainfall_above_percentile
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def flag_exceedance_of_max_etccdi_variable(
|
|
222
|
+
annual_sum_rainfall: pl.DataFrame, rain_col: str, nearby_etccdi_data: xr.Dataset, etccdi_var: str
|
|
223
|
+
) -> list:
|
|
224
|
+
"""
|
|
225
|
+
Flag exceedance of maximum ETCCDI variable, comparing the maximum sums of each year.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
annual_sum_rainfall :
|
|
230
|
+
Rainfall data as by year sums
|
|
231
|
+
rain_col :
|
|
232
|
+
Column with rainfall data
|
|
233
|
+
nearby_etccdi_data :
|
|
234
|
+
ETCCDI data with given variable to check
|
|
235
|
+
etccdi_var :
|
|
236
|
+
variable to load from ETCCDI
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
exceedance_flags :
|
|
241
|
+
Flags of exceedances of max ETCCDI value
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
# 1. Get local maximum ETCCDI value
|
|
245
|
+
etccdi_var_max = np.max(nearby_etccdi_data[etccdi_var])
|
|
246
|
+
|
|
247
|
+
# 2. Get flags.
|
|
248
|
+
exceedance_flags = [
|
|
249
|
+
flag_exceedance_of_ref_val(val=yr, ref_val=etccdi_var_max) for yr in annual_sum_rainfall[rain_col]
|
|
250
|
+
]
|
|
251
|
+
return exceedance_flags
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def add_daily_year_col(data: pl.DataFrame) -> pl.DataFrame:
|
|
255
|
+
"""
|
|
256
|
+
Make a year column for the data. This method will first upsample data so that it is every day.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
data :
|
|
261
|
+
Rainfall data
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
data_w_year_col :
|
|
266
|
+
Rainfall data with year column
|
|
267
|
+
|
|
268
|
+
"""
|
|
269
|
+
data_daily_upsample = data.upsample("time", every="1d")
|
|
270
|
+
return data_daily_upsample.with_columns(pl.col("time").dt.year().alias("year"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def flag_exceedance_of_ref_val(val: int | float, ref_val: int | float) -> int:
|
|
274
|
+
"""
|
|
275
|
+
Exceedance flagger from intenseqc.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
val :
|
|
280
|
+
Value to check
|
|
281
|
+
ref_val :
|
|
282
|
+
Reference value to compare against
|
|
283
|
+
|
|
284
|
+
Returns
|
|
285
|
+
-------
|
|
286
|
+
Flag :
|
|
287
|
+
Exceedance flag
|
|
288
|
+
|
|
289
|
+
"""
|
|
290
|
+
if val is None or np.isnan(val):
|
|
291
|
+
return np.nan
|
|
292
|
+
elif val >= ref_val * 1.5:
|
|
293
|
+
return 4
|
|
294
|
+
elif val >= ref_val * 1.33:
|
|
295
|
+
return 3
|
|
296
|
+
elif val >= ref_val * 1.2:
|
|
297
|
+
return 2
|
|
298
|
+
elif val >= ref_val:
|
|
299
|
+
return 1
|
|
300
|
+
else:
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def flag_exceedance_of_ref_val_as_col(
|
|
305
|
+
data: pl.DataFrame, rain_col: str, ref_val: int | float, new_col_name: str
|
|
306
|
+
) -> pl.DataFrame:
|
|
307
|
+
"""
|
|
308
|
+
Flag exceedance of maximum reference value and return as column.
|
|
309
|
+
|
|
310
|
+
Used in QC11 of the IntenseQC framework. TODO: could this be used in QC8+9?
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
data :
|
|
315
|
+
Rainfall data.
|
|
316
|
+
rain_col :
|
|
317
|
+
Column with rainfall data
|
|
318
|
+
ref_val :
|
|
319
|
+
Reference value.
|
|
320
|
+
new_col_name :
|
|
321
|
+
New column name.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
data :
|
|
326
|
+
Data with exceedance flags between 0-4.
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
return data.with_columns(
|
|
330
|
+
pl.when(pl.col(rain_col).is_null() | pl.col(rain_col).is_nan())
|
|
331
|
+
.then(np.nan)
|
|
332
|
+
.when(pl.col(rain_col) >= ref_val * 1.5)
|
|
333
|
+
.then(4)
|
|
334
|
+
.when(pl.col(rain_col) >= ref_val * 1.33)
|
|
335
|
+
.then(3)
|
|
336
|
+
.when(pl.col(rain_col) >= ref_val * 1.2)
|
|
337
|
+
.then(2)
|
|
338
|
+
.when(pl.col(rain_col) >= ref_val)
|
|
339
|
+
.then(1)
|
|
340
|
+
.otherwise(0)
|
|
341
|
+
.alias(new_col_name)
|
|
342
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Quality control checks examining suspicious rain gauges.
|
|
4
|
+
|
|
5
|
+
Gauge checks are defined as QC checks that: "detect abnormalities in summary and descriptive statistics of rain gauges."
|
|
6
|
+
|
|
7
|
+
Classes and functions ordered by appearance in IntenseQC framework.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
import scipy.stats
|
|
12
|
+
|
|
13
|
+
from rainfallqc.utils import data_utils, stats
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_years_where_nth_percentile_is_zero(data: pl.DataFrame, rain_col: str, quantile: float) -> list:
|
|
17
|
+
"""
|
|
18
|
+
Return years where the n-th percentiles is zero.
|
|
19
|
+
|
|
20
|
+
This is QC1 from the IntenseQC framework
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
data :
|
|
25
|
+
Rainfall data
|
|
26
|
+
rain_col :
|
|
27
|
+
Column with rainfall data
|
|
28
|
+
quantile :
|
|
29
|
+
Between 0 & 1
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
year_list :
|
|
34
|
+
List of years where n-th percentile is zero.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
nth_perc = data.group_by_dynamic("time", every="1y").agg(pl.quantile(rain_col, quantile))
|
|
38
|
+
return nth_perc.filter(pl.col(rain_col) == 0)["time"].dt.year().to_list()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_years_where_annual_mean_k_top_rows_are_zero(data: pl.DataFrame, rain_col: str, k: int) -> list:
|
|
42
|
+
"""
|
|
43
|
+
Return year list where the annual mean top-K rows are zero.
|
|
44
|
+
|
|
45
|
+
This is QC2 from the IntenseQC framework
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
data :
|
|
50
|
+
Rainfall data
|
|
51
|
+
rain_col :
|
|
52
|
+
Column with rainfall data
|
|
53
|
+
k :
|
|
54
|
+
Number of top values check i.e. k==5 is top 5
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
year_list :
|
|
59
|
+
List of years where k-largest are zero.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
data_top_k = data.group_by_dynamic("time", every="1y").agg(pl.col(rain_col).top_k(k).min())
|
|
63
|
+
return data_top_k.filter(pl.col(rain_col) == 0)["time"].dt.year().to_list()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_temporal_bias(
|
|
67
|
+
data: pl.DataFrame,
|
|
68
|
+
rain_col: str,
|
|
69
|
+
time_granularity: str,
|
|
70
|
+
p_threshold: float = 0.01,
|
|
71
|
+
) -> int:
|
|
72
|
+
"""
|
|
73
|
+
Perform a two-sided t-test on the distribution of mean rainfall over time slices.
|
|
74
|
+
|
|
75
|
+
This is QC3 (day of week bias) and QC4 (hour-of-day bias) from the IntenseQC framework.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
data :
|
|
80
|
+
Rainfall data
|
|
81
|
+
rain_col :
|
|
82
|
+
Column with rainfall data
|
|
83
|
+
time_granularity :
|
|
84
|
+
Temporal grouping, either 'weekday' or 'hour'
|
|
85
|
+
p_threshold :
|
|
86
|
+
Significance level for the test
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
flag : int
|
|
91
|
+
1 if bias is detected (p < threshold), 0 otherwise
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
if time_granularity == "weekday":
|
|
95
|
+
time_group = pl.col("time").dt.weekday()
|
|
96
|
+
elif time_granularity == "hour":
|
|
97
|
+
time_group = pl.col("time").dt.hour()
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError("time_granularity must be either 'weekday' or 'hour'")
|
|
100
|
+
|
|
101
|
+
# 1. Get time-average mean
|
|
102
|
+
grouped_means = data.group_by(time_group).agg(pl.col(rain_col).drop_nans().mean())[rain_col]
|
|
103
|
+
|
|
104
|
+
# 2. Get data mean
|
|
105
|
+
overall_mean = data[rain_col].drop_nans().mean()
|
|
106
|
+
|
|
107
|
+
# 3. Compute 1-sample t-test
|
|
108
|
+
_, p_val = scipy.stats.ttest_1samp(grouped_means, overall_mean)
|
|
109
|
+
return int(p_val < p_threshold)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_intermittency(
|
|
113
|
+
data: pl.DataFrame, rain_col: str, no_data_threshold: int = 2, annual_count_threshold: int = 5
|
|
114
|
+
) -> list:
|
|
115
|
+
"""
|
|
116
|
+
Return years where more than five periods of missing data are bounded by zeros.
|
|
117
|
+
|
|
118
|
+
TODO: split into multiple sub-functions and write more tests!
|
|
119
|
+
This is QC5 from the IntenseQC framework.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
data :
|
|
124
|
+
Rainfall data
|
|
125
|
+
rain_col :
|
|
126
|
+
Column with rainfall data
|
|
127
|
+
no_data_threshold :
|
|
128
|
+
Number of missing values needed to be counted as a no data period (default: 2 (days))
|
|
129
|
+
annual_count_threshold :
|
|
130
|
+
Number of missing data periods above no_data_threshold per year (default: 5)
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
years_w_intermittency :
|
|
135
|
+
List of years with intermittency issues.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
# 1. Identify missing values
|
|
139
|
+
data = data_utils.replace_missing_vals_with_nan(data, rain_col) # drops None by default
|
|
140
|
+
missing_vals_mask = data[rain_col].is_nan()
|
|
141
|
+
data = data.with_columns(missing_vals_mask.alias("is_missing"))
|
|
142
|
+
|
|
143
|
+
# 2. Identify group numbers for consecutive nulls
|
|
144
|
+
gauge_data_missing_groups = data.with_columns(
|
|
145
|
+
(pl.when(data["is_missing"]).then((~data["is_missing"]).cum_sum()).otherwise(None)).alias("group")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# 3. Get length of groups of missing data
|
|
149
|
+
gauge_data_missing_group_counts = gauge_data_missing_groups.group_by("group").agg(
|
|
150
|
+
pl.col("is_missing").sum().alias("count")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# 4. Get groups with missing values above or at the `no_data_threshold`
|
|
154
|
+
no_data_period_groups = gauge_data_missing_group_counts.filter(pl.col("count") >= no_data_threshold)["group"]
|
|
155
|
+
|
|
156
|
+
# 5. Select rows belonging to 'no data periods'
|
|
157
|
+
gauge_data_no_data_periods = gauge_data_missing_groups.filter(
|
|
158
|
+
pl.col("group").is_in(no_data_period_groups.to_list())
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 6. Get annual counts of no data periods
|
|
162
|
+
gauge_data_year_counts = gauge_data_no_data_periods.select(pl.col("time").dt.year()).to_series().value_counts()
|
|
163
|
+
|
|
164
|
+
# 7. Filter out years above or at the threshold of `annual_count_threshold`
|
|
165
|
+
years_w_intermittency = gauge_data_year_counts.filter(pl.col("count") >= annual_count_threshold)["time"].to_list()
|
|
166
|
+
return years_w_intermittency
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_breakpoints(
|
|
170
|
+
data: pl.DataFrame,
|
|
171
|
+
rain_col: str,
|
|
172
|
+
p_threshold: float = 0.01,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""
|
|
175
|
+
Use a Pettitt test rainfall data to check for breakpoints.
|
|
176
|
+
|
|
177
|
+
This is QC6 from the IntenseQC framework.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
data :
|
|
182
|
+
Rainfall data.
|
|
183
|
+
rain_col :
|
|
184
|
+
Column with rainfall data.
|
|
185
|
+
p_threshold :
|
|
186
|
+
Significance level for the test.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
flag : int
|
|
191
|
+
1 if breakpoint is detected (p < p_threshold), 0 otherwise
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
# 1. Upsample data to daily
|
|
195
|
+
data_upsampled = data.upsample("time", every="1d")
|
|
196
|
+
|
|
197
|
+
# 2. Compute Pettitt test for breakpoints
|
|
198
|
+
_, p_val = stats.pettitt_test(data_upsampled[rain_col].fill_nan(0.0))
|
|
199
|
+
if p_val < p_threshold:
|
|
200
|
+
return 1
|
|
201
|
+
else:
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_min_val_change(data: pl.DataFrame, rain_col: str, expected_min_val: float) -> list:
|
|
206
|
+
"""
|
|
207
|
+
Return years when the minimum recorded value changes.
|
|
208
|
+
|
|
209
|
+
Used to determine whether there are possible changes to the measuring equipment.
|
|
210
|
+
This is QC7 from the IntenseQC framework.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
data :
|
|
215
|
+
Rainfall data
|
|
216
|
+
rain_col :
|
|
217
|
+
Column with rainfall data.
|
|
218
|
+
expected_min_val :
|
|
219
|
+
Expected value of rainfall i.e. basically the resolution of data.
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
yr_list :
|
|
224
|
+
List of years with minimum value changes.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
# 1. Filter out non-zero years
|
|
228
|
+
data_non_zero = data.filter(pl.col(rain_col) > 0)
|
|
229
|
+
|
|
230
|
+
# 2. Get minimum value each year
|
|
231
|
+
data_min_by_year = data_non_zero.group_by_dynamic(pl.col("time"), every="1y").agg(pl.col(rain_col).min())
|
|
232
|
+
|
|
233
|
+
non_res_years = data_min_by_year.filter(pl.col(rain_col) != expected_min_val)
|
|
234
|
+
return non_res_years["time"].dt.year().to_list()
|