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.
Files changed (30) hide show
  1. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/PKG-INFO +1 -1
  2. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/pyproject.toml +1 -1
  3. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/__init__.py +3 -2
  4. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/comparison_checks.py +14 -3
  5. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/gauge_checks.py +24 -13
  6. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/neighbourhood_checks.py +9 -0
  7. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/timeseries_checks.py +11 -2
  8. rainfallqc-0.2.2/src/rainfallqc/core/all_qc_checks.py +98 -0
  9. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/apply_qc_framework.py +0 -1
  10. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/data_readers.py +1 -1
  11. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/data_utils.py +24 -1
  12. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/neighbourhood_utils.py +87 -24
  13. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/spatial_utils.py +32 -0
  14. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/LICENSE +0 -0
  15. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/README.rst +0 -0
  16. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/__init__.py +0 -0
  17. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/checks/pypwsqc_filters.py +0 -0
  18. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/core/__init__.py +0 -0
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/ETCCDI/__init__.py +0 -0
  26. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/data/__init__.py +0 -0
  27. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/__init__.py +0 -0
  28. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/qc_frameworks/inbuilt_qc_frameworks.py +0 -0
  29. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/__init__.py +0 -0
  30. {rainfallqc-0.2.1 → rainfallqc-0.2.2}/src/rainfallqc/utils/stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rainfallqc
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Quality control for rainfall data
5
5
  Author-email: Tom Keel <tomkee@ceh.ac.uk>
6
6
  Maintainer-email: Tom Keel <tomkee@ceh.ac.uk>
@@ -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.1"
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.1"
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.get_nearest_etccdi_val_to_gauge(etccdi_r99p, gauge_lat, gauge_lon)
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.get_nearest_etccdi_val_to_gauge(etccdi_prcptot, gauge_lat, gauge_lon)
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.get_nearest_etccdi_val_to_gauge(etccdi_rx1day, gauge_lat, gauge_lon)
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. Replace missing values with NaN
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
- # --- Step 2: Mark missing values ---
149
+ # 3. Mark missing values
142
150
  data = data.with_columns(pl.col(target_gauge_col).is_nan().alias("is_missing"))
143
151
 
144
- # 3. Assign group numbers to consecutive missing values
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
- # 4. Count size of each missing group
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
- # 5. Keep only valid groups
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
- # 6. Get first and last time for each group
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
- # 7. Add stable row index to full sorted data
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
- # 8. Lookup row indices of group bounds
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
- # 9. Compute prev/next indices and fetch values
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
- # 10. Filter to groups bounded by zero
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
- # 11. Extract year from each bounded group's start time
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
- # 12. Count how many bounded groups occur per year
207
+ # 13. Count how many bounded groups occur per year
199
208
  year_counts = bounded_years.group_by("year").len()
200
209
 
201
- # 13. Get years exceeding threshold
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.get_nearest_etccdi_val_to_gauge(etccdi_cdd_days, gauge_lat, gauge_lon)
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.get_nearest_etccdi_val_to_gauge(etccdi_sdii, gauge_lat, gauge_lon)
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
@@ -57,7 +57,6 @@ def run_qc_framework(
57
57
  for k, v in combined_kwargs.items()
58
58
  if k in accepted_keys or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
59
59
  }
60
-
61
60
  qc_results[qc_method] = qc_func(data, **filtered_kwargs)
62
61
 
63
62
  return qc_results
@@ -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(f"Data has a inconsistent time step. Data has following time steps: {timestep_strings}")
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(gauge_network_metadata, target_id=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, target_id=target_id
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["station_id"].to_list()
167
- neighbour_overlap_ids = neighbour_overlap_days_df["station_id"].to_list()
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(gauge_network_metadata: pl.DataFrame, target_id: str) -> pl.DataFrame:
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("station_id") == target_id)
258
+ target_station = gauge_network_metadata.filter(pl.col(station_id_col) == target_id)
226
259
  start_1, end_1 = (
227
- target_station["start_datetime"].item(),
228
- target_station["end_datetime"].item(),
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
- ["station_id", "start_datetime", "end_datetime"]
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
- "station_id": neighbour_overlap_days.keys(),
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(gauge_network_metadata: pl.DataFrame, target_id: str) -> pl.DataFrame:
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("station_id") == target_id)
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
- ["station_id", "latitude", "longitude"]
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
- "station_id": neighbour_distances.keys(),
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 get_nearest_etccdi_val_to_gauge(
362
- etccdi_data: xr.Dataset, gauge_lat: int | float, gauge_lon: int | float
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. Get local data
383
- nearby_etccdi_data = etccdi_data.sel(
384
- lon=gauge_lon,
385
- lat=gauge_lat,
386
- method="nearest",
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