Skip to content

Commit

Permalink
Merge branch 'master' into epp1965_virtualtemperature
Browse files Browse the repository at this point in the history
  • Loading branch information
mo-philrelton authored Dec 11, 2024
2 parents a0b412f + 3c02114 commit 9454738
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 47 deletions.
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Neil Crosswaite <[email protected]> <43375279+neilCrosswaite@user
Paul Abernethy <[email protected]> <[email protected]>
Peter Jordan <[email protected]> <[email protected]>
Phil Relton <[email protected]> <[email protected]>
Phoebe Lambert <[email protected]> <[email protected]>
Sam Griffiths <[email protected]> <[email protected]>
Shafiat Dewan <[email protected]> <[email protected]>
Shubhendra Singh Chauhan <[email protected]> <[email protected]>
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ below:
- Caroline Jones (Met Office, UK)
- Peter Jordan (Met Office, UK)
- Bruno P. Kinoshita (NIWA, NZ)
- Phoebe Lambert (Met Office, UK)
- Lucy Liu (Bureau of Meteorology, Australia)
- Daniel Mentiplay (Bureau of Meteorology, Australia)
- Stephen Moseley (Met Office, UK)
Expand Down
10 changes: 10 additions & 0 deletions improver/standardise.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ def _discard_redundant_cell_methods(cube: Cube) -> None:

cube.cell_methods = updated_cms

@staticmethod
def _remove_long_name_if_standard_name(cube: Cube) -> None:
"""
Remove the long_name attribute from cubes if the cube also has a standard_name defined
"""

if cube.standard_name and cube.long_name:
cube.long_name = None

