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.
Files changed (28) hide show
  1. rainfallqc/__init__.py +22 -0
  2. rainfallqc/checks/__init__.py +1 -0
  3. rainfallqc/checks/comparison_checks.py +342 -0
  4. rainfallqc/checks/gauge_checks.py +234 -0
  5. rainfallqc/checks/neighbourhood_checks.py +341 -0
  6. rainfallqc/checks/timeseries_checks.py +1042 -0
  7. rainfallqc/core/__init__.py +1 -0
  8. rainfallqc/data/ETCCDI/RawData_HADEX2_CDD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  9. rainfallqc/data/ETCCDI/RawData_HADEX2_CWD_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  10. rainfallqc/data/ETCCDI/RawData_HADEX2_PRCPTOT_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  11. rainfallqc/data/ETCCDI/RawData_HADEX2_R99p_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  12. rainfallqc/data/ETCCDI/RawData_HADEX2_Rx1day_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  13. rainfallqc/data/ETCCDI/RawData_HADEX2_SDII_1951-2010_ANN_from-90to90_from-180to180.nc +0 -0
  14. rainfallqc/data/ETCCDI/__init__.py +1 -0
  15. rainfallqc/data/__init__.py +1 -0
  16. rainfallqc/qc_frameworks/__init__.py +1 -0
  17. rainfallqc/qc_frameworks/apply_qc_framework.py +63 -0
  18. rainfallqc/qc_frameworks/inbuilt_qc_frameworks.py +25 -0
  19. rainfallqc/utils/__init__.py +1 -0
  20. rainfallqc/utils/data_readers.py +713 -0
  21. rainfallqc/utils/data_utils.py +259 -0
  22. rainfallqc/utils/neighbourhood_utils.py +355 -0
  23. rainfallqc/utils/spatial_utils.py +40 -0
  24. rainfallqc/utils/stats.py +260 -0
  25. rainfallqc-0.0.9.dist-info/METADATA +201 -0
  26. rainfallqc-0.0.9.dist-info/RECORD +28 -0
  27. rainfallqc-0.0.9.dist-info/WHEEL +4 -0
  28. 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()