Skip to content

Commit fbf4763

Browse files
Merge pull request #672 from cal-adapt/bug/wl-start-year-fix
Fixing start year WL slicing for LOCA data
2 parents 00c4789 + 56fb2dd commit fbf4763

7 files changed

Lines changed: 218 additions & 18 deletions

File tree

climakitae/core/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,10 @@
6868
]
6969

7070
CALISO_AREA_THRESHOLD = 100
71+
72+
# Start and end years for LOCA and WRF data
73+
LOCA_START_YEAR = 1950
74+
LOCA_END_YEAR = 2100
75+
76+
WRF_START_YEAR = 1981
77+
WRF_END_YEAR = 2099

climakitae/core/data_load.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1297,7 +1297,7 @@ def read_catalog_from_select(selections: "DataParameters") -> xr.DataArray:
12971297
"""
12981298

12991299
if selections.approach == "Warming Level":
1300-
selections.time_slice = (1980, 2100) # Retrieve entire time period
1300+
selections.time_slice = (1950, 2100) # Retrieve entire time period
13011301

13021302
# Raise appropriate errors for time-based retrieval
13031303
if selections.approach == "Time":

climakitae/new_core/processors/warming_level.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
DataProcessor,
2121
register_processor,
2222
)
23-
from climakitae.util.utils import read_csv_file
23+
24+
# from climakitae.new_core.processors.processor_utils import _determine_is_complete_wl
25+
from climakitae.util.utils import _determine_is_complete_wl, read_csv_file
2426

2527

2628
@register_processor("warming_level", priority=10)
@@ -50,9 +52,9 @@ class WarmingLevel(DataProcessor):
5052
5153
Notes
5254
-----
53-
The input data must span from 1980-2100 and include historical climate data
54-
for proper warming level calculations. Data should have simulation and scenario
55-
dimensions or be properly configured for stacking.
55+
The input data must span from 1950-2100 or 1981-2100, depending on the downscaling method,
56+
and include historical climate data for proper warming level calculations. Data should
57+
have simulation and scenario dimensions or be properly configured for stacking.
5658
"""
5759