def process(self, cube: Cube) -> Cube:
"""
Perform compulsory and user-configurable metadata adjustments. The
Expand Down Expand Up @@ -269,6 +278,7 @@ def process(self, cube: Cube) -> Cube:
if self._attributes_dict:
amend_attributes(cube, self._attributes_dict)
self._discard_redundant_cell_methods(cube)
self._remove_long_name_if_standard_name(cube)

# this must be done after unit conversion as if the input is an integer
# field, unit conversion outputs the new data as float64
Expand Down
106 changes: 88 additions & 18 deletions improver/utilities/copy_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# See LICENSE in the root of the repository for full licensing details.
from typing import List, Union

from dateutil import parser as dparser
from iris.cube import Cube, CubeList

from improver import BasePlugin
Expand Down Expand Up @@ -31,38 +32,107 @@ def __init__(self, attributes: List = [], aux_coord: List = []):
self.attributes = attributes
self.aux_coord = aux_coord

def process(self, *cubes: Union[Cube, CubeList]) -> Union[Cube, CubeList]:
@staticmethod
def get_most_recent_history(datelist: list) -> list:
"""
Gets the most recent history attribute from the list of provided dates.
Args:
datelist:
A list of dates to find the most recent calue from.
Returns:
The most recent history attribute.
"""
prev_time = None

for date in datelist:
new_time = dparser.parse(date, fuzzy=True)
if not prev_time:
prev_time = new_time
str_time = date
elif new_time > prev_time:
prev_time = new_time
str_time = date

return str_time

def find_common_attributes(self, cubes: CubeList, attributes: List) -> dict:
"""
Find the common attribute values between the cubes. If the attribute is history, the most recent
value will be returned.
Args:
cubes:
A list of template cubes to extract common attributes from.
attributes:
A list of attributes to be copied.
Returns:
A dictionary of common attributes.
Raises:
ValueError: If the attribute is not found in any of the template cubes
ValueError: If the attribute has different values in the provided template cubes.
"""
common_attributes = {}
for attribute in attributes:
attribute_value = [
cube.attributes.get(attribute)
for cube in cubes
if cube.attributes.get(attribute) is not None
]
if attribute == "history":
# We expect the history attribute to differ between cubes, so we will only keep the most recent one
common_attributes[attribute] = self.get_most_recent_history(
attribute_value
)
elif len(attribute_value) == 0:
raise ValueError(
f"Attribute {attribute} not found in any of the template cubes"
)
elif any(attr != attribute_value[0] for attr in attribute_value):
raise ValueError(
f"Attribute {attribute} has different values in the provided template cubes"
)
else:
common_attributes[attribute] = attribute_value[0]

return common_attributes

def process(self, *cubes: Union[Cube, CubeList]) -> Cube:
"""
Copy attribute or auxilary coordinate values from template_cube to cube,
overwriting any existing values.
overwriting any existing values. If the history attribute is present in
the list of requested attributes, the most recent value will be used. If an
auxilary coordinate needs to be copied then all template cubes must have the
auxilary coordinate present.
Operation is performed in-place on provided inputs.
Args:
cubes:
Source cube(s) to be updated. Final cube provided represents the template_cube.
List of cubes. First cube provided represents the cube to be updated. All
other cubes are treated as template cubes.
Returns:
Updated cube(s).
A cube with attributes copied from the template cubes
"""
cubes_proc = as_cubelist(*cubes)
if len(cubes_proc) < 2:
raise RuntimeError(
f"At least two cubes are required for this operation, got {len(cubes_proc)}"
)
template_cube = cubes_proc.pop()

for cube in cubes_proc:
new_attributes = {k: template_cube.attributes[k] for k in self.attributes}
amend_attributes(cube, new_attributes)
for coord in self.aux_coord:
# If coordinate is already present in the cube, remove it
if cube.coords(coord):
cube.remove_coord(coord)
cube.add_aux_coord(
template_cube.coord(coord),
data_dims=template_cube.coord_dims(coord=coord),
)
cube = cubes_proc.pop(0)
template_cubes = cubes_proc
new_attributes = self.find_common_attributes(template_cubes, self.attributes)
amend_attributes(cube, new_attributes)

return cubes_proc if len(cubes_proc) > 1 else cubes_proc[0]
for coord in self.aux_coord:
# If coordinate is already present in the cube, remove it
if cube.coords(coord):
cube.remove_coord(coord)
cube.add_aux_coord(
template_cubes[0].coord(coord),
data_dims=template_cubes[0].coord_dims(coord=coord),
)
return cube
8 changes: 8 additions & 0 deletions improver_tests/standardise/test_StandardiseMetadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,14 @@ def test_air_temperature_status_flag_coord_without_realization(self):
self.assertArrayEqual(result.data, target.data)
self.assertEqual(result.coords(), target.coords())

def test_long_name_removed(self):
cube = self.cube.copy()
cube.long_name = "kittens"
result = StandardiseMetadata().process(cube)
assert (
result.long_name is None
), "long_name removal expected, but long_name is not None"


if __name__ == "__main__":
unittest.main()
140 changes: 111 additions & 29 deletions improver_tests/utilities/copy_metadata/test_CopyMetadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import pytest
from iris.coords import AuxCoord
from iris.cube import Cube, CubeList
from iris.cube import Cube

from improver.utilities.copy_metadata import CopyMetadata

Expand All @@ -17,45 +17,92 @@ class HaltExecution(Exception):

@patch("improver.utilities.copy_metadata.as_cubelist")
def test_as_cubelist_called(mock_as_cubelist):
"""Test that the as_cubelist function is called."""
mock_as_cubelist.side_effect = HaltExecution
try:
CopyMetadata(["attribA", "attribB"])(
sentinel.cube0, sentinel.cube1, sentinel.template_cube
sentinel.cube0, sentinel.template_cube1, sentinel.template_cube2
)
except HaltExecution:
pass
mock_as_cubelist.assert_called_once_with(
sentinel.cube0, sentinel.cube1, sentinel.template_cube
sentinel.cube0, sentinel.template_cube1, sentinel.template_cube2
)


def test_copy_attributes_multi_input():
@pytest.mark.parametrize("history", [False, True])
def test_copy_attributes_multi_input(history):
"""
Test the copy_attributes function for multiple input cubes.
Test the copy_attributes function for multiple input template cubes.
Demonstrates copying attributes from the template cube to the input
cubes and also demonstrates the attributes on the templates cube that
aren't specified in the attributes list are indeed ignored.
Also demonstrates that the most recent history attribute is copied correctly if
present on multiple template cubes.
Note how we are verifying the object IDs, since CubeAttributes is an
in-place operation.
"""
attributes = ["attribA", "attribB"]
cube0 = Cube([0], attributes={"attribA": "valueA", "attribB": "valueB"})
cube1 = Cube([0], attributes={"attribA": "valueAA", "attribB": "valueBB"})
template_cube = Cube(
[0], attributes={"attribA": "tempA", "attribB": "tempB", "attribC": "tempC"}
[0],
attributes={
"attribA": "tempA",
"attribB": "tempB",
"attribC": "tempC",
"history": "2024-11-25T00:00:00Z",
},
)
template_cube_2 = Cube(
[0],
attributes={
"attribA": "tempA",
"attribC": "tempC",
"history": "2024-11-25T01:43:15Z",
},
)
if history:
attributes.append("history")

plugin = CopyMetadata(attributes)
result = plugin.process(cube0, template_cube, template_cube_2)
assert isinstance(result, Cube)
assert result.attributes["attribA"] == "tempA"
assert result.attributes["attribB"] == "tempB"
assert "attribC" not in result.attributes
assert result == cube0 # Checks cube has been altered in-place
if history:
assert result.attributes["history"] == "2024-11-25T01:43:15Z"
else:
assert "history" not in result.attributes


def test_copy_attributes_one_history_attribute():
"""Test that the history attribute is copied correctly if only one template cube has a history attribute."""
attributes = ["attribA", "attribB", "history"]
cube0 = Cube([0], attributes={"attribA": "valueA", "attribB": "valueB"})
template_cube = Cube(
[0],
attributes={
"attribA": "tempA",
"attribB": "tempB",
"attribC": "tempC",
"history": "2024-11-25T00:00:00Z",
},
)
template_cube_2 = Cube([0], attributes={"attribA": "tempA", "attribC": "tempC"})

plugin = CopyMetadata(attributes)
result = plugin.process(cube0, cube1, template_cube)
assert type(result) is CubeList
for res in result:
assert res.attributes["attribA"] == "tempA"
assert res.attributes["attribB"] == "tempB"
assert "attribC" not in res.attributes
assert id(result[0]) == id(cube0)
assert id(result[1]) == id(cube1)
result = plugin.process(cube0, template_cube_2, template_cube)
assert isinstance(result, Cube)
assert result.attributes["attribA"] == "tempA"
assert result.attributes["attribB"] == "tempB"
assert "attribC" not in result.attributes
assert result == cube0 # Checks cube has been altered in-place
assert result.attributes["history"] == "2024-11-25T00:00:00Z"


def test_copy_attributes_single_input():
Expand All @@ -72,12 +119,12 @@ def test_copy_attributes_single_input():

plugin = CopyMetadata(attributes)
result = plugin.process(cube0, template_cube)
assert type(result) is Cube
assert isinstance(result, Cube)
assert result.attributes["attribA"] == "tempA"
assert result.attributes["attribB"] == "tempB"
assert result.attributes["attribD"] == "valueD"
assert "attribC" not in result.attributes
assert id(result) == id(cube0)
assert result == cube0 # Checks cube has been altered in-place


@pytest.mark.parametrize("cubelist", [True, False])
Expand All @@ -101,21 +148,56 @@ def test_auxiliary_coord_modification(cubelist):

cube = Cube(data, aux_coords_and_dims=[(dummy_aux_coord_0, 0)])
# Create the cube with the auxiliary coordinates
template_cube = Cube(
template_cubes = Cube(
data,
aux_coords_and_dims=[(dummy_aux_coord_0_temp, 0), (dummy_aux_coord_1_temp, 0)],
)

cubes = cube
if cubelist:
cubes = [cube, cube]

template_cubes = [template_cubes, template_cubes]
plugin = CopyMetadata(aux_coord=auxiliary_coord)
result = plugin.process(cubes, template_cube)
if cubelist:
for res in result:
assert res.coord("dummy_0 status_flag") == dummy_aux_coord_0_temp
assert res.coord("dummy_1 status_flag") == dummy_aux_coord_1_temp
else:
assert result.coord("dummy_0 status_flag") == dummy_aux_coord_0_temp
assert result.coord("dummy_1 status_flag") == dummy_aux_coord_1_temp
result = plugin.process(cube, template_cubes)
assert result.coord("dummy_0 status_flag") == dummy_aux_coord_0_temp
assert result.coord("dummy_1 status_flag") == dummy_aux_coord_1_temp


def test_copy_attributes_multi_input_mismatching_attributes():
"""Test that an error is raised if the template cubes have mismatching attribute values."""
attributes = ["attribA", "attribB"]
cube0 = Cube([0], attributes={"attribA": "valueA", "attribB": "valueB"})
template_cube = Cube(
[0], attributes={"attribA": "tempA", "attribB": "tempB", "attribC": "tempC"}
)
template_cube_2 = Cube([0], attributes={"attribA": "temp2A", "attribC": "tempC"})

plugin = CopyMetadata(attributes)
with pytest.raises(
ValueError,
match="Attribute attribA has different values in the provided template cubes",
):
plugin.process(cube0, template_cube_2, template_cube)


def test_copy_attributes_multi_input_missing_attributes():
"""Test that an error is raised if a requested attribute is not present on any of the template cubes."""
attributes = ["attribA", "attribB"]
cube0 = Cube([0], attributes={"attribA": "valueA", "attribB": "valueB"})
template_cube = Cube([0], attributes={"attribB": "tempB", "attribC": "tempC"})
template_cube_2 = Cube([0], attributes={"attribC": "tempC"})
plugin = CopyMetadata(attributes)
with pytest.raises(
ValueError, match="Attribute attribA not found in any of the template cubes"
):
plugin.process(cube0, template_cube_2, template_cube)


def test_copy_attributes_missing_inputs():
"""Test that an error is raised if the number of input cubes is less than 2."""
attributes = ["attribA", "attribB"]
cube0 = Cube([0])

plugin = CopyMetadata(attributes)
with pytest.raises(
RuntimeError, match="At least two cubes are required for this operation, got 1"
):
plugin.process(cube0)

0 comments on commit 9454738

Please sign in to comment.