Repository: cal-adapt/climakitae
Pull request #567: Adding Custom WLs
Author: login: claalmve
Created: 2025-05-19T23:17:23Z | Merged: 2025-05-30T22:23:35Z

## Summary of changes and related issue
Allows users to pass in custom WLs to `get_data` and get WL data for that WL!

Need to:

- [x] Make bounds for valid WLs to retrieve (0.8 to 4.0? Something looser?)
- [ ] Add tests for custom WLs

## Relevant motivation and context
We want to provide functionality for users to retrieve more granular WLs than just the WLs provided in `gwl_1850-1900ref.csv`.

## How to test 
Use `get_data` and retrieve WL data for a custom WL (between 0.8 and 4.0).

## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] None of the above  

## To-Do
- [x] Unit tests
  - [ ] Existing unit tests are passing
  - [ ] If relevant, new unit tests are written (required 80% unit test coverage)
  - [ ] Advanced Testing passing (select Advanced Testing label)
- [x] Documentation
  - [ ] Intent of all functions included
  - [ ] Complex code commented
  - [ ] Functions include [NumPy style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html) 
- [x] Naming conventions followed
  - [ ] Helper functions hidden with `_` before the name
- [x] Any notebooks known to utilize the affected functions are still working
- [x] Black formatting has been utilized
- [ ] Tagged/notified 2 reviewers for this PR



Diff:
diff --git a/climakitae/core/data_interface.py b/climakitae/core/data_interface.py
index aa5959da6..1b8815a35 100644
--- a/climakitae/core/data_interface.py
+++ b/climakitae/core/data_interface.py
@@ -52,6 +52,7 @@
     scenario_to_experiment_id,
     timescale_to_table_id,
 )
+from climakitae.util.warming_levels import create_ae_warming_trajectories
 
 # Warnings raised by function get_subsetting_options, not sure why but they are silenced here
 pd.options.mode.chained_assignment = None  # default='warn'
@@ -703,7 +704,7 @@ class DataParameters(param.Parameterized):
     # Warming level options
     wl_options = WARMING_LEVELS
     wl_time_option = ["n/a"]
