Skip to content

Commit

Permalink
Upgrade ensure_cube_aggregatable_across_cases to public operator
Browse files Browse the repository at this point in the history
  • Loading branch information
daflack committed Jan 30, 2025
1 parent 19bfb50 commit 95ea2dc
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 118 deletions.
50 changes: 0 additions & 50 deletions src/CSET/operators/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,53 +221,3 @@ def is_time_aggregatable(cube: iris.cube.Cube) -> bool:
temporal_coords = [coord for coord in coord_names if coord in TEMPORAL_COORD_NAMES]
# Return whether both coordinates are in the temporal coordinates.
return len(temporal_coords) == 2


def ensure_aggregatable_across_cases(
cube: iris.cube.Cube | iris.cube.CubeList,
) -> iris.cube.Cube:
"""Ensure a Cube or CubeList can be aggregated across multiple cases.
Arguments
---------
cube: iris.cube.Cube | iris.cube.CubeList
If a Cube is provided it is checked to determine if it has the
the necessary dimensional coordinates to be aggregatable.
These necessary coordinates are 'forecast_period' and
'forecast_reference_time'.If a CubeList is provided a Cube is created
by slicing over all time coordinates and the resulting list is merged
to create an aggregatable cube.
Returns
-------
cube: iris.cube.Cube
A time aggregatable cube with dimension coordinates including
'forecast_period' and 'forecast_reference_time'.
Raises
------
ValueError
If a Cube is provided and it is not aggregatable a ValueError is
raised. The user should then provide a CubeList to be turned into an
aggregatable cube to allow aggregation across multiple cases to occur.
"""
# Check to see if a cube is input and if that cube is iterable.
if isinstance(cube, iris.cube.Cube):
if is_time_aggregatable(cube):
return cube
else:
raise ValueError(
"Single Cube should have 'forecast_period' and"
"'forecast_reference_time' dimensional coordinates. "
"To make a time aggregatable Cube input a CubeList."
)
# Create an aggregatable cube from the provided CubeList.
else:
new_cube_list = iris.cube.CubeList()
for sub_cube in cube:
for cube_slice in sub_cube.slices_over(
["forecast_period", "forecast_reference_time"]
):
new_cube_list.append(cube_slice)
new_merged_cube = new_cube_list.merge_cube()
return new_merged_cube
3 changes: 2 additions & 1 deletion src/CSET/operators/collapse.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
import iris.util

from CSET._common import iter_maybe
from CSET.operators._utils import ensure_aggregatable_across_cases, is_time_aggregatable
from CSET.operators._utils import is_time_aggregatable
from CSET.operators.misc import ensure_aggregatable_across_cases


