rainfallqc 0.2.1__tar.gz → 0.2.2__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.
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/PKG-INFO +1 -1
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/pyproject.toml +1 -1
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/__init__.py +3 -2
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/comparison_checks.py +14 -3
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/gauge_checks.py +24 -13
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/neighbourhood_checks.py +9 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/timeseries_checks.py +11 -2
- rainfallqc-0.2.2/src/rainfallqc/core/all_qc_checks.py +98 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/apply_qc_framework.py +0 -1
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/data_readers.py +1 -1
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/data_utils.py +24 -1
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/neighbourhood_utils.py +87 -24
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/spatial_utils.py +32 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/LICENSE +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/README.rst +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/pypwsqc_filters.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/core/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_CDD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_CWD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_PRCPTOT_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_R99p_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_Rx1day_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/RawData_HADEX2_SDII_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/inbuilt_qc_frameworks.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/__init__.py +0 -0
- {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/stats.py +0 -0
|
@@ -68,7 +68,7 @@ homepage = "https://github.com/thomasjkeel/RainfallQC"
|
|
|
68
68
|
target-version = ["py310", "py311", "py312", "py313"]
|
|
69
69
|
|
|
70
70
|
[tool.bumpversion]
|
|
71
|
-
current_version = "0.2.
|
|
71
|
+
current_version = "0.2.2"
|
|
72
72
|
commit = true
|
|
73
73
|
message = "Bump version: {current_version} → {new_version}"
|
|
74
74
|
commit_args = ""
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
__author__ = """Tom Keel"""
|
|
4
4
|
__email__ = "tomkee@ceh.ac.uk"
|
|
5
|
-
__version__ = "0.2.
|
|
5
|
+
__version__ = "0.2.2"
|
|
6
6
|
|
|
7
|
-
from rainfallqc import utils
|
|
7
|
+
from rainfallqc import core, utils
|
|
8
8
|
from rainfallqc.checks import (
|
|
9
9
|
comparison_checks,
|
|
10
10
|
gauge_checks,
|
|
@@ -19,5 +19,6 @@ __all__ = [
|
|
|
19
19
|
"gauge_checks",
|
|
20
20
|
"neighbourhood_checks",
|
|
21
21
|
"timeseries_checks",
|
|
22
|
+
"core",
|
|
22
23
|
"utils",
|
|
23
24
|
]
|
|
@@ -11,9 +11,11 @@ import numpy as np
|
|
|
11
11
|
import polars as pl
|
|
12
12
|
import xarray as xr
|
|
13
13
|
|
|
14
|
+
from rainfallqc.core.all_qc_checks import qc_check
|
|
14
15
|
from rainfallqc.utils import data_readers, data_utils, neighbourhood_utils, stats
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
@qc_check("check_annual_exceedance_etccdi_r99p", require_non_negative=True)
|
|
17
19
|
def check_annual_exceedance_etccdi_r99p(
|
|
18
20
|
data: pl.DataFrame, target_gauge_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
19
21
|
) -> list:
|
|
@@ -43,7 +45,9 @@ def check_annual_exceedance_etccdi_r99p(
|
|
|
43
45
|
etccdi_r99p = data_readers.load_etccdi_data(etccdi_var="R99p")
|
|
44
46
|
|
|
45
47
|
# 2. Get nearest local R99p value to the gauge coordinates
|
|
46
|
-
nearby_etccdi_r99p = neighbourhood_utils.
|
|
48
|
+
nearby_etccdi_r99p = neighbourhood_utils.get_nearest_non_nan_etccdi_val_to_gauge(
|
|
49
|
+
etccdi_r99p, etccdi_name="R99p", gauge_lat=gauge_lat, gauge_lon=gauge_lon
|
|
50
|
+
)
|
|
47
51
|
|
|
48
52
|
# 3. Get sum of rainfall above the 99th percentile per year
|
|
49
53
|
sum_rainfall_above_99percentile_per_year = get_sum_rainfall_above_percentile_per_year(
|
|
@@ -58,6 +62,7 @@ def check_annual_exceedance_etccdi_r99p(
|
|
|
58
62
|
return exceedance_flags
|
|
59
63
|
|
|
60
64
|
|
|
65
|
+
@qc_check("check_annual_exceedance_etccdi_prcptot", require_non_negative=True)
|
|
61
66
|
def check_annual_exceedance_etccdi_prcptot(
|
|
62
67
|
data: pl.DataFrame, target_gauge_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
63
68
|
) -> list:
|
|
@@ -87,7 +92,9 @@ def check_annual_exceedance_etccdi_prcptot(
|
|
|
87
92
|
etccdi_prcptot = data_readers.load_etccdi_data(etccdi_var="PRCPTOT")
|
|
88
93
|
|
|
89
94
|
# 2. Get nearest local PRCPTOT value to the gauge coordinates
|
|
90
|
-
nearby_etccdi_prcptot = neighbourhood_utils.
|
|
95
|
+
nearby_etccdi_prcptot = neighbourhood_utils.get_nearest_non_nan_etccdi_val_to_gauge(
|
|
96
|
+
etccdi_prcptot, etccdi_name="PRCPTOT", gauge_lat=gauge_lat, gauge_lon=gauge_lon
|
|
97
|
+
)
|
|
91
98
|
|
|
92
99
|
# 3. Get sum of rainfall above the 99th percentile per year
|
|
93
100
|
sum_rainfall_above_99percentile_per_year = get_sum_rainfall_above_percentile_per_year(
|
|
@@ -102,6 +109,7 @@ def check_annual_exceedance_etccdi_prcptot(
|
|
|
102
109
|
return exceedance_flags
|
|
103
110
|
|
|
104
111
|
|
|
112
|
+
@qc_check("check_exceedance_of_rainfall_world_record", require_non_negative=True)
|
|
105
113
|
def check_exceedance_of_rainfall_world_record(data: pl.DataFrame, target_gauge_col: str, time_res: str) -> pl.DataFrame:
|
|
106
114
|
"""
|
|
107
115
|
Check exceedance of rainfall world record.
|
|
@@ -135,6 +143,7 @@ def check_exceedance_of_rainfall_world_record(data: pl.DataFrame, target_gauge_c
|
|
|
135
143
|
return data_w_flags.select(["time", "world_record_check"])
|
|
136
144
|
|
|
137
145
|
|
|
146
|
+
@qc_check("check_hourly_exceedance_etccdi_rx1day", require_non_negative=True)
|
|
138
147
|
def check_hourly_exceedance_etccdi_rx1day(
|
|
139
148
|
data: pl.DataFrame, target_gauge_col: str, gauge_lat: int | float, gauge_lon: int | float
|
|
140
149
|
) -> pl.DataFrame:
|
|
@@ -173,7 +182,9 @@ def check_hourly_exceedance_etccdi_rx1day(
|
|
|
173
182
|
etccdi_rx1day = data_readers.load_etccdi_data(etccdi_var="Rx1day")
|
|
174
183
|
|
|
175
184
|
# 3. Get nearest local Rx1day value to the gauge coordinates
|
|
176
|
-
nearby_etccdi_rx1day = neighbourhood_utils.
|
|
185
|
+
nearby_etccdi_rx1day = neighbourhood_utils.get_nearest_non_nan_etccdi_val_to_gauge(
|
|
186
|
+
etccdi_rx1day, etccdi_name="Rx1day", gauge_lat=gauge_lat, gauge_lon=gauge_lon
|
|
187
|
+
)
|
|
177
188
|
|
|
178
189
|
# 4. Get local maximum ETCCDI value
|
|
179
190
|
max_nearby_etccdi_rx1day = np.max(nearby_etccdi_rx1day["Rx1day"])
|
|
@@ -10,9 +10,11 @@ Classes and functions ordered by appearance in IntenseQC framework.
|
|
|
10
10
|
import polars as pl
|
|
11
11
|
import scipy.stats
|
|
12
12
|
|
|
13
|
+
from rainfallqc.core.all_qc_checks import qc_check
|
|
13
14
|
from rainfallqc.utils import data_utils, stats
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@qc_check("check_years_where_nth_percentile_is_zero", require_non_negative=True)
|
|
16
18
|
def check_years_where_nth_percentile_is_zero(data: pl.DataFrame, target_gauge_col: str, quantile: float) -> list:
|
|
17
19
|
"""
|
|
18
20
|
Return years where the n-th percentiles is zero.
|
|
@@ -38,6 +40,7 @@ def check_years_where_nth_percentile_is_zero(data: pl.DataFrame, target_gauge_co
|
|
|
38
40
|
return nth_perc.filter(pl.col(target_gauge_col) == 0)["time"].dt.year().to_list()
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
@qc_check("check_years_where_annual_mean_k_top_rows_are_zero", require_non_negative=True)
|
|
41
44
|
def check_years_where_annual_mean_k_top_rows_are_zero(data: pl.DataFrame, target_gauge_col: str, k: int) -> list:
|
|
42
45
|
"""
|
|
43
46
|
Return year list where the annual mean top-K rows are zero.
|
|
@@ -63,6 +66,7 @@ def check_years_where_annual_mean_k_top_rows_are_zero(data: pl.DataFrame, target
|
|
|
63
66
|
return data_top_k.filter(pl.col(target_gauge_col) == 0)["time"].dt.year().to_list()
|
|
64
67
|
|
|
65
68
|
|
|
69
|
+
@qc_check("check_temporal_bias", require_non_negative=True)
|
|
66
70
|
def check_temporal_bias(
|
|
67
71
|
data: pl.DataFrame,
|
|
68
72
|
target_gauge_col: str,
|
|
@@ -109,6 +113,7 @@ def check_temporal_bias(
|
|
|
109
113
|
return int(p_val < p_threshold)
|
|
110
114
|
|
|
111
115
|
|
|
116
|
+
@qc_check("check_intermittency", require_non_negative=True)
|
|
112
117
|
def check_intermittency(
|
|
113
118
|
data: pl.DataFrame, target_gauge_col: str, no_data_threshold: int = 2, annual_count_threshold: int = 5
|
|
114
119
|
) -> list:
|
|
@@ -135,20 +140,24 @@ def check_intermittency(
|
|
|
135
140
|
List of years with intermittency issues.
|
|
136
141
|
|
|
137
142
|
"""
|
|
138
|
-
# 1.
|
|
143
|
+
# 1. Check data has consistent time step
|
|
144
|
+
data_utils.check_data_has_consistent_time_step(data)
|
|
145
|
+
|
|
146
|
+
# 2. Replace missing values with NaN
|
|
139
147
|
data = data_utils.replace_missing_vals_with_nan(data, target_gauge_col)
|
|
140
148
|
|
|
141
|
-
#
|
|
149
|
+
# 3. Mark missing values
|
|
142
150
|
data = data.with_columns(pl.col(target_gauge_col).is_nan().alias("is_missing"))
|
|
143
151
|
|
|
144
|
-
#
|
|
152
|
+
# 4. Assign group numbers to consecutive missing values
|
|
145
153
|
grouped = data.with_columns(
|
|
146
154
|
pl.when(pl.col("is_missing"))
|
|
147
155
|
.then((~pl.col("is_missing")).cum_sum()) # Only number missing stretches
|
|
148
156
|
.otherwise(None)
|
|
149
157
|
.alias("group")
|
|
150
158
|
)
|
|
151
|
-
|
|
159
|
+
|
|
160
|
+
# 5. Count size of each missing group
|
|
152
161
|
group_counts = (
|
|
153
162
|
grouped.filter(pl.col("is_missing"))
|
|
154
163
|
.group_by("group")
|
|
@@ -156,21 +165,21 @@ def check_intermittency(
|
|
|
156
165
|
.filter(pl.col("count") >= no_data_threshold)
|
|
157
166
|
)
|
|
158
167
|
|
|
159
|
-
#
|
|
168
|
+
# 6. Keep only valid groups
|
|
160
169
|
valid_missing_groups = group_counts["group"].to_list()
|
|
161
170
|
valid_missing = grouped.filter(pl.col("group").is_in(valid_missing_groups))
|
|
162
171
|
|
|
163
|
-
#
|
|
172
|
+
# 7. Get first and last time for each group
|
|
164
173
|
first_last = (
|
|
165
174
|
valid_missing.sort("time")
|
|
166
175
|
.group_by("group")
|
|
167
176
|
.agg([pl.first("time").alias("start_time"), pl.last("time").alias("end_time")])
|
|
168
177
|
)
|
|
169
178
|
|
|
170
|
-
#
|
|
179
|
+
# 8. Add stable row index to full sorted data
|
|
171
180
|
df_sorted = data.sort("time").with_row_index("row_idx")
|
|
172
181
|
|
|
173
|
-
#
|
|
182
|
+
# 9. Lookup row indices of group bounds
|
|
174
183
|
first_last_with_idx = (
|
|
175
184
|
first_last.join(df_sorted.select(["time", "row_idx"]), left_on="start_time", right_on="time")
|
|
176
185
|
.rename({"row_idx": "start_idx"})
|
|
@@ -178,7 +187,7 @@ def check_intermittency(
|
|
|
178
187
|
.rename({"row_idx": "end_idx"})
|
|
179
188
|
)
|
|
180
189
|
|
|
181
|
-
#
|
|
190
|
+
# 10. Compute prev/next indices and fetch values
|
|
182
191
|
row_lookup = df_sorted.select([pl.col("row_idx"), pl.col(target_gauge_col)])
|
|
183
192
|
|
|
184
193
|
first_last_with_vals = (
|
|
@@ -189,21 +198,22 @@ def check_intermittency(
|
|
|
189
198
|
.join(row_lookup.rename({target_gauge_col: "next_val"}), left_on="next_idx", right_on="row_idx")
|
|
190
199
|
)
|
|
191
200
|
|
|
192
|
-
#
|
|
201
|
+
# 11. Filter to groups bounded by zero
|
|
193
202
|
bounded_by_zero = first_last_with_vals.filter((pl.col("prev_val") == 0) & (pl.col("next_val") == 0))
|
|
194
203
|
|
|
195
|
-
#
|
|
204
|
+
# 12. Extract year from each bounded group's start time
|
|
196
205
|
bounded_years = bounded_by_zero.select(pl.col("start_time").dt.year().alias("year"))
|
|
197
206
|
|
|
198
|
-
#
|
|
207
|
+
# 13. Count how many bounded groups occur per year
|
|
199
208
|
year_counts = bounded_years.group_by("year").len()
|
|
200
209
|
|
|
201
|
-
#
|
|
210
|
+
# 14. Get years exceeding threshold
|
|
202
211
|
years_w_intermittency = year_counts.filter(pl.col("len") >= annual_count_threshold)["year"].to_list()
|
|
203
212
|
|
|
204
213
|
return years_w_intermittency
|
|
205
214
|
|
|
206
215
|
|
|
216
|
+
@qc_check("check_breakpoints", require_non_negative=True)
|
|
207
217
|
def check_breakpoints(
|
|
208
218
|
data: pl.DataFrame,
|
|
209
219
|
target_gauge_col: str,
|
|
@@ -240,6 +250,7 @@ def check_breakpoints(
|
|
|
240
250
|
return 0
|
|
241
251
|
|
|
242
252
|
|
|
253
|
+
@qc_check("check_min_val_change", require_non_negative=True)
|
|
243
254
|
def check_min_val_change(data: pl.DataFrame, target_gauge_col: str, expected_min_val: float) -> list:
|
|
244
255
|
"""
|
|
245
256
|
Return years when the minimum recorded value changes.
|
|
@@ -12,9 +12,11 @@ from typing import Iterable, List
|
|
|
12
12
|
import numpy as np
|
|
13
13
|
import polars as pl
|
|
14
14
|
|
|
15
|
+
from rainfallqc.core.all_qc_checks import qc_check
|
|
15
16
|
from rainfallqc.utils import data_readers, data_utils, neighbourhood_utils, stats
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
@qc_check("check_wet_neighbours", require_non_negative=True)
|
|
18
20
|
def check_wet_neighbours(
|
|
19
21
|
neighbour_data: pl.DataFrame,
|
|
20
22
|
target_gauge_col: str,
|
|
@@ -129,6 +131,7 @@ def check_wet_neighbours(
|
|
|
129
131
|
return neighbour_data_w_wet_flags
|
|
130
132
|
|
|
131
133
|
|
|
134
|
+
@qc_check("check_dry_neighbours", require_non_negative=True)
|
|
132
135
|
def check_dry_neighbours(
|
|
133
136
|
neighbour_data: pl.DataFrame,
|
|
134
137
|
target_gauge_col: str,
|
|
@@ -261,6 +264,7 @@ def check_dry_neighbours(
|
|
|
261
264
|
return neighbour_data_w_dry_flags
|
|
262
265
|
|
|
263
266
|
|
|
267
|
+
@qc_check("check_monthly_neighbours", require_non_negative=True)
|
|
264
268
|
def check_monthly_neighbours(
|
|
265
269
|
monthly_neighbour_data: pl.DataFrame,
|
|
266
270
|
target_gauge_col: str,
|
|
@@ -355,6 +359,7 @@ def check_monthly_neighbours(
|
|
|
355
359
|
return monthly_neighbour_data_w_flags.select(["time", "majority_monthly_flag"])
|
|
356
360
|
|
|
357
361
|
|
|
362
|
+
@qc_check("check_timing_offset", require_non_negative=True)
|
|
358
363
|
def check_timing_offset(
|
|
359
364
|
neighbour_data: pl.DataFrame,
|
|
360
365
|
target_gauge_col: str,
|
|
@@ -436,6 +441,7 @@ def check_timing_offset(
|
|
|
436
441
|
return offset_flag
|
|
437
442
|
|
|
438
443
|
|
|
444
|
+
@qc_check("check_neighbour_affinity_index", require_non_negative=True)
|
|
439
445
|
def check_neighbour_affinity_index(
|
|
440
446
|
neighbour_data: pl.DataFrame, target_gauge_col: str, neighbouring_gauge_col: str
|
|
441
447
|
) -> float:
|
|
@@ -471,6 +477,7 @@ def check_neighbour_affinity_index(
|
|
|
471
477
|
return stats.affinity_index(neighbour_data, binary_col="rain_not_minima")
|
|
472
478
|
|
|
473
479
|
|
|
480
|
+
@qc_check("check_neighbour_correlation", require_non_negative=True)
|
|
474
481
|
def check_neighbour_correlation(
|
|
475
482
|
neighbour_data: pl.DataFrame, target_gauge_col: str, neighbouring_gauge_col: str
|
|
476
483
|
) -> float:
|
|
@@ -501,6 +508,7 @@ def check_neighbour_correlation(
|
|
|
501
508
|
return stats.gauge_correlation(neighbour_data, target_col=target_gauge_col, other_col=neighbouring_gauge_col)
|
|
502
509
|
|
|
503
510
|
|
|
511
|
+
@qc_check("check_daily_factor", require_non_negative=True)
|
|
504
512
|
def check_daily_factor(
|
|
505
513
|
neighbour_data: pl.DataFrame, target_gauge_col: str, neighbouring_gauge_col: str, averaging_method: str = "mean"
|
|
506
514
|
) -> float:
|
|
@@ -553,6 +561,7 @@ def check_daily_factor(
|
|
|
553
561
|
raise ValueError(f"{averaging_method} not recognised, please use 'mean' or 'median'")
|
|
554
562
|
|
|
555
563
|
|
|
564
|
+
@qc_check("check_monthly_factor", require_non_negative=True)
|
|
556
565
|
def check_monthly_factor(
|
|
557
566
|
neighbour_data: pl.DataFrame, target_gauge_col: str, neighbouring_gauge_col: str
|
|
558
567
|
) -> pl.DataFrame:
|
|
@@ -11,11 +11,13 @@ import numpy as np
|
|
|
11
11
|
import polars as pl
|
|
12
12
|
import xarray as xr
|
|
13
13
|
|
|
14
|
+
from rainfallqc.core.all_qc_checks import qc_check
|
|
14
15
|
from rainfallqc.utils import data_readers, data_utils, neighbourhood_utils, spatial_utils, stats
|
|
15
16
|
|
|
16
17
|
DAILY_DIVIDING_FACTOR = {"15m": 96, "hourly": 24, "daily": 1}
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
@qc_check("check_dry_period_cdd", require_non_negative=True)
|
|
19
21
|
def check_dry_period_cdd(
|
|
20
22
|
data: pl.DataFrame, target_gauge_col: str, time_res: str, gauge_lat: int | float, gauge_lon: int | float
|
|
21
23
|
) -> pl.DataFrame:
|
|
@@ -53,7 +55,9 @@ def check_dry_period_cdd(
|
|
|
53
55
|
etccdi_cdd_days = compute_dry_spell_days(etccdi_cdd)
|
|
54
56
|
|
|
55
57
|
# 3. Get nearest local CDD value to the gauge coordinates
|
|
56
|
-
nearby_etccdi_cdd_days = neighbourhood_utils.
|
|
58
|
+
nearby_etccdi_cdd_days = neighbourhood_utils.get_nearest_non_nan_etccdi_val_to_gauge(
|
|
59
|
+
etccdi_cdd_days, etccdi_name="CDD", gauge_lat=gauge_lat, gauge_lon=gauge_lon
|
|
60
|
+
)
|
|
57
61
|
|
|
58
62
|
# 4. Get local maximum CDD_days value
|
|
59
63
|
max_etccdi_cdd_days = np.max(nearby_etccdi_cdd_days["CDD_days"])
|
|
@@ -74,6 +78,7 @@ def check_dry_period_cdd(
|
|
|
74
78
|
return data_w_dry_spell_flags.select(["time", "dry_spell_flag"])
|
|
75
79
|
|
|
76
80
|
|
|
81
|
+
@qc_check("check_daily_accumulations", require_non_negative=True)
|
|
77
82
|
def check_daily_accumulations(
|
|
78
83
|
data: pl.DataFrame,
|
|
79
84
|
target_gauge_col: str,
|
|
@@ -141,6 +146,7 @@ def check_daily_accumulations(
|
|
|
141
146
|
return data.select(["time", "daily_accumulation"])
|
|
142
147
|
|
|
143
148
|
|
|
149
|
+
@qc_check("check_monthly_accumulations", require_non_negative=True)
|
|
144
150
|
def check_monthly_accumulations(
|
|
145
151
|
data: pl.DataFrame,
|
|
146
152
|
target_gauge_col: str,
|
|
@@ -223,6 +229,7 @@ def check_monthly_accumulations(
|
|
|
223
229
|
return gauge_data_monthly_accumulations.select(["time", "monthly_accumulation"])
|
|
224
230
|
|
|
225
231
|
|
|
232
|
+
@qc_check("check_streaks", require_non_negative=True)
|
|
226
233
|
def check_streaks(
|
|
227
234
|
data: pl.DataFrame,
|
|
228
235
|
target_gauge_col: str,
|
|
@@ -681,7 +688,9 @@ def get_local_etccdi_sdii_mean(gauge_lat: int | float, gauge_lon: int | float) -
|
|
|
681
688
|
# 2. Compute spatial mean
|
|
682
689
|
etccdi_sdii = spatial_utils.compute_spatial_mean_xr(etccdi_sdii, var_name="SDII")
|
|
683
690
|
# 3. Get nearest local CDD value to the gauge coordinates
|
|
684
|
-
nearby_etccdi_sdii = neighbourhood_utils.
|
|
691
|
+
nearby_etccdi_sdii = neighbourhood_utils.get_nearest_non_nan_etccdi_val_to_gauge(
|
|
692
|
+
etccdi_sdii, etccdi_name="SDII", gauge_lat=gauge_lat, gauge_lon=gauge_lon
|
|
693
|
+
)
|
|
685
694
|
# 4. Get local maximum CDD_days value
|
|
686
695
|
nearby_etccdi_sdii_mean = np.max(nearby_etccdi_sdii["SDII_mean"])
|
|
687
696
|
return nearby_etccdi_sdii_mean
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Registry of all the QC checks in RainfallQC."""
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import itertools
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from rainfallqc.utils import data_utils
|
|
10
|
+
|
|
11
|
+
QC_CHECKS = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def qc_check(name: str, require_non_negative: bool = False) -> callable:
|
|
15
|
+
"""
|
|
16
|
+
Register a QC check and check for non-negative values if required.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
name :
|
|
21
|
+
Name of the QC check.
|
|
22
|
+
require_non_negative :
|
|
23
|
+
If True, check that the target gauge column has no negative values before running the QC check
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
callable :
|
|
28
|
+
Decorator to register the QC check.
|
|
29
|
+
|
|
30
|
+
Raises
|
|
31
|
+
------
|
|
32
|
+
ValueError :
|
|
33
|
+
If require_non_negative is True and the target gauge column contains negative values.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def decorator(func: callable) -> callable:
|
|
38
|
+
@functools.wraps(func)
|
|
39
|
+
def wrapper(df: pl.DataFrame, *args, **kwargs) -> list:
|
|
40
|
+
# Determine which column the user wants checked
|
|
41
|
+
columns_to_check = []
|
|
42
|
+
columns_to_check = get_columns_in_kwargs(
|
|
43
|
+
kwargs, kwarg_name="target_gauge_col", column_list=columns_to_check, name=name
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
for kwarg_name in ["neighbouring_gauge_col", "neighbouring_gauge_cols"]:
|
|
47
|
+
if kwarg_name in kwargs:
|
|
48
|
+
columns_to_check = get_columns_in_kwargs(
|
|
49
|
+
kwargs, kwarg_name=kwarg_name, column_list=columns_to_check, name=name
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# flatten column list for neighbouring_gauge_cols
|
|
53
|
+
columns_to_check = list(
|
|
54
|
+
itertools.chain.from_iterable(col if isinstance(col, list) else [col] for col in columns_to_check)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Optional non-negative pre-check
|
|
58
|
+
for col in columns_to_check:
|
|
59
|
+
if require_non_negative and data_utils.check_for_negative_values(df, col):
|
|
60
|
+
raise ValueError(f"{name} failed: column '{col}' contains negative values.")
|
|
61
|
+
|
|
62
|
+
# Run the actual QC check
|
|
63
|
+
return func(df, *args, **kwargs)
|
|
64
|
+
|
|
65
|
+
# Register for later use
|
|
66
|
+
QC_CHECKS[name] = wrapper
|
|
67
|
+
return wrapper
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_columns_in_kwargs(kwargs: dict, kwarg_name: str, column_list: list, name: str) -> list:
|
|
73
|
+
"""
|
|
74
|
+
Check that a column exists in the DataFrame.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
kwargs :
|
|
79
|
+
Dictionary of keyword arguments.
|
|
80
|
+
kwarg_name :
|
|
81
|
+
Name of the kwarg to check.
|
|
82
|
+
column_list :
|
|
83
|
+
List to append the column_name to if it exists.
|
|
84
|
+
name :
|
|
85
|
+
Name of the QC check (for error messages).
|
|
86
|
+
|
|
87
|
+
Raises
|
|
88
|
+
------
|
|
89
|
+
ValueError :
|
|
90
|
+
If the column does not exist in the DataFrame.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
col_name = kwargs.get(kwarg_name) # user defined column name for that kwarg
|
|
94
|
+
|
|
95
|
+
if col_name is None:
|
|
96
|
+
raise ValueError(f"{name} requires '{kwarg_name}' to be provided.")
|
|
97
|
+
column_list.append(col_name)
|
|
98
|
+
return column_list
|
|
@@ -693,7 +693,7 @@ class GPCCNetworkReader(GaugeNetworkReader):
|
|
|
693
693
|
)
|
|
694
694
|
|
|
695
695
|
def load_network_data(
|
|
696
|
-
self, data_paths: List[str] | np.ndarray[str], target_gauge_col: str, missing_val: int | float = -999
|
|
696
|
+
self, data_paths: List[str] | np.ndarray[str], target_gauge_col: str, missing_val: int | float = -999.9
|
|
697
697
|
) -> pl.DataFrame:
|
|
698
698
|
"""
|
|
699
699
|
Load GPCC network data based on file paths.
|
|
@@ -107,7 +107,10 @@ def check_data_has_consistent_time_step(data: pl.DataFrame) -> None:
|
|
|
107
107
|
unique_timesteps = get_data_timesteps(data)
|
|
108
108
|
if unique_timesteps.len() != 1:
|
|
109
109
|
timestep_strings = [format_timedelta_duration(td) for td in unique_timesteps]
|
|
110
|
-
raise ValueError(
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"""Data has a inconsistent time step. Data has following time steps: {timestep_strings}.
|
|
112
|
+
One potential fix is to resample the data using: 'data.upsample(\"time\", every=\"[insert_timestep]\")'"""
|
|
113
|
+
)
|
|
111
114
|
|
|
112
115
|
|
|
113
116
|
def check_data_is_monthly(data: pl.DataFrame) -> None:
|
|
@@ -175,6 +178,26 @@ def check_data_is_specific_time_res(data: pl.DataFrame, time_res: str | list) ->
|
|
|
175
178
|
raise ValueError(f"Invalid time step. Expected one of {allowed_res}, but data has time-step(s): {time_step}")
|
|
176
179
|
|
|
177
180
|
|
|
181
|
+
def check_for_negative_values(df: pl.DataFrame, target_gauge_col: str) -> bool:
|
|
182
|
+
"""
|
|
183
|
+
Check if the target column contains any negative values.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
df :
|
|
188
|
+
DataFrame to check.
|
|
189
|
+
target_gauge_col :
|
|
190
|
+
Column to check for negative values.
|
|
191
|
+
|
|
192
|
+
Raises
|
|
193
|
+
------
|
|
194
|
+
ValueError
|
|
195
|
+
If negative values are found in the target column.
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
return (df[target_gauge_col] < 0).any()
|
|
199
|
+
|
|
200
|
+
|
|
178
201
|
def convert_datarray_seconds_to_days(series_seconds: xr.DataArray) -> np.ndarray:
|
|
179
202
|
"""
|
|
180
203
|
Convert xarray series from seconds to days. For some reason the CDD data from ETCCDI is in seconds.
|
|
@@ -8,6 +8,12 @@ import numpy as np
|
|
|
8
8
|
import polars as pl
|
|
9
9
|
import xarray as xr
|
|
10
10
|
|
|
11
|
+
from rainfallqc.utils import spatial_utils
|
|
12
|
+
|
|
13
|
+
STATION_ID_COL = "station_id"
|
|
14
|
+
START_DATETIME_COL = "start_datetime"
|
|
15
|
+
END_DATETIME_COL = "end_datetime"
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
def get_target_neighbour_non_zero_minima(
|
|
13
19
|
data: pl.DataFrame, target_col: str, other_col: str, default_minima: float = 0.1
|
|
@@ -121,6 +127,9 @@ def get_ids_of_n_nearest_overlapping_neighbouring_gauges(
|
|
|
121
127
|
distance_threshold: int | float,
|
|
122
128
|
n_closest: int,
|
|
123
129
|
min_overlap_days: int,
|
|
130
|
+
station_id_col: str = STATION_ID_COL,
|
|
131
|
+
start_datetime_col: str = START_DATETIME_COL,
|
|
132
|
+
end_datetime_col: str = END_DATETIME_COL,
|
|
124
133
|
) -> set:
|
|
125
134
|
"""
|
|
126
135
|
Get gauge IDs of nearest n time-overlapping neighbouring gauges.
|
|
@@ -137,6 +146,12 @@ def get_ids_of_n_nearest_overlapping_neighbouring_gauges(
|
|
|
137
146
|
Number of closest neighbours.
|
|
138
147
|
min_overlap_days :
|
|
139
148
|
Minimum overlap between target and neighbouring gauges
|
|
149
|
+
station_id_col :
|
|
150
|
+
Column name for station ID in gauge_network_metadata (default 'station_id')
|
|
151
|
+
start_datetime_col :
|
|
152
|
+
Column name for start datetime in gauge_network_metadata (default 'start_datetime')
|
|
153
|
+
end_datetime_col :
|
|
154
|
+
Column name for end datetime in gauge_network_metadata (default 'end_datetime')
|
|
140
155
|
|
|
141
156
|
Returns
|
|
142
157
|
-------
|
|
@@ -145,11 +160,17 @@ def get_ids_of_n_nearest_overlapping_neighbouring_gauges(
|
|
|
145
160
|
|
|
146
161
|
"""
|
|
147
162
|
# 1. Compute distances between neighbours and target
|
|
148
|
-
neighbour_distances_df = compute_km_distances_from_target_id(
|
|
163
|
+
neighbour_distances_df = compute_km_distances_from_target_id(
|
|
164
|
+
gauge_network_metadata, target_id=target_id, station_id_col=station_id_col
|
|
165
|
+
)
|
|
149
166
|
|
|
150
167
|
# 2. Compute overlapping days between neighbours and target
|
|
151
168
|
neighbour_overlap_days_df = compute_temporal_overlap_days_from_target_id(
|
|
152
|
-
gauge_network_metadata,
|
|
169
|
+
gauge_network_metadata,
|
|
170
|
+
target_id=target_id,
|
|
171
|
+
station_id_col=station_id_col,
|
|
172
|
+
start_datetime_col=start_datetime_col,
|
|
173
|
+
end_datetime_col=end_datetime_col,
|
|
153
174
|
)
|
|
154
175
|
|
|
155
176
|
# 3. Subset n_closest based on distance threshold
|
|
@@ -163,8 +184,8 @@ def get_ids_of_n_nearest_overlapping_neighbouring_gauges(
|
|
|
163
184
|
)
|
|
164
185
|
|
|
165
186
|
# 5. get all ids of neighbouring gauges
|
|
166
|
-
neighbour_distances_ids = neighbour_distances_df[
|
|
167
|
-
neighbour_overlap_ids = neighbour_overlap_days_df[
|
|
187
|
+
neighbour_distances_ids = neighbour_distances_df[station_id_col].to_list()
|
|
188
|
+
neighbour_overlap_ids = neighbour_overlap_days_df[station_id_col].to_list()
|
|
168
189
|
|
|
169
190
|
# 6. Select gauge IDs meeting both conditions
|
|
170
191
|
all_neighbour_ids = set(neighbour_distances_ids).intersection(set(neighbour_overlap_ids))
|
|
@@ -202,7 +223,13 @@ def compute_temporal_overlap_days(
|
|
|
202
223
|
return overlap_days
|
|
203
224
|
|
|
204
225
|
|
|
205
|
-
def compute_temporal_overlap_days_from_target_id(
|
|
226
|
+
def compute_temporal_overlap_days_from_target_id(
|
|
227
|
+
gauge_network_metadata: pl.DataFrame,
|
|
228
|
+
target_id: str,
|
|
229
|
+
station_id_col: str,
|
|
230
|
+
start_datetime_col: str,
|
|
231
|
+
end_datetime_col: str,
|
|
232
|
+
) -> pl.DataFrame:
|
|
206
233
|
"""
|
|
207
234
|
Compute overlap in days between target gauges and its neighbours.
|
|
208
235
|
|
|
@@ -214,6 +241,12 @@ def compute_temporal_overlap_days_from_target_id(gauge_network_metadata: pl.Data
|
|
|
214
241
|
Metadata for gauge network. Each gauge must have 'longitude' and 'latitude'.
|
|
215
242
|
target_id :
|
|
216
243
|
Target gauge to compare against.
|
|
244
|
+
station_id_col :
|
|
245
|
+
Column name for station ID in gauge_network_metadata
|
|
246
|
+
start_datetime_col :
|
|
247
|
+
Column name for start datetime in gauge_network_metadata
|
|
248
|
+
end_datetime_col :
|
|
249
|
+
Column name for end datetime in gauge_network_metadata
|
|
217
250
|
|
|
218
251
|
Returns
|
|
219
252
|
-------
|
|
@@ -222,16 +255,16 @@ def compute_temporal_overlap_days_from_target_id(gauge_network_metadata: pl.Data
|
|
|
222
255
|
|
|
223
256
|
"""
|
|
224
257
|
# 1. Get target station and start and end date
|
|
225
|
-
target_station = gauge_network_metadata.filter(pl.col(
|
|
258
|
+
target_station = gauge_network_metadata.filter(pl.col(station_id_col) == target_id)
|
|
226
259
|
start_1, end_1 = (
|
|
227
|
-
target_station[
|
|
228
|
-
target_station[
|
|
260
|
+
target_station[start_datetime_col].item(),
|
|
261
|
+
target_station[end_datetime_col].item(),
|
|
229
262
|
)
|
|
230
263
|
|
|
231
264
|
# 2. Compute overlap days between target station to other start and end date
|
|
232
265
|
neighbour_overlap_days = {}
|
|
233
266
|
for other_station_id, start_2, end_2 in gauge_network_metadata[
|
|
234
|
-
[
|
|
267
|
+
[station_id_col, start_datetime_col, end_datetime_col]
|
|
235
268
|
].rows():
|
|
236
269
|
if target_id == other_station_id:
|
|
237
270
|
continue
|
|
@@ -241,7 +274,7 @@ def compute_temporal_overlap_days_from_target_id(gauge_network_metadata: pl.Data
|
|
|
241
274
|
# 3. Convert to pl.Dataframe
|
|
242
275
|
neighbour_overlap_days_df = pl.DataFrame(
|
|
243
276
|
{
|
|
244
|
-
|
|
277
|
+
station_id_col: neighbour_overlap_days.keys(),
|
|
245
278
|
"overlap_days": neighbour_overlap_days.values(),
|
|
246
279
|
}
|
|
247
280
|
)
|
|
@@ -272,7 +305,9 @@ def get_neighbours_with_min_overlap_days(
|
|
|
272
305
|
return neighbour_overlap_days_df.filter(pl.col("overlap_days") >= min_overlap_days)
|
|
273
306
|
|
|
274
307
|
|
|
275
|
-
def compute_km_distances_from_target_id(
|
|
308
|
+
def compute_km_distances_from_target_id(
|
|
309
|
+
gauge_network_metadata: pl.DataFrame, target_id: str, station_id_col: str
|
|
310
|
+
) -> pl.DataFrame:
|
|
276
311
|
"""
|
|
277
312
|
Compute kilometre distances between gauges in network and target gauges.
|
|
278
313
|
|
|
@@ -282,6 +317,8 @@ def compute_km_distances_from_target_id(gauge_network_metadata: pl.DataFrame, ta
|
|
|
282
317
|
Metadata for gauge network. Each gauge must have 'longitude' and 'latitude'.
|
|
283
318
|
target_id :
|
|
284
319
|
Target gauge to compare against.
|
|
320
|
+
station_id_col :
|
|
321
|
+
Column name for station ID in gauge_network_metadata
|
|
285
322
|
|
|
286
323
|
Returns
|
|
287
324
|
-------
|
|
@@ -290,7 +327,7 @@ def compute_km_distances_from_target_id(gauge_network_metadata: pl.DataFrame, ta
|
|
|
290
327
|
|
|
291
328
|
"""
|
|
292
329
|
# 1. Get target station lat and lon
|
|
293
|
-
target_station = gauge_network_metadata.filter(pl.col(
|
|
330
|
+
target_station = gauge_network_metadata.filter(pl.col(station_id_col) == target_id)
|
|
294
331
|
target_latlon = (
|
|
295
332
|
target_station["latitude"].item(),
|
|
296
333
|
target_station["longitude"].item(),
|
|
@@ -299,7 +336,7 @@ def compute_km_distances_from_target_id(gauge_network_metadata: pl.DataFrame, ta
|
|
|
299
336
|
# 2. Calculate lat/lon distances from the target gauge
|
|
300
337
|
neighbour_distances = {}
|
|
301
338
|
for other_station_id, other_lat, other_lon in gauge_network_metadata[
|
|
302
|
-
[
|
|
339
|
+
[station_id_col, "latitude", "longitude"]
|
|
303
340
|
].rows():
|
|
304
341
|
neighbour_distances[other_station_id] = geopy.distance.geodesic(
|
|
305
342
|
target_latlon, (other_lat, other_lon)
|
|
@@ -308,7 +345,7 @@ def compute_km_distances_from_target_id(gauge_network_metadata: pl.DataFrame, ta
|
|
|
308
345
|
# 3. Convert to pl.Dataframe
|
|
309
346
|
neighbour_distances_df = pl.DataFrame(
|
|
310
347
|
{
|
|
311
|
-
|
|
348
|
+
station_id_col: neighbour_distances.keys(),
|
|
312
349
|
"distance": neighbour_distances.values(),
|
|
313
350
|
}
|
|
314
351
|
)
|
|
@@ -358,31 +395,57 @@ def get_n_closest_neighbours(
|
|
|
358
395
|
return sorted_close_neighbours.filter(pl.col("distance") <= nth_distance)
|
|
359
396
|
|
|
360
397
|
|
|
361
|
-
def
|
|
362
|
-
etccdi_data: xr.Dataset,
|
|
398
|
+
def get_nearest_non_nan_etccdi_val_to_gauge(
|
|
399
|
+
etccdi_data: xr.Dataset,
|
|
400
|
+
etccdi_name: str,
|
|
401
|
+
gauge_lat: int | float,
|
|
402
|
+
gauge_lon: int | float,
|
|
403
|
+
max_distance_km: int | float = 500,
|
|
363
404
|
) -> xr.Dataset:
|
|
364
405
|
"""
|
|
365
|
-
Get the value at the nearest ETCCDI grid cell to the gauge coordinates.
|
|
406
|
+
Get the value at the nearest non-nan ETCCDI grid cell to the gauge coordinates.
|
|
366
407
|
|
|
367
408
|
Parameters
|
|
368
409
|
----------
|
|
369
410
|
etccdi_data
|
|
370
411
|
ETCCDI data with given variable to check
|
|
412
|
+
etccdi_name :
|
|
413
|
+
ETCCDI variable name to check
|
|
371
414
|
gauge_lat :
|
|
372
415
|
latitude of the rain gauge
|
|
373
416
|
gauge_lon :
|
|
374
417
|
longitude of the rain gauge
|
|
418
|
+
max_distance_km :
|
|
419
|
+
Maximum distance in km to search for a non-nan value (default 500 km)
|
|
375
420
|
|
|
376
421
|
Returns
|
|
377
422
|
-------
|
|
378
423
|
nearby_etccdi_data :
|
|
379
|
-
ETCCDI data at the nearest grid cell
|
|
424
|
+
ETCCDI data at the nearest grid cell with non-nan values
|
|
380
425
|
|
|
381
426
|
"""
|
|
382
|
-
# 1.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
427
|
+
# 1. Stack lat/lon into a single dimension
|
|
428
|
+
stacked = etccdi_data.stack(points=("lat", "lon"))
|
|
429
|
+
|
|
430
|
+
# 2. Compute haversine distance to each point
|
|
431
|
+
dists = spatial_utils.haversine(stacked["lon"], stacked["lat"], gauge_lon, gauge_lat)
|
|
432
|
+
|
|
433
|
+
# 3. Sort by distance
|
|
434
|
+
sorted_idx = dists.argsort()
|
|
435
|
+
|
|
436
|
+
# Loop through points in ascending distance order
|
|
437
|
+
for idx in sorted_idx.values:
|
|
438
|
+
dist_km = dists.isel(points=idx).item()
|
|
439
|
+
if dist_km <= max_distance_km:
|
|
440
|
+
if not np.isnan(stacked[etccdi_name].isel(points=idx)).all():
|
|
441
|
+
sel_lon = stacked["lon"].isel(points=idx).item()
|
|
442
|
+
sel_lat = stacked["lat"].isel(points=idx).item()
|
|
443
|
+
return etccdi_data.sel(lon=sel_lon, lat=sel_lat)
|
|
444
|
+
else:
|
|
445
|
+
# Because sorted by distance, if we've exceeded max distance, we can break early
|
|
446
|
+
break
|
|
447
|
+
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"""No non-NaN point found within {max_distance_km} km of ({gauge_lat}, {gauge_lon}).
|
|
450
|
+
Assuming EPSG:4326 coordinates."""
|
|
387
451
|
)
|
|
388
|
-
return nearby_etccdi_data
|
|
@@ -8,6 +8,8 @@ Classes and functions ordered alphabetically.
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import xarray as xr
|
|
10
10
|
|
|
11
|
+
EARTH_RADIUS_KM = 6371.0 # Radius of the Earth in kilometers
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
def compute_spatial_mean_xr(data: xr.Dataset, var_name: str) -> xr.Dataset:
|
|
13
15
|
"""
|
|
@@ -38,3 +40,33 @@ def compute_spatial_mean_xr(data: xr.Dataset, var_name: str) -> xr.Dataset:
|
|
|
38
40
|
np.ma.mean(data_masked, axis=0),
|
|
39
41
|
) # axis 0 is time
|
|
40
42
|
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def haversine(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
|
46
|
+
"""
|
|
47
|
+
Great circle distance (km) between two points on Earth.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
lon1 : float
|
|
52
|
+
Longitude of point 1
|
|
53
|
+
lat1 : float
|
|
54
|
+
Latitude of point 1
|
|
55
|
+
lon2 : float
|
|
56
|
+
Longitude of point 2
|
|
57
|
+
lat2 : float
|
|
58
|
+
Latitude of point 2
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
distance : float
|
|
63
|
+
Distance between the two points in km
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
R = EARTH_RADIUS_KM
|
|
67
|
+
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, float(lon2), float(lat2)])
|
|
68
|
+
dlon = lon2 - lon1
|
|
69
|
+
dlat = lat2 - lat1
|
|
70
|
+
a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
|
|
71
|
+
c = 2 * np.arcsin(np.sqrt(a))
|
|
72
|
+
return R * c
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|