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

Mobt 2066 convert forecast to climatological anomaly #2072

Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ab4fb61
Recreated checksums
maxwhitemet Dec 19, 2024
44b252a
Added the requested functionality to convert a forecast to climatolog…
maxwhitemet Dec 31, 2024
e6b3209
Removed whitespace from files.
maxwhitemet Dec 31, 2024
80abfb5
Resolved test coverage issues Codecov highlighted.
maxwhitemet Dec 31, 2024
d93f1a2
Resolved remaining test coverage issues.
maxwhitemet Dec 31, 2024
b0a4f44
Resolved formatting issues.
maxwhitemet Dec 31, 2024
f6932e4
Partial commit. Switching branches to work on another ticket.
maxwhitemet Jan 22, 2025
badeead
Implemented points from previous review, including compliance with CF…
maxwhitemet Jan 24, 2025
9fbe175
Resolved failure of pre-commmit checks due to comparing to 'None' usi…
maxwhitemet Jan 24, 2025
508f934
Resolved warning from pre-commit checks that 'expected_attributes' va…
maxwhitemet Jan 24, 2025
1481214
Added new pytest tests for site data and replaced a test using 'pytes…
maxwhitemet Jan 31, 2025
29f9abe
Added unit test for raising an error when the time bounds of mean cub…
maxwhitemet Jan 31, 2025
e591ea8
Resolved accidental reuse of a test name
maxwhitemet Jan 31, 2025
6fcaf07
Added missing unit test highlighted by codecov
maxwhitemet Feb 3, 2025
50f0fce
Ensured all redundant comments removed and added a new test for when …
maxwhitemet Feb 5, 2025
9ab6d30
Increased readability of unit tests and resolved new test failures pr…
maxwhitemet Feb 10, 2025
61f3b3e
Adding the missing test file
maxwhitemet Feb 10, 2025
0a25409
Adjusted unit tests following review
maxwhitemet Feb 14, 2025
a41cddd
Implemented unit test changes suggested in the latest first review
maxwhitemet Feb 17, 2025
e626804
Standardised check that a reference epoch coordinate is present, whic…
maxwhitemet Feb 17, 2025
20364a7
Fixed double hashes for comments and simplified testing of reference …
maxwhitemet Feb 18, 2025
243eeb2
Removed references to the diagnostic_cube in the _add_reference_epoch…
maxwhitemet Feb 18, 2025
0806ffb
Resolved reformatting requests in previous review
maxwhitemet Feb 19, 2025
fc31ff5
Aimed to fix checksums issue and remove whitespace
maxwhitemet Feb 19, 2025
4f42ef3
Reverted mistaken changes to test_mathematical_operations.py
maxwhitemet Feb 20, 2025
743d139
Changed all instances of 'standard anomaly' with 'standardized anomal…
maxwhitemet Feb 20, 2025
93cecae
Removed whitespace
maxwhitemet Feb 20, 2025
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
246 changes: 246 additions & 0 deletions improver/utilities/mathematical_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import iris
import numpy as np
import numpy.ma as ma
from iris.coords import CellMethod
from iris.cube import Cube
from numpy import ndarray