def collapse(
Expand Down
60 changes: 60 additions & 0 deletions src/CSET/operators/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from CSET.operators._utils import (
fully_equalise_attributes,
get_cube_yxcoordname,
is_time_aggregatable,
)


Expand Down Expand Up @@ -332,3 +333,62 @@ def is_increasing(sequence: list) -> bool:
difference.rename(base.name() + "_difference")
difference.data = base.data - other.data
return difference


def ensure_aggregatable_across_cases(
cube: iris.cube.Cube | iris.cube.CubeList,
) -> iris.cube.Cube:
"""Ensure a Cube or CubeList can be aggregated across multiple cases.
Arguments
---------
cube: iris.cube.Cube | iris.cube.CubeList
If a Cube is provided it is checked to determine if it has the
the necessary dimensional coordinates to be aggregatable.
These necessary coordinates are 'forecast_period' and
'forecast_reference_time'.If a CubeList is provided a Cube is created
by slicing over all time coordinates and the resulting list is merged
to create an aggregatable cube.
Returns
-------
cube: iris.cube.Cube
A time aggregatable cube with dimension coordinates including
'forecast_period' and 'forecast_reference_time'.
Raises
------
ValueError
If a Cube is provided and it is not aggregatable a ValueError is
raised. The user should then provide a CubeList to be turned into an
aggregatable cube to allow aggregation across multiple cases to occur.
Notes
-----
This is a simple operator designed to ensure that a Cube is aggregatable
across cases. If a CubeList is presented it will create an aggregatable Cube
from that list. Its functionality is for case study (or trial) aggregation
to ensure that the full dataset can be loaded as a single cube. This
functionality is particularly useful for percentiles, Q-Q plots, and
histograms.
"""
# Check to see if a cube is input and if that cube is iterable.
if isinstance(cube, iris.cube.Cube):
if is_time_aggregatable(cube):
return cube
else:
raise ValueError(
"Single Cube should have 'forecast_period' and"
"'forecast_reference_time' dimensional coordinates. "
"To make a time aggregatable Cube input a CubeList."
)
# Create an aggregatable cube from the provided CubeList.
else:
new_cube_list = iris.cube.CubeList()
for sub_cube in cube:
for cube_slice in sub_cube.slices_over(
["forecast_period", "forecast_reference_time"]
):
new_cube_list.append(cube_slice)
new_merged_cube = new_cube_list.merge_cube()
return new_merged_cube
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,27 @@ def transect_source_cube_readonly():
def transect_source_cube(transect_source_cube_readonly):
"""Get a 3D cube to test with. It is safe to modify."""
return transect_source_cube_readonly.copy()


@pytest.fixture()
def long_forecast():
"""Get long_forecast to run tests on."""
return read.read_cube(
"tests/test_data/long_forecast_air_temp_fcst_1.nc", "air_temperature"
)


@pytest.fixture()
def long_forecast_multi_day():
"""Get long_forecast_multi_day to run tests on."""
return read.read_cube(
"tests/test_data/long_forecast_air_temp_multi_day.nc", "air_temperature"
)


@pytest.fixture()
def long_forecast_many_cubes():
"""Get long_forecast_may_cubes to run tests on."""
return read.read_cubes(
"tests/test_data/long_forecast_air_temp_fcst_*.nc", "air_temperature"
)
39 changes: 39 additions & 0 deletions tests/operators/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,42 @@ def test_difference_different_model_types(cube):
assert np.allclose(
difference_cube.data, np.zeros_like(difference_cube.data), atol=1e-9
)


def test_ensure_aggregatable_across_cases_true_aggregatable_cube(
long_forecast_multi_day,
):
"""Check that an aggregatable cube is returned with no changes."""
assert np.allclose(
misc.ensure_aggregatable_across_cases(long_forecast_multi_day).data,
long_forecast_multi_day.data,
rtol=1e-06,
atol=1e-02,
)


def test_ensure_aggregatable_across_cases_false_aggregatable_cube(long_forecast):
"""Check that a non-aggregatable cube raises an error."""
with pytest.raises(ValueError):
misc.ensure_aggregatable_across_cases(long_forecast)


def test_ensure_aggregatable_across_cases_cubelist(
long_forecast_many_cubes, long_forecast_multi_day
):
"""Check that a CubeList turns into an aggregatable Cube."""
# Check output is a Cube.
output_data = misc.ensure_aggregatable_across_cases(long_forecast_many_cubes)
assert isinstance(output_data, iris.cube.Cube)
# Check output can be aggregated in time.
assert isinstance(
misc.ensure_aggregatable_across_cases(output_data), iris.cube.Cube
)
# Check output is identical to a pre-calculated cube.
pre_calculated_data = long_forecast_multi_day
assert np.allclose(
pre_calculated_data.data,
output_data.data,
rtol=1e-06,
atol=1e-02,
)
67 changes: 0 additions & 67 deletions tests/operators/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,9 @@
import iris
import iris.coords
import iris.cube
import numpy as np
import pytest

import CSET.operators._utils as operator_utils
from CSET.operators import read


@pytest.fixture()
def long_forecast() -> iris.cube.Cube:
"""Get long_forecast to run tests on."""
return read.read_cube(
"tests/test_data/long_forecast_air_temp_fcst_1.nc", "air_temperature"
)


@pytest.fixture()
def long_forecast_multi_day() -> iris.cube.Cube:
"""Get long_forecast_multi_day to run tests on."""
return read.read_cube(
"tests/test_data/long_forecast_air_temp_multi_day.nc", "air_temperature"
)


@pytest.fixture()
def long_forecast_many_cubes() -> iris.cube.Cube:
"""Get long_forecast_may_cubes to run tests on."""
return read.read_cubes(
"tests/test_data/long_forecast_air_temp_fcst_*.nc", "air_temperature"
)


def test_missing_coord_get_cube_yxcoordname_x(regrid_rectilinear_cube):
Expand Down Expand Up @@ -171,44 +145,3 @@ def test_is_time_aggregatable_False(long_forecast):
def test_is_time_aggregatable(long_forecast_multi_day):
"""Check that a time aggregatable cube returns True."""
assert operator_utils.is_time_aggregatable(long_forecast_multi_day)


def test_ensure_aggregatable_across_cases_true_aggregatable_cube(
long_forecast_multi_day,
):
"""Check that an aggregatable cube is returned with no changes."""
assert np.allclose(
operator_utils.ensure_aggregatable_across_cases(long_forecast_multi_day).data,
long_forecast_multi_day.data,
rtol=1e-06,
atol=1e-02,
)


def test_ensure_aggregatable_across_cases_false_aggregatable_cube(long_forecast):
"""Check that a non-aggregatable cube raises an error."""
with pytest.raises(ValueError):
operator_utils.ensure_aggregatable_across_cases(long_forecast)


def test_ensure_aggregatable_across_cases_cubelist(
long_forecast_many_cubes, long_forecast_multi_day
):
"""Check that a CubeList turns into an aggregatable Cube."""
# Check output is a Cube.
output_data = operator_utils.ensure_aggregatable_across_cases(
long_forecast_many_cubes
)
assert isinstance(output_data, iris.cube.Cube)
# Check output can be aggregated in time.
assert isinstance(
operator_utils.ensure_aggregatable_across_cases(output_data), iris.cube.Cube
)
# Check output is identical to a pre-calculated cube.
pre_calculated_data = long_forecast_multi_day
assert np.allclose(
pre_calculated_data.data,
output_data.data,
rtol=1e-06,
atol=1e-02,
)

0 comments on commit 95ea2dc

Please sign in to comment.