-    warming_level = param.ListSelector(default=["n/a"], objects=["n/a"])
+    warming_level = param.List(default=[1.0], item_type=Union[float, str])
     warming_level_window = param.Integer(
         default=15,
         bounds=(5, 25),
@@ -828,7 +829,6 @@ def _update_scenarios_approach(self):
         If time-based is selected, there should be no warming levels options shown.
         """
         if self.approach == "Warming Level":
-            self.param["warming_level"].objects = self.wl_options
             self.warming_level = [2.0]
 
             self.param["scenario_ssp"].objects = ["n/a"]
@@ -838,7 +838,6 @@ def _update_scenarios_approach(self):
             self.scenario_historical = ["n/a"]
 
         elif self.approach == "Time":
-            self.param["warming_level"].objects = ["n/a"]
             self.warming_level = ["n/a"]
 
             self.param["scenario_ssp"].objects = SSPS
@@ -913,7 +912,6 @@ def _update_data_type_option_for_some_selections(self):
         else:
             self.param["data_type"].objects = ["Gridded", "Stations"]
         if self.variable_type == "Derived Index":
-
             # Haven't built into the code to retrieve derive index for statistically downscaled data yet. Derived indices at the moment only work for hourly data.
             self.param["downscaling_method"].objects = ["Dynamical"]
             self.downscaling_method = "Dynamical"
@@ -1107,7 +1105,6 @@ def _update_scenarios(self):
         """
 
         if self.approach == "Time":
-
             # Set incoming scenario_historical
             _scenario_historical = self.scenario_historical
 
@@ -1624,7 +1621,6 @@ def _check_if_good_input(d: dict, cat_df: pd.DataFrame) -> dict:
         # If the input value is not in the valid options, see if you can help the user out
         key_updated = []
         for val_i in val:
-
             # This catches any common bad inputs for resolution: i.e. "3KM" or "3km" instead of "3 km"
             if key == "resolution":
                 try:
@@ -1646,7 +1642,6 @@ def _check_if_good_input(d: dict, cat_df: pd.DataFrame) -> dict:
                     pass
 
             if val_i not in valid_options:
-
                 print("Input " + key + "='" + val_i + "' is not a valid option.")
 
                 closest_options = _get_closest_options(val_i, valid_options)
@@ -2023,7 +2018,10 @@ def _check_valid_input_station(
 
     # Internal functions
     def _error_handling_warming_level_inputs(
-        wl: Union[list[float], list[int]], argument_name: str
+        wl: Union[list[float], list[int]],
+        argument_name: str,
+        downscaling_method: str,
+        resolution: str,
     ):
         """
         Error handling for arguments: warming_level and warming_level_month
@@ -2031,6 +2029,23 @@ def _error_handling_warming_level_inputs(
         argument_name is either "warming_level" or "warming_level_months" and is used to
         print an appropriate error message for bad input
         """
+        # Find the WL bounds for LOCA and WRF
+        loca, wrf = create_ae_warming_trajectories(resolution)
+        loca_max = round(loca.max().max(), 2)
+        wrf_max = round(wrf.max().max(), 2)
+
+        match downscaling_method:
+            case "Statistical":
+                max_val = loca_max
+            case "Dynamical":
+                max_val = wrf_max
+            case "Dynamical+Statistical":
+                max_val = min(loca_max, wrf_max)
+            case _:
+                raise ValueError(
+                    "Downscaling method be 'Statistical', 'Dynamical', or 'Dynamical+Statistical'"
+                )
+
         if (wl is not None) and not isinstance(wl, list):
             if isinstance(wl, (float, int)):  # Convert float to a singleton list
                 wl = [wl]
@@ -2041,12 +2056,16 @@ def _error_handling_warming_level_inputs(
                 )
         if isinstance(wl, list):
             for x in wl:
-                if isinstance(x, (float, int)):
-                    continue
-                raise ValueError(
-                    f"""Function argument {argument_name} requires a float/int or list of
-                    floats/ints input. Your input: {type(x)}"""
-                )
+                if not isinstance(x, (float, int)):
+                    raise ValueError(
+                        f"Each item in '{argument_name}' must be a float or int. Got: {type(x)}"
+                    )
+                if argument_name == "warming_level":
+                    if x < 0 or x > max_val:
+                        raise ValueError(
+                            f"{argument_name} value {x}. "
+                            f"Allowed range for {downscaling_method}-downscaled data at {resolution} resolution is 0 to {max_val:.2f}."
+                        )
         return wl
 
     def _error_handling_approach_inputs(
@@ -2107,7 +2126,6 @@ def _get_scenario_ssp_scenario_historical(
             scenario_ssp = ["n/a"]
             scenario_historical = ["n/a"]
         elif approach == "Time":
-
             if (
                 "Historical Reconstruction" in scenario
             ):  # Handling for Historical Reconstruction option
@@ -2147,7 +2165,6 @@ def _get_scenario_ssp_scenario_historical(
 
     # Station data error handling
     if data_type == "Stations":
-
         # dictionary with { argument name : [valid option, user input]}
         d = {
             "downscaling_method": ["Dynamical", downscaling_method],
@@ -2218,10 +2235,10 @@ def _get_scenario_ssp_scenario_historical(
     # Check warming level inputs
     try:
         warming_level = _error_handling_warming_level_inputs(
-            warming_level, "warming_level"
+            warming_level, "warming_level", downscaling_method, resolution
         )
         warming_level_months = _error_handling_warming_level_inputs(
-            warming_level_months, "warming_level_months"
+            warming_level_months, "warming_level_months", downscaling_method, resolution
         )
     except ValueError as error_message:
         print(_format_error_print_message(error_message))
@@ -2470,7 +2487,6 @@ def _get_and_reformat_derived_variables(
     # loop through each row in the DataFrame
     derived_variables_split = []
     for i in range(len(derived_variables_multi_timescale)):
-
         # Get single row, containing one variable
         row = derived_variables_multi_timescale.iloc[i]
 
diff --git a/climakitae/core/data_load.py b/climakitae/core/data_load.py
index 1e3d071ec..e97da0810 100644
--- a/climakitae/core/data_load.py
+++ b/climakitae/core/data_load.py
@@ -22,6 +22,7 @@
 from xclim.sdba import Grouper, QuantileDeltaMapping
 
 from climakitae.core.boundaries import Boundaries
+from climakitae.core.constants import WARMING_LEVELS
 from climakitae.tools.derived_variables import (
     compute_dewpointtemp,
     compute_relative_humidity,
@@ -43,7 +44,11 @@
     scenario_to_experiment_id,
     timescale_to_table_id,
 )
-from climakitae.util.warming_levels import calculate_warming_level, drop_invalid_sims
+from climakitae.util.warming_levels import (
+    calculate_warming_level,
+    drop_invalid_sims,
+    create_new_warming_level_table,
+)
 
 if TYPE_CHECKING:
     from climakitae.core.data_interface import DataParameters
@@ -1203,7 +1208,6 @@ def read_catalog_from_select(selections: "DataParameters") -> xr.DataArray:
 
     # Raise appropriate errors for time-based retrieval
     if selections.approach == "Time":
-
         if (selections.scenario_ssp != []) and (
             "Historical Reconstruction" in selections.scenario_historical
         ):
@@ -1366,14 +1370,16 @@ def _apply_warming_levels_approach(
     # We need to drop them here such that the global warming levels table can be adequately parsed by the calculate_warming_level function
     data_stacked = drop_invalid_sims(data_stacked, selections)
 
-    # Calculate warming level DataArray for each individual warming level
-    # Function will be applied for each individual warming level
-    # Then, the list of DataArrays (one for each warming level) will be concatenated into a single DataArray object
     da_list = []
     for level in selections.warming_level:
+        gwl_table = selections._warming_level_times
+
+        if level not in WARMING_LEVELS:
+            gwl_table = create_new_warming_level_table(warming_level=level)
+
         da_by_wl = calculate_warming_level(
             data_stacked,
-            gwl_times=selections._warming_level_times,
+            gwl_times=gwl_table,
             level=level,
             months=selections.warming_level_months,
             window=selections.warming_level_window,
diff --git a/climakitae/core/paths.py b/climakitae/core/paths.py
index 1d3c57c9e..203bc41d2 100644
--- a/climakitae/core/paths.py
+++ b/climakitae/core/paths.py
@@ -23,6 +23,8 @@
 # Global warming levels files
 gwl_1850_1900_file = "data/gwl_1850-1900ref.csv"
 gwl_1981_2010_file = "data/gwl_1981-2010ref.csv"
+GWL_1850_1900_TIMEIDX_FILE = "data/gwl_1850-1900ref_timeidx.csv"
+GWL_1981_2010_TIMEIDX_FILE = "data/gwl_1981-2010ref_timeidx.csv"
 
 # GMT context plot files
 ssp119_file = "data/tas_global_SSP1_1_9.csv"
diff --git a/climakitae/explore/vulnerability.py b/climakitae/explore/vulnerability.py
index c86945866..d1a20487b 100644
--- a/climakitae/explore/vulnerability.py
+++ b/climakitae/explore/vulnerability.py
@@ -5,6 +5,7 @@
 import numpy as np
 import pandas as pd
 import param
+from typing import Union
 
 from climakitae.util.utils import (
     get_closest_gridcell,
@@ -315,11 +316,9 @@ class CavaParams(param.Parameterized):
     approach = param.ObjectSelector(
         default="Time", objects=["Time", "Warming Level"], doc="Approach to use"
     )
-    warming_level = param.ObjectSelector(
-        default=1.5,
-        objects=[None] + WARMING_LEVELS,
-        doc="Warming level for analysis",
-    )
+    warming_level = param.Number(
+        default=1.0, bounds=(0, 7)
+    )  # Bounds will be further enforced once `DataParameters` is replaced with `get_data`
     wrf_bias_adjust = param.Boolean(default=True, doc="Flag for WRF bias adjustment")
     historical_data = param.ObjectSelector(
         default="Historical Climate",
@@ -398,15 +397,6 @@ def validate_params(self):
                 "Historical Reconstruction data can only be retrieved using a time-based approach."
             )
 
-        # Validating warming level inputs
-        if (
-            self.approach == "Warming Level"
-            and self.warming_level not in WARMING_LEVELS
-        ):
-            errors.append(
-                "Warming level must be one of " + ", ".join(map(str, WARMING_LEVELS))
-            )
-
         # Validating that only one of heat_idx_threshold, percentile, or one_in_x is passed
         if (
             (self.heat_idx_threshold is not None and self.percentile is not None)
@@ -487,16 +477,19 @@ def get_names(self):
             elif self.metric_calc == "min":
                 variable = "Minimum air temperature at 2m"
 
+        # Reducing potential decimal places for warming level
+        self.warming_level = round(self.warming_level, 3)
+
         # Adding time period or warming level to filename
         if self.approach == "Time":
             approach_str = f"{self.time_start_year}_to_{self.time_end_year}"
             approach_var_name = f"from {approach_str}"
         elif self.approach == "Warming Level":
-            wl_str = (
-                str(int(self.warming_level))
-                if self.warming_level not in [0.8, 1.2, 1.5, 2.5]
-                else str(self.warming_level).replace(".", "pt")
-            )
+            if float(self.warming_level).is_integer():
+                wl_str = str(int(self.warming_level))
+            else:
+                wl_str = str(self.warming_level).replace(".", "pt")
+
             approach_str = f"{wl_str}degreeWL"
             approach_var_name = f"for Warming Level {self.warming_level}°C"
 
diff --git a/climakitae/util/utils.py b/climakitae/util/utils.py
index 7c9d891a7..90cedf746 100644
--- a/climakitae/util/utils.py
+++ b/climakitae/util/utils.py
@@ -12,12 +12,16 @@
 from shapely.geometry import mapping
 from typing import Any
 import xarray as xr
+import intake
 from timezonefinder import TimezoneFinder
 
-from climakitae.core.constants import SSPS, UNSET
+from climakitae.core.constants import SSPS, UNSET, WARMING_LEVELS
 
 # from climakitae.core.data_interface import DataParameters
-from climakitae.core.paths import data_catalog_url, stations_csv_path
+from climakitae.core.paths import (
+    data_catalog_url,
+    stations_csv_path,
+)
 
 
 def downscaling_method_as_list(downscaling_method: str) -> list[str]:
diff --git a/climakitae/util/warming_levels.py b/climakitae/util/warming_levels.py
index b910bea85..b0d895cd8 100644
--- a/climakitae/util/warming_levels.py
+++ b/climakitae/util/warming_levels.py
@@ -1,13 +1,16 @@
 """Helper functions related to applying a warming levels approach to a data object"""
 
-import xarray as xr
+import calendar
 import numpy as np
 import pandas as pd
-import calendar
+import xarray as xr
+import intake
+
 from typing import Union
 from climakitae.util.utils import (
     scenario_to_experiment_id,
     _get_cat_subset,
+    resolution_to_gridlabel,
     read_csv_file,
 )
 from climakitae.core.paths import (
@@ -17,6 +20,9 @@
     ssp370_file,
     ssp585_file,
     hist_file,
+    data_catalog_url,
+    GWL_1850_1900_TIMEIDX_FILE,
+    gwl_1850_1900_file,
 )
 
 
@@ -224,6 +230,130 @@ def drop_invalid_sims(ds, selections):
     return ds.sel(all_sims=valid_sim_list)
 
 
+def read_warming_level_csvs() -> tuple[pd.DataFrame, pd.DataFrame]:
+    """
+    Reads two CSV files containing global warming level (GWL) data.
+
+    Returns:
+        tuple:
+            - df (pd.DataFrame): Time-indexed DataFrame (time as index, simulations as columns).
+            - other_df (pd.DataFrame): DataFrame with warming levels per simulation (no datetime index).
+    """
+    df = read_csv_file(GWL_1850_1900_TIMEIDX_FILE, index_col="time", parse_dates=True)
+    other_df = read_csv_file(gwl_1850_1900_file)
+    return df, other_df
+
+
+def get_wl_timestamp(series: pd.Series, degree: float) -> Union[str, float]:
+    """
+    Finds the first timestamp when the series crosses the specified warming level.
+
+    Parameters:
+        series (pd.Series): A time-indexed warming level series.
+        degree (float): Target warming level.
+
+    Returns:
+        str or float: Timestamp as string if crossed, else np.nan.
+    """
+    if any(series >= degree):
+        return series[series >= degree].index[0].strftime("%Y-%m-%d %H:%M")
+    return np.nan
+
+
+def create_new_warming_level_table(warming_level: float) -> pd.DataFrame:
+    """
+    Returns a table of timestamps when each simulation reaches the given warming level.
+
+    Parameters:
+        warming_level (float): New WL to retrieve WL timing for.
+
+    Returns:
+        pd.DataFrame: Same DataFrame as `data/gwl_1850-1900ref.csv`, just with a new WL columns with the `warming_level` arg passed.
+    """
+    df, other_df = read_warming_level_csvs()
+
+    # Map each simulation to its crossing timestamp for the given warming level
+    wl_timestamps = {
+        col: get_wl_timestamp(df[col], warming_level) for col in df.columns
+    }
+
+    result = other_df.copy(deep=True)
+    result["sim"] = result["GCM"] + "_" + result["run"] + "_" + result["scenario"]
+    timestamp_series = pd.Series(wl_timestamps)
+
+    result[str(warming_level)] = result["sim"].map(timestamp_series)
+    result = result.drop(columns="sim")
+    result = result.set_index(["GCM", "run", "scenario"])
+
+    return result
+
+
+def filter_warming_trajectories_to_ae(
+    simulations_df: pd.DataFrame,
+    warming_trajectories: pd.DataFrame,
+    downscaling_method: str,
+) -> pd.DataFrame:
+    """
+    Filters all simulations in `warming_trajectories` to only the ones we have on AE (`simulations_df`).
+    Does this filtering by `downscaling_method` as well.
+
+    Parameters:
+        simulations_df (pd.DataFrame): Complete simulation dataframe of all simulations in GWL tables.
+        warming_trajectories (pd.DataFrame): Full warming trajectory DataFrame, computed from `read_warming_level_csvs`.
+        downscaling_method (str): Downscaling method to filter DataFrame by ('LOCA' or 'WRF').
+
+    Returns:
+        pd.DataFrame: Filtered `simulations_df` to only simulations accessible on AE.
+    """
+    columns_to_keep = []
+    activity_simulations = simulations_df[
+        simulations_df["activity_id"] == downscaling_method
+    ]
+
+    for _, row in activity_simulations.iterrows():
+        pattern = f"{row['source_id']}_{row['member_id']}_{row['experiment_id']}"
+        matches = [col for col in warming_trajectories.columns if pattern in col]
+        columns_to_keep.extend(matches)
+
+    return warming_trajectories[columns_to_keep]
+
+
+def create_ae_warming_trajectories(
+    resolution: str,
+) -> tuple[pd.DataFrame, pd.DataFrame]:
+    """
+    Creates warming trajectories for all AE simulations based on a given resolution.
+    This resolution is an important parameter because not all resolutions have the same number of WRF simulations (i.e. 3km has 8 but 9km has 10).
+
+    Parameters:
+        resolution (str): Grid resolution (e.g., "6km", "12km").
+
+    Returns:
+        tuple:
+            - LOCA2 warming trajectories (pd.DataFrame)
+            - WRF warming trajectories (pd.DataFrame)
+    """
+    df = intake.open_esm_datastore(data_catalog_url).df
+    grid_label = resolution_to_gridlabel(resolution)
+
+    # Only select simulations with the given grid label, since WRF has a different number of simulations depending on the spatial resolution
+    select_sims = df[df["grid_label"] == grid_label]
+
+    simulations_df = (
+        select_sims[["activity_id", "source_id", "experiment_id", "member_id"]]
+        .drop_duplicates()
+        .reset_index(drop=True)
+    )
+    warming_trajectories, _ = read_warming_level_csvs()
+
+    loca2 = filter_warming_trajectories_to_ae(
+        simulations_df, warming_trajectories, "LOCA2"
+    )
+    wrf = filter_warming_trajectories_to_ae(simulations_df, warming_trajectories, "WRF")
+
+    return loca2, wrf
+
+
 def generate_ssp_dict() -> dict[str, pd.DataFrame]:
     """
     Loads historical and SSP scenario CSVs into one dictionary.


Reviews:
user: login: neilSchroeder
body: Needs some hygiene improvements
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T20:56:40Z
user: login: neilSchroeder
body: [PEP8 Naming Convention for Constants](https://peps.python.org/pep-0008/#constants)
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-30T13:35:08Z
user: login: acordonez
body: I'm getting an import error in the warming_levels_approach notebook:
```
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import climakitae as ck
from climakitaegui.explore import warming_levels
from climakitaegui.explore.warming import fit_models_and_plots
from climakitae.explore.threshold_tools import get_exceedance_count
from climakitae.util.utils import get_closest_gridcell, add_dummy_time_to_wl

---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Cell In[1], line 4
      2 import pandas as pd
      3 import matplotlib.pyplot as plt
----> 4 import climakitae as ck
      5 from climakitaegui.explore import warming_levels
      6 from climakitaegui.explore.warming import fit_models_and_plots

File [/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/__init__.py:1](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/__init__.py#line=0)
----> 1 from climakitae.core.data_load import load
      2 from climakitae.core.data_export import export, remove_zarr
      4 try:

File [/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/core/data_load.py:32](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/core/data_load.py#line=31)
     30 from climakitae.tools.indices import effective_temp, fosberg_fire_index, noaa_heat_index
     31 from climakitae.util.unit_conversions import convert_units, get_unit_conversion_options
---> 32 from climakitae.util.utils import (
     33     _get_cat_subset,
     34     _get_scenario_from_selections,
     35     area_average,
     36     downscaling_method_as_list,
     37     downscaling_method_to_activity_id,
     38     get_closest_gridcell,
     39     readable_bytes,
     40     resolution_to_gridlabel,
     41     scenario_to_experiment_id,
     42     timescale_to_table_id,
     43     create_new_warming_level_table,
     44 )
     45 from climakitae.util.warming_levels import calculate_warming_level, drop_invalid_sims
     47 # Importing DataParameters causes ImportError due to circular import
     48 # so only import for type checking and reference as str 'DataParameters'

ImportError: cannot import name 'create_new_warming_level_table' from 'climakitae.util.utils' ([/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/util/utils.py](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/util/utils.py))
```
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T17:41:59Z
user: login: neilSchroeder
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-30T17:48:46Z
user: login: neilSchroeder
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-30T20:18:14Z
user: login: acordonez
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T21:21:42Z

Review comments:
diff_hunk: @@ -2023,14 +2019,30 @@ def _check_valid_input_station(
 
     # Internal functions
     def _error_handling_warming_level_inputs(
-        wl: Union[list[float], list[int]], argument_name: str
+        wl: Union[list[float], list[int]],
+        argument_name: str,
+        downscaling_method: str,
+        resolution: str,
     ):
         """
         Error handling for arguments: warming_level and warming_level_month
         Both require a list of either floats or ints
         argument_name is either "warming_level" or "warming_level_months" and is used to
         print an appropriate error message for bad input
         """
+        # Find the WL bounds for LOCA and WRF
+        loca, wrf = create_ae_warming_trajectories(resolution)
+        loca_max = round(loca.max().max(), 2)
+        wrf_max = round(wrf.max().max(), 2)
+
+        match downscaling_method:
+            case "Statistical":
+                max_val = loca_max
+            case "Dynamical":
+                max_val = wrf_max
+            case "Dynamical+Statistical":
+                max_val = min(loca_max, wrf_max)
path: climakitae/core/data_interface.py
user: login: neilSchroeder
body: Should have a wildcard case here:
```python
case _:
    raise ValueError("must be Statistical, Dynamical, Dynamical+Statistical")
```
or something

created_at: 2025-05-29T20:50:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1188,6 +1189,63 @@ def stack_sims_across_locs(ds):
     return ds
 
 
+def read_warming_level_csvs() -> tuple[pd.DataFrame, pd.DataFrame]:
+    """
+    Reads two CSV files containing global warming level (GWL) data.
+
+    Returns:
+        tuple:
+            - df (pd.DataFrame): Time-indexed DataFrame with simulations as columns, filled with WL values at each simulation x time combination.
+              Loaded from 'data/gwl_1850-1900ref_timeidx.csv' with 'time' as the parsed datetime index.
+
+            - other_df (pd.DataFrame): DataFrame containing WL information with warming levels as the columns, and simulations as the index.
+              Loaded from 'data/gwl_1850-1900ref.csv' without a datetime index.
+    """
+    df = read_csv_file(
+        "data/gwl_1850-1900ref_timeidx.csv", index_col="time", parse_dates=True
path: climakitae/util/utils.py
user: login: neilSchroeder
body: file paths should be move to `constants.py` or `paths.py` and imported.
created_at: 2025-05-29T20:51:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1188,6 +1189,63 @@ def stack_sims_across_locs(ds):
     return ds
 
 
+def read_warming_level_csvs() -> tuple[pd.DataFrame, pd.DataFrame]:
+    """
+    Reads two CSV files containing global warming level (GWL) data.
+
+    Returns:
+        tuple:
+            - df (pd.DataFrame): Time-indexed DataFrame with simulations as columns, filled with WL values at each simulation x time combination.
+              Loaded from 'data/gwl_1850-1900ref_timeidx.csv' with 'time' as the parsed datetime index.
+
+            - other_df (pd.DataFrame): DataFrame containing WL information with warming levels as the columns, and simulations as the index.
+              Loaded from 'data/gwl_1850-1900ref.csv' without a datetime index.
+    """
+    df = read_csv_file(
+        "data/gwl_1850-1900ref_timeidx.csv", index_col="time", parse_dates=True
+    )
+    other_df = read_csv_file("data/gwl_1850-1900ref.csv")
path: climakitae/util/utils.py
user: login: neilSchroeder
body: same here
created_at: 2025-05-29T20:51:21Z
author_association: COLLABORATOR
diff_hunk: @@ -1188,6 +1189,63 @@ def stack_sims_across_locs(ds):
     return ds
 
 
+def read_warming_level_csvs() -> tuple[pd.DataFrame, pd.DataFrame]:
+    """
+    Reads two CSV files containing global warming level (GWL) data.
+
+    Returns:
+        tuple:
+            - df (pd.DataFrame): Time-indexed DataFrame with simulations as columns, filled with WL values at each simulation x time combination.
+              Loaded from 'data/gwl_1850-1900ref_timeidx.csv' with 'time' as the parsed datetime index.
+
+            - other_df (pd.DataFrame): DataFrame containing WL information with warming levels as the columns, and simulations as the index.
+              Loaded from 'data/gwl_1850-1900ref.csv' without a datetime index.
+    """
+    df = read_csv_file(
+        "data/gwl_1850-1900ref_timeidx.csv", index_col="time", parse_dates=True
+    )
+    other_df = read_csv_file("data/gwl_1850-1900ref.csv")
+    return df, other_df
+
+
+def get_wl_timestamp(series: pd.Series, degree: float) -> str:
+    """
+    Find the first timestamp where the warming level crosses the specified degree.
+    Return timestamp as string; return np.nan if never crosses.
path: climakitae/util/utils.py
user: login: neilSchroeder
body: Needs Parameters and Returns in docstring
created_at: 2025-05-29T20:51:58Z
author_association: COLLABORATOR
diff_hunk: @@ -1269,3 +1327,55 @@ def clip_to_shapefile(
     clipped.attrs["location_subset"] = location
 
     return clipped
+
+
+def filter_warming_trajectories(simulations_df, warming_trajectories, activity):
path: climakitae/util/utils.py
user: login: neilSchroeder
body: Needs type hints and docstring
created_at: 2025-05-29T20:54:42Z
author_association: COLLABORATOR
diff_hunk: @@ -1269,3 +1327,55 @@ def clip_to_shapefile(
     clipped.attrs["location_subset"] = location
 
     return clipped
+
+
+def filter_warming_trajectories(simulations_df, warming_trajectories, activity):
+    columns_to_keep = []
+
+    # Filter simulations_df for the specific activity
+    activity_simulations = simulations_df[simulations_df["activity_id"] == activity]
+
+    # Iterate through each row in the filtered simulations_df
+    for _, row in activity_simulations.iterrows():
+        # Construct the column name pattern
+        column_pattern = f"{row['source_id']}_{row['member_id']}_{row['experiment_id']}"
+
+        # Find matching columns in warming_trajectories
+        matching_columns = [
+            col for col in warming_trajectories.columns if column_pattern in col
+        ]
+
+        # Add matching columns to our list
+        columns_to_keep.extend(matching_columns)
+
+    # Create a new DataFrame with only the relevant columns
+    filtered_trajectories = warming_trajectories[columns_to_keep]
+
+    return filtered_trajectories
+
+
+def create_ae_warming_trajectories(resolution):
path: climakitae/util/utils.py
user: login: neilSchroeder
body: Needs docstring and type hints

created_at: 2025-05-29T20:55:22Z
author_association: COLLABORATOR
diff_hunk: @@ -23,6 +23,8 @@
 # Global warming levels files
 gwl_1850_1900_file = "data/gwl_1850-1900ref.csv"
 gwl_1981_2010_file = "data/gwl_1981-2010ref.csv"
+gwl_1850_1900_timeidx_file = "data/gwl_1850-1900ref_timeidx.csv"
+gwl_1981_2010_timeidx_file = "data/gwl_1981-2010ref_timeidx.csv"
path: climakitae/core/paths.py
user: login: neilSchroeder
body: Constant variables should be named in all caps. No need to fix everything in this file, just fix yours for now.
created_at: 2025-05-30T13:32:35Z
author_association: COLLABORATOR
diff_hunk: @@ -23,6 +23,8 @@
 # Global warming levels files
 gwl_1850_1900_file = "data/gwl_1850-1900ref.csv"
 gwl_1981_2010_file = "data/gwl_1981-2010ref.csv"
+gwl_1850_1900_timeidx_file = "data/gwl_1850-1900ref_timeidx.csv"
+gwl_1981_2010_timeidx_file = "data/gwl_1981-2010ref_timeidx.csv"
path: climakitae/core/paths.py
user: login: neilSchroeder
body: Use F2 to rename the symbol from this file. VSCode should take care of making all the other necessary changes.
created_at: 2025-05-30T13:33:32Z
author_association: COLLABORATOR

PR comments:
user: login: claalmve
created_at: 2025-05-29T18:12:48Z
body: I am thinking about moving the added `utils` functions (and other appropriate WL util functions) to another file called `warming_level_utils.py`. Thoughts?
author_association: COLLABORATOR
user: login: acordonez
created_at: 2025-05-29T18:22:16Z
body: > I am thinking about moving the added `utils` functions (and other appropriate WL util functions) to another file called `warming_level_utils.py`. Thoughts?

Maybe a silly question as I'm not familiar with this code, but why not add to util/warming_levels.py? Not opposed to warming_level_utils.py though, but it'd be nice to understand what the distinction is between the two.
author_association: CONTRIBUTOR
user: login: claalmve
created_at: 2025-05-30T00:03:30Z
body: > > I am thinking about moving the added `utils` functions (and other appropriate WL util functions) to another file called `warming_level_utils.py`. Thoughts?
> 
> Maybe a silly question as I'm not familiar with this code, but why not add to util/warming_levels.py? Not opposed to warming_level_utils.py though, but it'd be nice to understand what the distinction is between the two.

Good point, I should move it there. I got `utils/warming_levels.py` mistaken with `warming.py`, `utils/warming_levels.py` would be a great place for it.
author_association: COLLABORATOR
user: login: neilSchroeder
created_at: 2025-05-30T17:56:46Z
body: Yeah that same import error is showing up in the tests as well.
author_association: COLLABORATOR
user: login: claalmve
created_at: 2025-05-30T21:01:33Z
body: > I'm getting an import error in the warming_levels_approach notebook:
> 
> ```
> import numpy as np
> import pandas as pd
> import matplotlib.pyplot as plt
> import climakitae as ck
> from climakitaegui.explore import warming_levels
> from climakitaegui.explore.warming import fit_models_and_plots
> from climakitae.explore.threshold_tools import get_exceedance_count
> from climakitae.util.utils import get_closest_gridcell, add_dummy_time_to_wl
> 
> ---------------------------------------------------------------------------
> ImportError                               Traceback (most recent call last)
> Cell In[1], line 4
>       2 import pandas as pd
>       3 import matplotlib.pyplot as plt
> ----> 4 import climakitae as ck
>       5 from climakitaegui.explore import warming_levels
>       6 from climakitaegui.explore.warming import fit_models_and_plots
> 
> File [/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/__init__.py:1](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/__init__.py#line=0)
> ----> 1 from climakitae.core.data_load import load
>       2 from climakitae.core.data_export import export, remove_zarr
>       4 try:
> 
> File [/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/core/data_load.py:32](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/core/data_load.py#line=31)
>      30 from climakitae.tools.indices import effective_temp, fosberg_fire_index, noaa_heat_index
>      31 from climakitae.util.unit_conversions import convert_units, get_unit_conversion_options
> ---> 32 from climakitae.util.utils import (
>      33     _get_cat_subset,
>      34     _get_scenario_from_selections,
>      35     area_average,
>      36     downscaling_method_as_list,
>      37     downscaling_method_to_activity_id,
>      38     get_closest_gridcell,
>      39     readable_bytes,
>      40     resolution_to_gridlabel,
>      41     scenario_to_experiment_id,
>      42     timescale_to_table_id,
>      43     create_new_warming_level_table,
>      44 )
>      45 from climakitae.util.warming_levels import calculate_warming_level, drop_invalid_sims
>      47 # Importing DataParameters causes ImportError due to circular import
>      48 # so only import for type checking and reference as str 'DataParameters'
> 
> ImportError: cannot import name 'create_new_warming_level_table' from 'climakitae.util.utils' ([/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/util/utils.py](http://127.0.0.1:8888/srv/conda/envs/notebook/lib/python3.12/site-packages/climakitae/util/utils.py))
> ```

ImportErrors should all be fixed now!
author_association: COLLABORATOR