Expand All @@ -17,6 +18,7 @@
create_new_diagnostic_cube,
generate_mandatory_attributes,
)
from improver.utilities.cube_checker import spatial_coords_match
from improver.utilities.cube_manipulation import (
enforce_coordinate_ordering,
get_dim_coord_names,
Expand Down Expand Up @@ -404,3 +406,247 @@ def fast_linear_fit(

intercept = y_mean - grad * x_mean
return grad, intercept


class CalculateClimateAnomalies(BasePlugin):
"""Utility functionality to convert an input cube of data to a cube containing
anomaly data. If a forecast and a climatological mean are supplied, the anomaly
calculated will be forecast - mean. If the climatological standard deviation is
also supplied, then a standardised anomaly is calculated as
(forecast - mean) / standard deviation"""

def __init__(
self,
ignore_temporal_mismatch: bool = False,
) -> None:
"""
Initialise class.

Args:
ignore_temporal_mismatch:
If True, ignore mismatch in time coordinates between
the input cubes. Default is False. Set to True when interested
in the anomaly of a diagnostic cube relative to mean and
standard deviation cubes of a different period.
"""
self.ignore_temporal_mismatch = ignore_temporal_mismatch

@staticmethod
def verify_units_match(
diagnostic_cube: Cube, mean_cube: Cube, std_cube: Optional[Cube] = None
) -> None:
"""Check that all cubes have the same units. E.g. to prevent accidental
use of cubes with rate data with cubes with accumulation data."""
errors = []
if mean_cube.units != diagnostic_cube.units:
errors.append(
f"The mean cube must have the same units as the diagnostic cube."
f"The following units were found: {mean_cube.units},"
f"{diagnostic_cube.units}"
)
if std_cube and std_cube.units != str(diagnostic_cube.units):
errors.append(
f"The standard deviation cube must have the same units "
"as the diagnostic cube."
f"The following units were found: {std_cube.units},"
f"{diagnostic_cube.units}"
)

if errors:
raise ValueError("\n".join(errors))

@staticmethod
def verify_spatial_coords_match(
diagnostic_cube: Cube, mean_cube: Cube, std_cube: Optional[Cube] = None
) -> None:
"""Check that all cubes have the same spatial coordinates (i.e. the same grid
coordinates or spot index coordinates)."""
cubes_to_check = [
cube for cube in [diagnostic_cube, mean_cube, std_cube] if cube is not None
]
# Check if spot_index coordinate present for some but not all cubes
spot_index_present = list(
set(["spot_index" in get_dim_coord_names(cube) for cube in cubes_to_check])
)
if len(spot_index_present) > 1:
raise ValueError(
"The cubes must all have the same spatial coordinates. Some cubes"
"contain spot_index coordinates and some do not."
)
elif spot_index_present[0]:
if any(
not np.array_equal(
cube.coord("spot_index").points,
diagnostic_cube.coord("spot_index").points,
)
for cube in cubes_to_check
):
raise ValueError(
"Mismatching spot_index coordinates were found on the input cubes."
)
elif not spatial_coords_match(cubes_to_check):
raise ValueError("The spatial coordinates must match.")

def verify_time_coords_match(
self, diagnostic_cube: Cube, mean_cube: Cube, std_cube: Optional[Cube] = None
) -> Cube:
"""Check that all cubes have compatible time coordinates."""
errors = []

# Check if mean and standard deviation cubes have the same bounds
if std_cube and not np.array_equal(
mean_cube.coord("time").bounds, std_cube.coord("time").bounds
):
errors.append(
"The mean and standard deviation cubes must have compatible bounds. "
"The following bounds were found: "
f"mean_cube bounds: {mean_cube.coord('time').bounds},"
f"std_cube bounds: {std_cube.coord('time').bounds}"
)

# Check if diagnostic cube's time point falls within the bounds of the
# mean cube.
# The verification of the standard deviation cube's bounds suitably matching the
# diagnostic cube's is covered implicitly due to the above code chunk.
if not self.ignore_temporal_mismatch:
diagnostic_max = diagnostic_cube.coord("time").cell(-1).point
diagnostic_min = diagnostic_cube.coord("time").cell(0).point
mean_time_bounds = mean_cube.coord("time").bounds[0]
mean_time_bounds = [
mean_cube.coord("time").units.num2date(bound)
for bound in mean_time_bounds
]
if not (
diagnostic_max <= mean_time_bounds[1]
and diagnostic_min >= mean_time_bounds[0]
):
errors.append(
"The diagnostic cube's time points must fall within the bounds "
"of the mean cube. The following was found: "
f"diagnostic cube maximum: {diagnostic_max},"
f"diagnostic cube minimum: {diagnostic_min},"
f"mean cube upper bound: {mean_time_bounds[1]},"
f"mean cube lower bound:{mean_time_bounds[0]}"
)

if errors:
raise ValueError("\n".join(errors))

@staticmethod
def calculate_anomalies(
diagnostic_cube: Cube, mean_cube: Cube, std_cube: Optional[Cube] = None
) -> ndarray:
"""Calculate climate anomalies from the input cubes."""
anomalies_data = diagnostic_cube.data - mean_cube.data
if std_cube:
anomalies_data = anomalies_data / std_cube.data
return anomalies_data

@staticmethod
def _update_cube_name_and_units(
output_cube: Cube, standard_anomaly: bool = False
) -> None:
"""This method updates the name and units of the given output cube based on
whether it represents a standard anomaly or not. The cube is modified in place.

Args:
output_cube:
The cube to be updated.
standard_anomaly:
Flag indicating if the output is a standard anomaly. If True,
a "_standard_anomaly" suffix is added to the name.
If False, an "_anomaly" suffix is added to the name.
"""

# If standard_anomaly is true, the output is a standard anomaly
# and units are changed
suffix = "_standard_anomaly" if standard_anomaly else "_anomaly"

try:
output_cube.standard_name = output_cube.standard_name + suffix
except ValueError:
output_cube.long_name = output_cube.standard_name + suffix

return output_cube

@staticmethod
def _add_reference_epoch_metadata(output_cube: Cube, mean_cube: Cube) -> None:
"""Add epoch metadata to describe the creation of the anomaly.
1. Add a scalar coordinate 'reference epoch' to describe the time period over
which the climatology was calculated:
- timebound inherited from mean cube,
2. Add a cell method called 'anomaly' to describe the operation
that was performed.

The output_cube is modified in place.
"""
reference_epoch = iris.coords.AuxCoord(
points=mean_cube.coord("time").points,
bounds=mean_cube.coord("time").bounds
if mean_cube.coord("time").has_bounds()
else None,
long_name="reference_epoch",
units=mean_cube.coord("time").units,
)

output_cube.add_aux_coord(reference_epoch)

cell_method = CellMethod(method="anomaly", coords=["reference_epoch"])
output_cube.add_cell_method(cell_method)

def _create_output_cube(
self,
diagnostic_cube: Cube,
mean_cube: Cube,
std_cube: Optional[Cube] = None,
) -> Cube:
"""
Create a final anomalies cube by copying the diagnostic cube, updating its data
with the anomalies data, and updating its metadata.
"""
# Use the diagnostic cube as a template for the output cube
output_cube = diagnostic_cube.copy()

# Update the cube name and units
standard_anomaly = True if std_cube else False
self._update_cube_name_and_units(output_cube, standard_anomaly)

# Create the reference epoch coordinate and cell method
self._add_reference_epoch_metadata(output_cube, mean_cube)

anomalies_data = self.calculate_anomalies(diagnostic_cube, mean_cube, std_cube)

output_cube.data = anomalies_data

return output_cube

def process(
self,
diagnostic_cube: Cube,
mean_cube: Cube,
std_cube: Cube = None,
) -> Cube:
"""Calculate anomalies from the input cubes.

Args:
diagnostic_cube:
Cube containing the data to be converted to anomalies.
mean_cube:
Cube containing the mean data to be used for the
calculation of anomalies.
std_cube:
Cube containing the standard deviation data to be used for the
calculation of standardised anomalies. If not provided,
only anomalies (not standardised anomalies) will be
calculated.
Returns:
Cube containing the result of the calculation with metadata reflecting
the operation.
"""
self.verify_units_match(diagnostic_cube, mean_cube, std_cube)
self.verify_spatial_coords_match(diagnostic_cube, mean_cube, std_cube)
self.verify_time_coords_match(diagnostic_cube, mean_cube, std_cube)

anomalies_cube = self._create_output_cube(diagnostic_cube, mean_cube, std_cube)

return anomalies_cube
Loading