5860
def __init__(self, value: Dict[str, Any]):
@@ -134,7 +136,7 @@ def execute(
134136
# and split the data by member_id
135137
ret = self.reformat_member_ids(result)
136138

137-
# extend the time domain of all ssp scenarios to 1980-2100
139+
# extend the time domain of all ssp scenarios to cover historical period
138140
ret = self.extend_time_domain(ret)
139141

140142
# first, extract the member IDs from the data
@@ -151,6 +153,9 @@ def execute(
151153
# get center years for each key for each warming level
152154
center_years = self.get_center_years(member_ids, ret.keys())
153155
retkeys = list(ret.keys())
156+
157+
dropped_slices_count = 0
158+
154159
for key in retkeys:
155160
if key not in center_years:
156161
del ret[key]
@@ -169,9 +174,18 @@ def execute(
169174
)
170175
continue
171176
start_year = year - self.warming_level_window
172-
start_year = max(start_year, 1981)
173177
end_year = year + self.warming_level_window - 1
174-
end_year = min(end_year, 2100)
178+
179+
is_full_wl = _determine_is_complete_wl(
180+
start_year, end_year, key, context["activity_id"], wl
181+
)
182+
183+
if not is_full_wl:
184+
warnings.warn(
185+
f"\n\nIncomplete warming level for {key} at {wl}C. "
186+
"\nSkipping this warming level."
187+
)
188+
continue
175189

176190
da_slice = ret[key].sel(time=slice(f"{start_year}", f"{end_year}"))
177191

@@ -211,6 +225,8 @@ def execute(
211225
slices, dim="warming_level", join="outer", fill_value=np.nan
212226
)
213227
self.update_context(context)
228+
229+
print(f"Dropped slices count: {dropped_slices_count}")
214230
return ret
215231

216232
def update_context(self, context: Dict[str, Any]) -> Dict[str, Any]:
@@ -287,7 +303,8 @@ def extend_time_domain(
287303
self, result: Dict[str, Union[xr.Dataset, xr.DataArray]]
288304
) -> Dict[str, Union[xr.Dataset, xr.DataArray]]:
289305
"""
290-
Extend the time domain of the input data to cover 1980-2100.
306+
Extend the time domain of the input data to cover the historical period,
307+
either 1950-2100 for LOCA or 1981-2100 for WRF.
291308
292309
This method ensures that all SSP scenarios have historical data
293310
included in the time series, allowing for proper warming level calculations.

climakitae/util/utils.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import calendar
22
import datetime
33
import os
4+
import warnings
45
from typing import Any, Iterable, Union
56

67
import geopandas as gpd
@@ -13,7 +14,14 @@
1314
from shapely.geometry import Point, mapping
1415
from timezonefinder import TimezoneFinder
1516

16-
from climakitae.core.constants import SSPS, UNSET
17+
from climakitae.core.constants import (
18+
LOCA_END_YEAR,
19+
LOCA_START_YEAR,
20+
SSPS,
21+
UNSET,
22+
WRF_END_YEAR,
23+
WRF_START_YEAR,
24+
)
1725

1826
# from climakitae.core.data_interface import DataParameters
1927
from climakitae.core.paths import DATA_CATALOG_URL, STATIONS_CSV_PATH
@@ -1370,3 +1378,51 @@ def clip_gpd_to_shapefile(
13701378
print(f"Number of stations within area: {len(clipped)}")
13711379

13721380
return clipped
1381+
1382+
1383+
def _determine_is_complete_wl(
1384+
start_year: int,
1385+
end_year: int,
1386+
simulation_name: str,
1387+
downscaling_method: str,
1388+
level: float,
1389+
) -> bool:
1390+
"""
1391+
Determine if a complete warming level slice can be created for the given start and end years.
1392+
This checks if the years fall within valid ranges based on the downscaling method.
1393+
1394+
Parameters
1395+
----------
1396+
start_year : int
1397+
The starting year of the warming level slice.
1398+
end_year : int
1399+
The ending year of the warming level slice.
1400+
simulation_name : str
1401+
The name of the simulation being evaluated.
1402+
downscaling_method : str
1403+
The downscaling method used ("Statistical" or "Dynamical").
1404+
level : float
1405+
The warming level being evaluated.
1406+
1407+
Returns
1408+
-------
1409+
bool
1410+
True if a complete warming level slice can be created, False otherwise.
1411+
"""
1412+
valid_years = {
1413+
"LOCA2": (LOCA_START_YEAR, LOCA_END_YEAR),
1414+
"Statistical": (LOCA_START_YEAR, LOCA_END_YEAR),
1415+
"WRF": (WRF_START_YEAR, WRF_END_YEAR),
1416+
"Dynamical": (WRF_START_YEAR, WRF_END_YEAR),
1417+
}
1418+
1419+
min_year, max_year = valid_years.get(downscaling_method, (None, None))
1420+
1421+
if min_year and (start_year < min_year or end_year > max_year):
1422+
warnings.warn(
1423+
f"\n\nIncomplete warming level for {simulation_name} at {level}C. "
1424+
"\nSkipping this warming level."
1425+
)
1426+
return False
1427+
1428+
return True

climakitae/util/warming_levels.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Helper functions related to applying a warming levels approach to a data object"""
22

33
import calendar
4+
import warnings
45
from typing import Union
56

67
import intake
@@ -21,6 +22,7 @@
2122
SSP585_FILE,
2223
)
2324
from climakitae.util.utils import (
25+
_determine_is_complete_wl,
2426
_get_cat_subset,
2527
read_csv_file,
2628
resolution_to_gridlabel,
@@ -118,16 +120,18 @@ def _get_sliced_data(
118120
# Dropping leap days before slicing time dimension because the window size can affect number of leap days per slice
119121
y = y.loc[~((y.time.dt.month == 2) & (y.time.dt.day == 29))]
120122

123+
# Getting start and end years for slicing if `center_time` is not NaN
121124
if not pd.isna(center_time):
122-
123-
# Find the centered year
124125
centered_year = pd.to_datetime(center_time).year
125126
start_year = centered_year - window
126127
end_year = centered_year + (window - 1)
127128

128-
if start_year < 1981:
129-
start_year = 1981
129+
# If the centered year is not NaN and a complete warming level slice can be created, proceed with slicing
130+
if not pd.isna(center_time) and _determine_is_complete_wl(
131+
start_year, end_year, y.simulation.item(), y.downscaling_method, level
132+
):
130133

134+
# Slicing data around the centered year
131135
sliced = y.sel(time=slice(str(start_year), str(end_year)))
132136

133137
# Creating a mask for timestamps that are within the desired months

tests/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,37 @@ def test_dataarray_dict():
164164
xr_dict["WRF.UCLA.EC-Earth3.ssp370.day.d03"] = ds
165165

166166
return xr_dict
167+
168+
169+
@pytest.fixture
170+
def test_dataarray_dict_loca():
171+
"""Create test datasets using xarray for warming_level.py tests."""
172+
xr_dict = {}
173+
member_id = ["r1i1p1f1"]
174+
hist_periods, ssp_periods = 65, 86
175+
hist_time = pd.date_range(
176+
"1950-01-01", periods=hist_periods, freq="YS"
177+
) # yearly start
178+
ssp_time = pd.date_range(
179+
"2015-01-01", periods=ssp_periods, freq="YS"
180+
) # yearly start
181+
y = [0]
182+
x = [0]
183+
184+
for pair in zip([hist_periods, ssp_periods], [hist_time, ssp_time]):
185+
periods, timestamps = pair
186+
ds = xr.Dataset(
187+
{"t2": (("member_id", "y", "x", "time"), np.zeros((1, 1, 1, periods)))},
188+
coords={
189+
"member_id": member_id,
190+
"y": y,
191+
"x": x,
192+
"time": timestamps,
193+
},
194+
)
195+
if periods == hist_periods:
196+
xr_dict["LOCA2.UCLA.ACCESS-CM2.historical.day.d03"] = ds
197+
else:
198+
xr_dict["LOCA2.UCLA.ACCESS-CM2.ssp585.day.d03"] = ds
199+
200+
return xr_dict

tests/new_core/processors/test_warming_level.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,41 @@ def processor():
7878
yield wl_processor
7979

8080

81+
@pytest.fixture
82+
def edge_case_processor():
83+
"""Fixture for a WarmingLevel processor with edge case warming level time DataFrames."""
84+
wl_processor = WarmingLevel(value={"warming_levels": [0.5, 4.5]})
85+
wl_time_data = [
86+
[
87+
"GCM",
88+
"run",
89+
"scenario",
90+
"0.5",
91+
"4.5",
92+
],
93+
[
94+
"ACCESS-CM2",
95+
"r1i1p1f1",
96+
"ssp585",
97+
"1955-01-01 00:00:00",
98+
"2095-01-01 00:00:00",
99+
],
100+
]
101+
wl_processor.warming_level_times = pd.DataFrame(
102+
wl_time_data[1:], columns=wl_time_data[0]
103+
).set_index(["GCM", "run", "scenario"])
104+
105+
wl_time_idx_data = [
106+
["time", "ACCESS-CM2_r3i1p1f1_ssp585"],
107+
["2005-1", 1.0],
108+
]
109+
wl_processor.warming_level_times_idx = pd.DataFrame(
110+
wl_time_idx_data[1:], columns=wl_time_idx_data[0]
111+
).set_index("time")
112+
113+
yield wl_processor
114+
115+
81116
@pytest.fixture
82117
def full_processor():
83118
"""Fixture for a full WarmingLevel processor with default warming level time DataFrames."""
@@ -343,7 +378,7 @@ def test_empty_warming_level_times_error(self, mock_read_csv_file, processor):
343378
def test_execute_updates_context(self, request, full_processor):
344379
"""Test that execute updates context with warming level information."""
345380
test_result = request.getfixturevalue("test_dataarray_dict")
346-
context = {}
381+
context = {"activity_id": "WRF"}
347382
_ = full_processor.execute(result=test_result, context=context)
348383
assert full_processor.name in context[_NEW_ATTRS_KEY][full_processor.name]
349384
assert str(full_processor.value) in context[_NEW_ATTRS_KEY][full_processor.name]
@@ -378,15 +413,15 @@ def test_execute_skips_warming_level(self, request, full_processor):
378413
match=f"No warming level data found",
379414
),
380415
):
381-
ret = full_processor.execute(data, context={})
416+
ret = full_processor.execute(data, context={"activity_id": "WRF"})
382417
for key in ret:
383418
assert len(ret[key].warming_level) == 2
384419
assert 5.8 not in ret[key].warming_level.values
385420

386421
def test_execute_dims_correct(self, request, full_processor):
387422
"""Test that execute returns a dict with expected keys and types."""
388423
test_result = request.getfixturevalue("test_dataarray_dict")
389-
ret = full_processor.execute(result=test_result, context={})
424+
ret = full_processor.execute(result=test_result, context={"activity_id": "WRF"})
390425
for key in ret:
391426
assert isinstance(ret[key], xr.Dataset)
392427
assert "warming_level" in ret[key].dims
@@ -397,7 +432,7 @@ def test_execute_years_correct(self, request, full_processor):
397432
"""Test that execute manipulates the data to have correct dims and years."""
398433
test_result = request.getfixturevalue("test_dataarray_dict")
399434
test_key = "WRF.UCLA.EC-Earth3.ssp370.day.d03"
400-
ret = full_processor.execute(result=test_result, context={})
435+
ret = full_processor.execute(result=test_result, context={"activity_id": "WRF"})
401436
ret_key = "WRF.UCLA.EC-Earth3.ssp370.day.d03.r1i1p1f1"
402437

403438
# Check that the warming_level coordinate matches the processor's warming_levels
@@ -417,3 +452,50 @@ def test_execute_years_correct(self, request, full_processor):
417452
assert isinstance(ret[ret_key].centered_year.item(), int)
418453
# Check that the centered_year is within expected range
419454
assert 1981 <= ret[ret_key].centered_year.item() <= 2100
455+
456+
def test_execute_loca_correct(self, request, full_processor):
457+
"""Test that execute works correctly for LOCA data."""
458+
test_result = request.getfixturevalue("test_dataarray_dict_loca")
459+
# The test fixture creates data for ACCESS-CM2 model
460+
test_key = "LOCA2.UCLA.ACCESS-CM2.ssp585.day.d03"
461+
ret = full_processor.execute(
462+
result=test_result, context={"activity_id": "LOCA2"}
463+
)
464+
ret_key = "LOCA2.UCLA.ACCESS-CM2.ssp585.day.d03.r1i1p1f1"
465+
466+
# Check that the warming_level coordinate matches the processor's warming_levels
467+
assert (
468+
ret[ret_key].warming_level.values == full_processor.warming_levels
469+
).all()
470+
# Check the length of the time_delta dimension
471+
first_year = str(test_result[test_key].isel(time=0).time.dt.year.item())
472+
# Find the number of elements in the first year of `ret[key]`
473+
timesteps_per_year = (
474+
test_result[test_key].sel(time=slice(first_year, first_year)).time.size
475+
)
476+
assert (
477+
len(ret[ret_key].time_delta)
478+
== timesteps_per_year * full_processor.warming_level_window * 2
479+
)
480+
assert isinstance(ret[ret_key].centered_year.item(), int)
481+
# Check that the centered_year is within expected range
482+
assert 1981 <= ret[ret_key].centered_year.item() <= 2100
483+
484+
def test_execute_edge_case_years(self, request, edge_case_processor):
485+
"""Test that execute handles edge case warming levels and years correctly."""
486+
test_result = request.getfixturevalue("test_dataarray_dict_loca")
487+
with pytest.warns(
488+
UserWarning,
489+
) as record:
490+
ret = edge_case_processor.execute(
491+
result=test_result, context={"activity_id": "LOCA2"}
492+
)
493+
assert len(record) == 5
494+
assert ret == {}
495+
messages = [str(w.message) for w in record]
496+
expected_substrings = [
497+
"Incomplete warming level for LOCA2.UCLA.ACCESS-CM2.ssp585.day.d03.r1i1p1f1 at 0.5C",
498+
"Incomplete warming level for LOCA2.UCLA.ACCESS-CM2.ssp585.day.d03.r1i1p1f1 at 4.5C",
499+
]
500+
for substr in expected_substrings:
501+
assert any(substr in msg for msg in messages), f"Missing: {substr}"

0 commit comments

Comments
 (0)