Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure multiple cases are put into a single cube #1050

Merged
merged 8 commits into from
Jan 22, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Adds tests and function to ensure cube is aggregatable across
multiple cases.

Fixes #1048
daflack committed Jan 22, 2025
commit a9405e829815954a0ffa9db008aeec487d088521
84 changes: 83 additions & 1 deletion src/CSET/operators/_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# © Crown copyright, Met Office (2022-2024) and CSET contributors.
# © Crown copyright, Met Office (2022-2025) and CSET contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -189,3 +189,85 @@ def fully_equalise_attributes(cubes: iris.cube.CubeList):
logging.debug("Removed attributes from coordinate %s: %s", coord, removed)

return cubes


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 a sub-operator is called to determine if the
cube has the necessary dimensional coordinates to be aggregateable. If
a CubeList is provided a Cube is created by slicing over all time
coordinates and 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.
"""

def is_time_aggregatable(cube: iris.cube.Cube) -> bool:
"""Determine whether a cube can be aggregated in time.

If a cube is aggregatable it will contain both a 'forecast_reference_time'
and 'forecast_period' coordinate as dimensional coordinates.

Arguments
---------
cube: iris.cube.Cube
An iris cube which will be checked to see if it is aggregatable based
on a set of pre-defined dimensional time coordinates.

Returns
-------
bool
If true, then the cube is aggregatable and contains dimensional
coordinates including both 'forecast_reference_time' and
'forecast_period'.
"""
# Acceptable time coordinate names for aggregatable cube.
TEMPORAL_COORD_NAMES = ["forecast_period", "forecast_reference_time"]

# Coordinate names for the cube.
coord_names = [coord.name() for coord in cube.coords(dim_coords=True)]

# Check which temporal coordinates we have.
temporal_coords = [
coord for coord in coord_names if coord in TEMPORAL_COORD_NAMES
]
if len(temporal_coords) != 2:
return False

# Passed criterion so return True.
return True

# 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 x in cube:
for y in x.slices_over(["forecast_period", "forecast_reference_time"]):
new_cube_list.append(y)
new_list_merged = new_cube_list.merge()[0]
return new_list_merged
68 changes: 67 additions & 1 deletion tests/operators/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# © Crown copyright, Met Office (2022-2024) and CSET contributors.
# © Crown copyright, Met Office (2022-2025) and CSET contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,11 +17,36 @@
import iris
import iris.coords
import iris.cube
import numpy as np
import pytest

import CSET.operators._utils as operator_utils


@pytest.fixture()
def long_forecast() -> iris.cube.Cube:
"""Get long_forecast to run tests on."""
return iris.load_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 iris.load_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 iris.load(
"tests/test_data/long_forecast_air_temp_fcst_*.nc", "air_temperature"
)


def test_missing_coord_get_cube_yxcoordname_x(regrid_rectilinear_cube):
"""Missing X coordinate raises error."""
regrid_rectilinear_cube.remove_coord("grid_longitude")
@@ -137,3 +162,44 @@ def test_fully_equalise_attributes_equalise_coords():
fixed_cubes = operator_utils.fully_equalise_attributes([c1, c2])
for cube in fixed_cubes:
assert "shared_attribute" not in cube.coord("foo").attributes


def test_ensure_aggregatable_across_cases_true_aggregateable_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_aggregateable_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,
)
Binary file added tests/test_data/long_forecast_air_temp_fcst_2.nc
Binary file not shown.
Binary file added tests/test_data/long_forecast_air_temp_fcst_3.nc
Binary file not shown.
Binary file not shown.