diff --git a/improver/blending/weighted_blend.py b/improver/blending/weighted_blend.py index 654f758351..d3c8c0663d 100644 --- a/improver/blending/weighted_blend.py +++ b/improver/blending/weighted_blend.py @@ -21,6 +21,7 @@ from improver.blending.utilities import find_blend_dim_coord, store_record_run_as_coord from improver.metadata.constants import FLOAT_DTYPE, PERC_COORD from improver.metadata.forecast_times import rebadge_forecasts_as_latest_cycle +from improver.utilities.complex_conversion import complex_to_deg, deg_to_complex from improver.utilities.cube_manipulation import ( MergeCubes, collapsed, @@ -29,7 +30,6 @@ get_dim_coord_names, sort_coord_in_cube, ) -from improver.wind_calculations.wind_direction import WindDirection class MergeCubesForWeightedBlending(BasePlugin): @@ -631,7 +631,7 @@ def weighted_mean(self, cube: Cube, weights: Optional[Cube]) -> Cube: # If units are degrees, convert degrees to complex numbers. if cube.units == "degrees": - cube.data = WindDirection.deg_to_complex(cube.data) + cube.data = deg_to_complex(cube.data) weights_array = self.get_weights_array(cube, weights) @@ -658,7 +658,7 @@ def weighted_mean(self, cube: Cube, weights: Optional[Cube]) -> Cube: # If units are degrees, convert complex numbers back to degrees. if cube.units == "degrees": - result.data = WindDirection.complex_to_deg(result.data) + result.data = complex_to_deg(result.data) return result diff --git a/improver/cli/extract.py b/improver/cli/extract.py index 63cd852c73..7047158be9 100755 --- a/improver/cli/extract.py +++ b/improver/cli/extract.py @@ -52,13 +52,8 @@ def process( A single cube matching the input constraints or None. If no sub-cube is found within the cube that matches the constraints. """ - from improver.utilities.cube_extraction import extract_subcube + from improver.utilities.cube_extraction import ExtractSubCube - result = extract_subcube(cube, constraints, units) - - if result is None and ignore_failure: - return cube - if result is None: - msg = "Constraint(s) could not be matched in input cube" - raise ValueError(msg) + plugin = ExtractSubCube(constraints, units=units, ignore_failure=ignore_failure) + result = plugin.process(cube) return result diff --git a/improver/cli/freezing_rain.py b/improver/cli/freezing_rain.py index c1343203c0..5b6e8403f6 100644 --- a/improver/cli/freezing_rain.py +++ b/improver/cli/freezing_rain.py @@ -39,8 +39,6 @@ def process(*cubes: cli.inputcube, model_id_attr: str = None): A cube of freezing rain rate or accumulation probabilities. """ - from iris.cube import CubeList - from improver.precipitation_type.freezing_rain import FreezingRain - return FreezingRain(model_id_attr=model_id_attr)(CubeList(cubes)) + return FreezingRain(model_id_attr=model_id_attr)(*cubes) diff --git a/improver/cli/hail_fraction.py b/improver/cli/hail_fraction.py index a1891f809c..ad174e3f7f 100644 --- a/improver/cli/hail_fraction.py +++ b/improver/cli/hail_fraction.py @@ -27,34 +27,6 @@ def process(*cubes: cli.inputcubelist, model_id_attr: str = None): A single cube containing the hail fraction. """ - from iris.cube import CubeList - from improver.precipitation_type.hail_fraction import HailFraction - from improver.utilities.flatten import flatten - - ( - vertical_updraught, - hail_size, - cloud_condensation_level, - convective_cloud_top, - hail_melting_level, - altitude, - ) = CubeList(flatten(cubes)).extract( - [ - "maximum_vertical_updraught", - "diameter_of_hail_stones", - "air_temperature_at_condensation_level", - "air_temperature_at_convective_cloud_top", - "altitude_of_rain_from_hail_falling_level", - "surface_altitude", - ] - ) - return HailFraction(model_id_attr=model_id_attr)( - vertical_updraught, - hail_size, - cloud_condensation_level, - convective_cloud_top, - hail_melting_level, - altitude, - ) + return HailFraction(model_id_attr=model_id_attr)(*cubes) diff --git a/improver/cli/hail_size.py b/improver/cli/hail_size.py index 8d80976658..fab8cd7c2e 100644 --- a/improver/cli/hail_size.py +++ b/improver/cli/hail_size.py @@ -33,24 +33,6 @@ def process(*cubes: cli.inputcubelist, model_id_attr: str = None): iris.cube.Cube: Cube of diameter_of_hail (m). """ - - from iris.cube import CubeList - from improver.psychrometric_calculations.hail_size import HailSize - from improver.utilities.flatten import flatten - cubes = flatten(cubes) - (temperature, ccl_pressure, ccl_temperature, wet_bulb_zero, orography,) = CubeList( - cubes - ).extract( - [ - "air_temperature", - "air_pressure_at_condensation_level", - "air_temperature_at_condensation_level", - "wet_bulb_freezing_level_altitude", - "surface_altitude", - ] - ) - return HailSize(model_id_attr=model_id_attr)( - ccl_temperature, ccl_pressure, temperature, wet_bulb_zero, orography, - ) + return HailSize(model_id_attr=model_id_attr)(*cubes) diff --git a/improver/cli/interpolate_using_difference.py b/improver/cli/interpolate_using_difference.py index 003bbd9474..ca3e0ec0fe 100644 --- a/improver/cli/interpolate_using_difference.py +++ b/improver/cli/interpolate_using_difference.py @@ -53,7 +53,7 @@ def process( """ from improver.utilities.interpolation import InterpolateUsingDifference - result = InterpolateUsingDifference()( - cube, reference_cube, limit=limit, limit_as_maximum=limit_as_maximum + result = InterpolateUsingDifference(limit_as_maximum=limit_as_maximum)( + cube, reference_cube=reference_cube, limit=limit, ) return result diff --git a/improver/cli/lightning_from_cape_and_precip.py b/improver/cli/lightning_from_cape_and_precip.py index 1905768020..0db79ec8bf 100755 --- a/improver/cli/lightning_from_cape_and_precip.py +++ b/improver/cli/lightning_from_cape_and_precip.py @@ -29,10 +29,6 @@ def process( iris.cube.Cube: Cube of probabilities of lightning relative to a zero rate thresholds """ - from iris.cube import CubeList - from improver.lightning import LightningFromCapePrecip - result = LightningFromCapePrecip()(CubeList(cubes), model_id_attr=model_id_attr) - - return result + return LightningFromCapePrecip(model_id_attr=model_id_attr)(*cubes) diff --git a/improver/cli/nbhood.py b/improver/cli/nbhood.py index 7721b731c5..fc4fe6df9a 100755 --- a/improver/cli/nbhood.py +++ b/improver/cli/nbhood.py @@ -96,51 +96,17 @@ def process( RuntimeError: If degree_as_complex is used with neighbourhood_shape='circular'. """ - from improver.nbhood import radius_by_lead_time - from improver.nbhood.nbhood import ( - GeneratePercentilesFromANeighbourhood, - NeighbourhoodProcessing, - ) - from improver.utilities.pad_spatial import remove_cube_halo - from improver.wind_calculations.wind_direction import WindDirection - - if neighbourhood_output == "percentiles": - if weighted_mode: - raise RuntimeError( - "weighted_mode cannot be used with" 'neighbourhood_output="percentiles"' - ) - if degrees_as_complex: - raise RuntimeError("Cannot generate percentiles from complex numbers") - - if neighbourhood_shape == "circular": - if degrees_as_complex: - raise RuntimeError( - "Cannot process complex numbers with circular neighbourhoods" - ) - - if degrees_as_complex: - # convert cube data into complex numbers - cube.data = WindDirection.deg_to_complex(cube.data) + from improver.nbhood.nbhood import MetaNeighbourhood - radius_or_radii, lead_times = radius_by_lead_time(radii, lead_times) - - if neighbourhood_output == "probabilities": - result = NeighbourhoodProcessing( - neighbourhood_shape, - radius_or_radii, - lead_times=lead_times, - weighted_mode=weighted_mode, - sum_only=area_sum, - re_mask=True, - )(cube, mask_cube=mask) - elif neighbourhood_output == "percentiles": - result = GeneratePercentilesFromANeighbourhood( - radius_or_radii, lead_times=lead_times, percentiles=percentiles, - )(cube) - - if degrees_as_complex: - # convert neighbourhooded cube back to degrees - result.data = WindDirection.complex_to_deg(result.data) - if halo_radius is not None: - result = remove_cube_halo(result, halo_radius) - return result + plugin = MetaNeighbourhood( + neighbourhood_output=neighbourhood_output, + neighbourhood_shape=neighbourhood_shape, + radii=radii, + lead_times=lead_times, + degrees_as_complex=degrees_as_complex, + weighted_mode=weighted_mode, + area_sum=area_sum, + percentiles=percentiles, + halo_radius=halo_radius, + ) + return plugin(cube, mask=mask) diff --git a/improver/cli/phase_change_level.py b/improver/cli/phase_change_level.py index 0f6bdfd439..a8ba82157d 100755 --- a/improver/cli/phase_change_level.py +++ b/improver/cli/phase_change_level.py @@ -72,5 +72,5 @@ def process( horizontal_interpolation=horizontal_interpolation, model_id_attr=model_id_attr, ) - result = plugin(cubes) + result = plugin(*cubes) return result diff --git a/improver/cli/phase_probability.py b/improver/cli/phase_probability.py index 0377668193..b572de64de 100644 --- a/improver/cli/phase_probability.py +++ b/improver/cli/phase_probability.py @@ -45,10 +45,8 @@ def process(*cubes: cli.inputcube): The name of the orography cube must be "surface_altitude". The name of the site ancillary most be "grid_neighbours". """ - from iris.cube import CubeList - from improver.psychrometric_calculations.precip_phase_probability import ( PrecipPhaseProbability, ) - return PrecipPhaseProbability()(CubeList(cubes)) + return PrecipPhaseProbability()(*cubes) diff --git a/improver/cli/snow_splitter.py b/improver/cli/snow_splitter.py index bccb52850b..ff78126297 100644 --- a/improver/cli/snow_splitter.py +++ b/improver/cli/snow_splitter.py @@ -42,8 +42,6 @@ def process(*cubes: cli.inputcube, output_is_rain: bool): on precipitation cube) """ - from iris.cube import CubeList - from improver.precipitation_type.snow_splitter import SnowSplitter - return SnowSplitter(output_is_rain=output_is_rain)(CubeList(cubes)) + return SnowSplitter(output_is_rain=output_is_rain)(*cubes) diff --git a/improver/cli/vertical_updraught.py b/improver/cli/vertical_updraught.py index 56887eab6d..d2fafb9571 100755 --- a/improver/cli/vertical_updraught.py +++ b/improver/cli/vertical_updraught.py @@ -33,4 +33,4 @@ def process(*cubes: cli.inputcube, model_id_attr: str = None): """ from improver.wind_calculations.vertical_updraught import VerticalUpdraught - return VerticalUpdraught(model_id_attr=model_id_attr)(cubes) + return VerticalUpdraught(model_id_attr=model_id_attr)(*cubes) diff --git a/improver/cli/wet_bulb_freezing_level.py b/improver/cli/wet_bulb_freezing_level.py index ccf87fe789..e8447c3286 100644 --- a/improver/cli/wet_bulb_freezing_level.py +++ b/improver/cli/wet_bulb_freezing_level.py @@ -30,11 +30,8 @@ def process(wet_bulb_temperature: cli.inputcube): Cube of wet-bulb freezing level. """ - from improver.utilities.cube_extraction import ExtractLevel + from improver.psychrometric_calculations.wet_bulb_temperature import ( + MetaWetBulbFreezingLevel, + ) - wet_bulb_freezing_level = ExtractLevel( - positive_correlation=False, value_of_level=273.15 - )(wet_bulb_temperature) - wet_bulb_freezing_level.rename("wet_bulb_freezing_level_altitude") - - return wet_bulb_freezing_level + return MetaWetBulbFreezingLevel()(wet_bulb_temperature) diff --git a/improver/cli/wet_bulb_temperature.py b/improver/cli/wet_bulb_temperature.py index fb9babedb9..bd8b74248a 100755 --- a/improver/cli/wet_bulb_temperature.py +++ b/improver/cli/wet_bulb_temperature.py @@ -49,4 +49,4 @@ def process( return WetBulbTemperature( precision=convergence_condition, model_id_attr=model_id_attr - )(cubes) + )(*cubes) diff --git a/improver/lightning.py b/improver/lightning.py index b57f1bd322..3fb93528f4 100644 --- a/improver/lightning.py +++ b/improver/lightning.py @@ -4,7 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """Module containing lightning classes.""" from datetime import timedelta -from typing import Tuple +from typing import Tuple, Union import iris import numpy as np @@ -18,6 +18,7 @@ generate_mandatory_attributes, ) from improver.threshold import LatitudeDependentThreshold +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import spatial_coords_match from improver.utilities.rescale import rescale from improver.utilities.spatial import create_vicinity_coord @@ -44,6 +45,17 @@ class LightningFromCapePrecip(PostProcessingPlugin): """ + def __init__(self, model_id_attr: str = None) -> None: + """ + Initialise the plugin with the model_id_attr. + + Args: + model_id_attr: + The name of the dataset attribute to be used to identify the source + model when blending data from different models. + """ + self._model_id_attr = model_id_attr + @staticmethod def _get_inputs(cubes: CubeList) -> Tuple[Cube, Cube]: """ @@ -96,7 +108,7 @@ def _get_inputs(cubes: CubeList) -> Tuple[Cube, Cube]: raise ValueError("Supplied cubes do not have the same spatial coordinates") return cape, precip - def process(self, cubes: CubeList, model_id_attr: str = None) -> Cube: + def process(self, *cubes: Union[Cube, CubeList]) -> Cube: """ From the supplied CAPE and precipitation-rate cubes, calculate a probability of lightning cube. @@ -104,9 +116,6 @@ def process(self, cubes: CubeList, model_id_attr: str = None) -> Cube: Args: cubes: Cubes of CAPE and Precipitation rate. - model_id_attr: - The name of the dataset attribute to be used to identify the source - model when blending data from different models. Returns: Cube of lightning data @@ -115,6 +124,7 @@ def process(self, cubes: CubeList, model_id_attr: str = None) -> Cube: ValueError: If one of the cubes is not found or doesn't match the other """ + cubes = as_cubelist(*cubes) cape, precip = self._get_inputs(cubes) cape_true = LatitudeDependentThreshold( @@ -137,7 +147,7 @@ def process(self, cubes: CubeList, model_id_attr: str = None) -> Cube: template_cube=precip, data=data.astype(FLOAT_DTYPE), mandatory_attributes=generate_mandatory_attributes( - cubes, model_id_attr=model_id_attr + cubes, model_id_attr=self._model_id_attr ), ) diff --git a/improver/nbhood/nbhood.py b/improver/nbhood/nbhood.py index c5d889518e..d69a799895 100644 --- a/improver/nbhood/nbhood.py +++ b/improver/nbhood/nbhood.py @@ -12,9 +12,12 @@ from numpy import ndarray from scipy.ndimage.filters import correlate -from improver import PostProcessingPlugin +from improver import BasePlugin, PostProcessingPlugin from improver.constants import DEFAULT_PERCENTILES from improver.metadata.forecast_times import forecast_period_coord +from improver.nbhood import radius_by_lead_time +from improver.utilities.common_input_handle import as_cube +from improver.utilities.complex_conversion import complex_to_deg, deg_to_complex from improver.utilities.cube_checker import ( check_cube_coordinates, find_dimension_coordinate_mismatch, @@ -719,3 +722,140 @@ def make_percentile_cube(self, cube: Cube) -> Cube: if result.coord_dims(pct_coord_name) == (): result = iris.util.new_axis(result, scalar_coord=pct_coord_name) return result + + +class MetaNeighbourhood(BasePlugin): + """ + Meta-processing module which handles probabilities and percentiles + neighbourhood processing. + """ + + def __init__( + self, + neighbourhood_output: str, + radii: List[float], + lead_times: Optional[List[int]] = None, + neighbourhood_shape: str = "square", + degrees_as_complex: bool = False, + weighted_mode: bool = False, + area_sum: bool = False, + percentiles: List[float] = DEFAULT_PERCENTILES, + halo_radius: Optional[float] = None, + ) -> None: + """ + Initialise the MetaNeighbourhood class. + + Args: + neighbourhood_output: + The form of the results generated using neighbourhood processing. + If "probabilities" is selected, the mean probability with a + neighbourhood is calculated. If "percentiles" is selected, then + the percentiles are calculated with a neighbourhood. Calculating + percentiles from a neighbourhood is only supported for a circular + neighbourhood, and the input cube should be ensemble realizations. + The calculation of percentiles from a neighbourhood is notably slower + than neighbourhood processing using a thresholded probability field. + Options: "probabilities", "percentiles". + radii: + The radius or a list of radii in metres of the neighbourhood to + apply. + If it is a list, it must be the same length as lead_times, which + defines at which lead time to use which nbhood radius. The radius + will be interpolated for intermediate lead times. + lead_times: + The lead times in hours that correspond to the radii to be used. + If lead_times are set, radii must be a list the same length as + lead_times. + neighbourhood_shape: + Name of the neighbourhood method to use. Only a "circular" + neighbourhood shape is applicable for calculating "percentiles" + output. + Options: "circular", "square". + Default: "square". + degrees_as_complex: + Include this option to process angles as complex numbers. + Not compatible with circular kernel or percentiles. + weighted_mode: + Include this option to set the weighting to decrease with radius. + Otherwise a constant weighting is assumed. + weighted_mode is only applicable for calculating "probability" + neighbourhood output using the circular kernel. + area_sum: + Return sum rather than fraction over the neighbourhood area. + percentiles: + Calculates value at the specified percentiles from the + neighbourhood surrounding each grid point. This argument has no + effect if the output is probabilities. + halo_radius: + Set this radius in metres to define the excess halo to clip. Used + where a larger grid was defined than the standard grid and we want + to clip the grid back to the standard grid. Otherwise no clipping + is applied. + """ + self._neighbourhood_output = neighbourhood_output + self._neighbourhood_shape = neighbourhood_shape + self._radius_or_radii, self._lead_times = radius_by_lead_time(radii, lead_times) + self._degrees_as_complex = degrees_as_complex + self._weighted_mode = weighted_mode + self._area_sum = area_sum + self._percentiles = percentiles + self._halo_radius = halo_radius + + if neighbourhood_output == "percentiles": + if weighted_mode: + raise RuntimeError( + "weighted_mode cannot be used with" + 'neighbourhood_output="percentiles"' + ) + if degrees_as_complex: + raise RuntimeError("Cannot generate percentiles from complex numbers") + + if neighbourhood_shape == "circular": + if degrees_as_complex: + raise RuntimeError( + "Cannot process complex numbers with circular neighbourhoods" + ) + + def process(self, cube: Cube, mask: Cube = None) -> Cube: + """ + Apply neighbourhood processing to the input cube. + + Args: + cube: The input cube. + mask: The mask cube. + + Returns: + iris.cube.Cube: The processed cube. + """ + cube = as_cube(cube) + if mask: + mask = as_cube(mask) + + if self._degrees_as_complex: + # convert cube data into complex numbers + cube.data = deg_to_complex(cube.data) + + if self._neighbourhood_output == "probabilities": + result = NeighbourhoodProcessing( + self._neighbourhood_shape, + self._radius_or_radii, + lead_times=self._lead_times, + weighted_mode=self._weighted_mode, + sum_only=self._area_sum, + re_mask=True, + )(cube, mask_cube=mask) + elif self._neighbourhood_output == "percentiles": + result = GeneratePercentilesFromANeighbourhood( + self._radius_or_radii, + lead_times=self._lead_times, + percentiles=self._percentiles, + )(cube) + + if self._degrees_as_complex: + # convert neighbourhooded cube back to degrees + result.data = complex_to_deg(result.data) + if self._halo_radius is not None: + from improver.utilities.pad_spatial import remove_cube_halo + + result = remove_cube_halo(result, self._halo_radius) + return result diff --git a/improver/precipitation_type/freezing_rain.py b/improver/precipitation_type/freezing_rain.py index 344ec836cd..94675a332a 100644 --- a/improver/precipitation_type/freezing_rain.py +++ b/improver/precipitation_type/freezing_rain.py @@ -4,7 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """Module containing the FreezingRain class.""" -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import iris import numpy as np @@ -17,6 +17,7 @@ create_new_diagnostic_cube, generate_mandatory_attributes, ) +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import spatial_coords_match from improver.utilities.cube_extraction import extract_subcube from improver.utilities.probability_manipulation import to_threshold_inequality @@ -268,7 +269,7 @@ def _calculate_freezing_rain_probability( return freezing_rain - def process(self, input_cubes: CubeList) -> Cube: + def process(self, *input_cubes: Union[Cube, CubeList]) -> Cube: """Check input cubes, then calculate a probability of freezing rain diagnostic. Collapses the realization coordinate if present. @@ -282,6 +283,7 @@ def process(self, input_cubes: CubeList) -> Cube: Returns: Cube of freezing rain probabilties. """ + input_cubes = as_cubelist(*input_cubes) self._get_input_cubes(input_cubes) try: diff --git a/improver/precipitation_type/hail_fraction.py b/improver/precipitation_type/hail_fraction.py index 1643325290..308eb403c6 100644 --- a/improver/precipitation_type/hail_fraction.py +++ b/improver/precipitation_type/hail_fraction.py @@ -4,16 +4,17 @@ # See LICENSE in the root of the repository for full licensing details. """Module containing the HailFraction class.""" -from typing import Optional +from typing import Optional, Union import numpy as np -from iris.cube import Cube +from iris.cube import Cube, CubeList from improver import PostProcessingPlugin from improver.metadata.utilities import ( create_new_diagnostic_cube, generate_mandatory_attributes, ) +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import assert_spatial_coords_match @@ -91,31 +92,43 @@ def _compute_hail_fraction( ] = 0.05 return hail_fraction - def process( - self, - vertical_updraught: Cube, - hail_size: Cube, - cloud_condensation_level: Cube, - convective_cloud_top: Cube, - hail_melting_level: Cube, - orography: Cube, - ) -> Cube: + def process(self, *cubes: Union[Cube, CubeList]) -> Cube: """Calculates the hail fraction using the maximum vertical updraught, the hail_size, the cloud condensation level temperature, the convective cloud top temperature, the altitude of the hail to rain phase change and the orography. Args: - vertical_updraught: Maximum vertical updraught. - hail_size: Hail size. - cloud_condensation_level: Cloud condensation level temperature. - convective_cloud_top: Convective cloud top. - hail_melting_level: Altitude of the melting of hail to rain. - orography: Altitude of the orography. + cubes: + vertical_updraught: Maximum vertical updraught. + hail_size: Hail size. + cloud_condensation_level: Cloud condensation level temperature. + convective_cloud_top: Convective cloud top. + hail_melting_level: Altitude of the melting of hail to rain. + orography: Altitude of the orography. Returns: Hail fraction cube. """ + cubes = as_cubelist(*cubes) + ( + vertical_updraught, + hail_size, + cloud_condensation_level, + convective_cloud_top, + hail_melting_level, + orography, + ) = cubes.extract( + [ + "maximum_vertical_updraught", + "diameter_of_hail_stones", + "air_temperature_at_condensation_level", + "air_temperature_at_convective_cloud_top", + "altitude_of_rain_from_hail_falling_level", + "surface_altitude", + ] + ) + vertical_updraught.convert_units("m s-1") hail_size.convert_units("m") cloud_condensation_level.convert_units("K") diff --git a/improver/precipitation_type/snow_splitter.py b/improver/precipitation_type/snow_splitter.py index 6adb2c630d..2ea312d8c2 100644 --- a/improver/precipitation_type/snow_splitter.py +++ b/improver/precipitation_type/snow_splitter.py @@ -4,7 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """Module to separate snow and rain contributions from a precipitation diagnostic""" -from typing import Tuple +from typing import Tuple, Union import numpy as np from iris import Constraint @@ -12,6 +12,7 @@ from improver import BasePlugin from improver.cube_combiner import Combine +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import assert_spatial_coords_match @@ -63,7 +64,7 @@ def separate_input_cubes(cubes: CubeList) -> Tuple[Cube, Cube, Cube]: return (rain_cube, snow_cube, precip_cube) - def process(self, cubes: CubeList,) -> Cube: + def process(self, *cubes: Union[Cube, CubeList]) -> Cube: """ Splits the precipitation cube data into a snow or rain contribution. @@ -102,7 +103,7 @@ def process(self, cubes: CubeList,) -> Cube: ValueError: If, at some grid square, both snow_cube and rain_cube have a probability of 0 """ # noqa: W605 (flake8 objects to \_ in "lwe\_" that is required for Sphinx) - + cubes = as_cubelist(*cubes) rain_cube, snow_cube, precip_cube = self.separate_input_cubes(cubes) assert_spatial_coords_match([rain_cube, snow_cube, precip_cube]) diff --git a/improver/psychrometric_calculations/hail_size.py b/improver/psychrometric_calculations/hail_size.py index 8b98c3e3bd..16dcf2f120 100644 --- a/improver/psychrometric_calculations/hail_size.py +++ b/improver/psychrometric_calculations/hail_size.py @@ -5,10 +5,10 @@ """module to calculate hail_size""" from bisect import bisect_right -from typing import List, Tuple +from typing import List, Tuple, Union import numpy as np -from iris.cube import Cube +from iris.cube import Cube, CubeList from iris.exceptions import CoordinateNotFoundError from improver import BasePlugin @@ -21,6 +21,7 @@ dry_adiabatic_temperature, saturated_humidity, ) +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import assert_spatial_coords_match from improver.utilities.cube_extraction import ExtractLevel from improver.utilities.cube_manipulation import enforce_coordinate_ordering @@ -466,31 +467,41 @@ def make_hail_cube( ) return hail_size_cube - def process( - self, - ccl_temperature: Cube, - ccl_pressure: Cube, - temperature_on_pressure: Cube, - wet_bulb_zero_height_asl: Cube, - orography: Cube, - ) -> Cube: + def process(self, *cubes: Union[Cube, CubeList]) -> Cube: """ Main entry point of this class Args: - ccl_temperature: - Cube of the cloud condensation level temperature - ccl_pressure: - Cube of the cloud condensation level pressure. - temperature_on_pressure: - Cube of temperature on pressure levels - wet_bulb_zero_height_asl: - Cube of the height of the wet-bulb freezing level above sea level - orography: - Cube of the orography height. + cubes + air_temperature: + Cube of the cloud condensation level temperature + air_pressure_at_condensation_level: + Cube of the cloud condensation level pressure. + air_temperature_at_condensation_level: + Cube of temperature on pressure levels + wet_bulb_freezing_level_altitude: + Cube of the height of the wet-bulb freezing level above sea level + surface_altitude: + Cube of the orography height. Returns: Cube of hail diameter (m) """ + cubes = as_cubelist(*cubes) + ( + temperature_on_pressure, + ccl_pressure, + ccl_temperature, + wet_bulb_zero_height_asl, + orography, + ) = cubes.extract( + [ + "air_temperature", + "air_pressure_at_condensation_level", + "air_temperature_at_condensation_level", + "wet_bulb_freezing_level_altitude", + "surface_altitude", + ] + ) self.check_cubes( ccl_temperature, diff --git a/improver/psychrometric_calculations/precip_phase_probability.py b/improver/psychrometric_calculations/precip_phase_probability.py index 929cd6ca51..96cff0d5a6 100644 --- a/improver/psychrometric_calculations/precip_phase_probability.py +++ b/improver/psychrometric_calculations/precip_phase_probability.py @@ -17,6 +17,7 @@ create_new_diagnostic_cube, generate_mandatory_attributes, ) +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import spatial_coords_match @@ -49,7 +50,7 @@ class PrecipPhaseProbability(BasePlugin): probability will be determined at each site's specific altitude. """ - def _extract_input_cubes(self, cubes: Union[CubeList, List[Cube]]) -> None: + def _extract_input_cubes(self, cubes: CubeList) -> None: """ Separates the input list into the required cubes for this plugin, detects whether snow, rain from hail or rain are required from the input @@ -77,8 +78,6 @@ def _extract_input_cubes(self, cubes: Union[CubeList, List[Cube]]) -> None: ValueError: If the extracted cubes do not have matching spatial coordinates. """ - if isinstance(cubes, list): - cubes = iris.cube.CubeList(cubes) if len(cubes) != 2: raise ValueError(f"Expected 2 cubes, found {len(cubes)}") @@ -137,7 +136,7 @@ def _extract_input_cubes(self, cubes: Union[CubeList, List[Cube]]) -> None: self.falling_level_cube = self.falling_level_cube.copy() self.falling_level_cube.convert_units(altitude_units) - def process(self, cubes: Union[CubeList, List[Cube]]) -> Cube: + def process(self, *cubes: Union[CubeList, List[Cube]]) -> Cube: """ Derives the probability of a precipitation phase at the surface / site altitude. If the snow-sleet falling-level is supplied, this is @@ -165,6 +164,7 @@ def process(self, cubes: Union[CubeList, List[Cube]]) -> Cube: precipitation to be divided uniquely between snow, sleet and rain phases. """ + cubes = as_cubelist(*cubes) self._extract_input_cubes(cubes) result_data = np.where( diff --git a/improver/psychrometric_calculations/psychrometric_calculations.py b/improver/psychrometric_calculations/psychrometric_calculations.py index 6fce6e68d8..3c15416bc9 100644 --- a/improver/psychrometric_calculations/psychrometric_calculations.py +++ b/improver/psychrometric_calculations/psychrometric_calculations.py @@ -854,7 +854,7 @@ def create_phase_change_level_cube( name, "m", template, attributes, data=phase_change_level ) - def process(self, cubes: Union[CubeList, List[Cube]]) -> Cube: + def process(self, *cubes: Union[CubeList, List[Cube]]) -> Cube: """ Use the wet bulb temperature integral to find the altitude at which a phase change occurs (e.g. snow to sleet). This is achieved by finding @@ -882,7 +882,7 @@ def process(self, cubes: Union[CubeList, List[Cube]]) -> Cube: ValueError: Raise exception if the model_id_attr attribute does not match on the input cubes. """ - + cubes = as_cubelist(*cubes) names_to_extract = [ "wet_bulb_temperature", "wet_bulb_temperature_integral", diff --git a/improver/psychrometric_calculations/wet_bulb_temperature.py b/improver/psychrometric_calculations/wet_bulb_temperature.py index 212aa3060a..9286b467c9 100644 --- a/improver/psychrometric_calculations/wet_bulb_temperature.py +++ b/improver/psychrometric_calculations/wet_bulb_temperature.py @@ -21,6 +21,7 @@ _calculate_latent_heat, saturated_humidity, ) +from improver.utilities.common_input_handle import as_cube, as_cubelist from improver.utilities.cube_checker import check_cube_coordinates from improver.utilities.mathematical_operations import Integration @@ -273,7 +274,7 @@ def create_wet_bulb_temperature_cube( ) return wbt - def process(self, cubes: Union[List[Cube], CubeList]) -> Cube: + def process(self, *cubes: Union[List[Cube], CubeList]) -> Cube: """ Call the calculate_wet_bulb_temperature function to calculate wet bulb temperatures. This process function splits input cubes over vertical @@ -293,6 +294,7 @@ def process(self, cubes: Union[List[Cube], CubeList]) -> Cube: Returns: Cube of wet bulb temperature (K). """ + cubes = as_cubelist(*cubes) names_to_extract = ["air_temperature", "relative_humidity", "air_pressure"] if len(cubes) != len(names_to_extract): raise ValueError( @@ -340,6 +342,7 @@ def process(self, wet_bulb_temperature: Cube) -> Cube: Returns: Cube of wet bulb temperature integral (Kelvin-metres). """ + wet_bulb_temperature = as_cube(wet_bulb_temperature) wbt = wet_bulb_temperature.copy() wbt.convert_units("degC") wbt.coord("height").convert_units("m") @@ -355,3 +358,36 @@ def process(self, wet_bulb_temperature: Cube) -> Cube: # 'K m', and these are equivalent wet_bulb_temperature_integral.units = Unit("K m") return wet_bulb_temperature_integral + + +class MetaWetBulbFreezingLevel(BasePlugin): + """Meta processing module to handle the necessary extract and metadata handling (rename) + required by wet bulb freezing level generation.""" + + def process(self, wet_bulb_temperature: Cube) -> Cube: + """ + generate wet-bulb freezing level. + + The height level at which the wet-bulb temperature first drops below 273.15K + (0 degrees Celsius) is extracted from the wet-bulb temperature cube starting from + the ground and ascending through height levels. + + In grid squares where the temperature never goes below 273.15K the highest + height level on the cube is returned. In grid squares where the temperature + starts below 273.15K the lowest height on the cube is returned. + + Args: + wet_bulb_temperature: + Cube of wet-bulb air temperatures over multiple height levels. + + Returns: + Cube of wet-bulb freezing level. + """ + from improver.utilities.cube_extraction import ExtractLevel + + wet_bulb_temperature = as_cube(wet_bulb_temperature) + wet_bulb_freezing_level = ExtractLevel( + positive_correlation=False, value_of_level=273.15 + )(wet_bulb_temperature) + wet_bulb_freezing_level.rename("wet_bulb_freezing_level_altitude") + return wet_bulb_freezing_level diff --git a/improver/utilities/complex_conversion.py b/improver/utilities/complex_conversion.py new file mode 100644 index 0000000000..b44ee601ea --- /dev/null +++ b/improver/utilities/complex_conversion.py @@ -0,0 +1,68 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +from typing import Union + +import numpy as np +from numpy import ndarray + + +def deg_to_complex( + angle_deg: Union[ndarray, float], radius: Union[ndarray, float] = 1 +) -> Union[ndarray, float]: + """Converts degrees to complex values. + + The radius argument can be used to weight values. Defaults to 1. + + Args: + angle_deg: + 3D array or float - direction angles in degrees. + radius: + 3D array or float - radius value for each point, default=1. + + Returns: + 3D array or float - direction translated to + complex numbers. + """ + # Convert from degrees to radians. + angle_rad = np.deg2rad(angle_deg) + # Derive real and imaginary components (also known as a and b) + real = radius * np.cos(angle_rad) + imag = radius * np.sin(angle_rad) + + # Combine components into a complex number and return. + return real + 1j * imag + + +def complex_to_deg(complex_in: ndarray) -> ndarray: + """Converts complex to degrees. + + The "np.angle" function returns negative numbers when the input + is greater than 180. Therefore additional processing is needed + to ensure that the angle is between 0-359. + + Args: + complex_in: + 3D array - direction angles in complex number form. + + Returns: + 3D array - direction in angle form + + Raises: + TypeError: If complex_in is not an array. + """ + + if not isinstance(complex_in, np.ndarray): + msg = "Input data is not a numpy array, but {}" + raise TypeError(msg.format(type(complex_in))) + + angle = np.angle(complex_in, deg=True) + + # Convert angles so they are in the range [0, 360) + angle = np.mod(np.float32(angle), 360) + + # We don't need 64 bit precision. + angle = angle.astype(np.float32) + + return angle diff --git a/improver/utilities/cube_extraction.py b/improver/utilities/cube_extraction.py index db94265586..ebf84d2185 100644 --- a/improver/utilities/cube_extraction.py +++ b/improver/utilities/cube_extraction.py @@ -14,6 +14,7 @@ from improver import BasePlugin from improver.metadata.constants import FLOAT_DTYPE +from improver.utilities.common_input_handle import as_cube from improver.utilities.cube_constraints import create_sorted_lambda_constraint from improver.utilities.cube_manipulation import get_dim_coord_names @@ -264,6 +265,65 @@ def extract_subcube( return output_cube +class ExtractSubCube(BasePlugin): + """Extract a subcube from the provided cube, given constraints.""" + + def __init__( + self, + constraints: List[str], + units: Optional[List[str]] = None, + use_original_units: bool = True, + ignore_failure: bool = False, + ) -> None: + """ + Set up the ExtractSubCube plugin. + + Args: + constraints: + List of string constraints with keys and values split by "=": + e.g: ["kw1=val1", "kw2 = val2", "kw3=val3"]. + units: + List of units (as strings) corresponding to each coordinate in the + list of constraints. One or more "units" may be None, and units + may only be associated with coordinate constraints. + use_original_units: + Boolean to state whether the coordinates used in the extraction + should be converted back to their original units. The default is + True, indicating that the units should be converted back to the + original units. + ignore_failure: + Option to ignore constraint match failure and return the input + cube. + """ + self._constraints = constraints + self._units = units + self._use_original_units = use_original_units + self._ignore_failure = ignore_failure + + def process(self, cube: Cube): + """Perform the subcube extraction. + + Args: + cube: + The cube from which a subcube is to be extracted. + + Returns: + A single cube matching the input constraints. + + Raises: + ValueError: If the constraint(s) could not be matched to the input cube. + """ + cube = as_cube(cube) + res = extract_subcube( + cube, self._constraints, self._units, self._use_original_units + ) + if res is None: + res = cube + if not self._ignore_failure: + raise ValueError("Constraint(s) could not be matched in input cube") + return res + + def thin_cube(cube: Cube, thinning_dict: Dict[str, int]) -> Cube: """ Thin the coordinate by taking every X points, defined in the thinning dict diff --git a/improver/utilities/cube_manipulation.py b/improver/utilities/cube_manipulation.py index aeecdb1725..1513887314 100644 --- a/improver/utilities/cube_manipulation.py +++ b/improver/utilities/cube_manipulation.py @@ -16,6 +16,7 @@ from improver import BasePlugin from improver.metadata.constants import FLOAT_DTYPE, FLOAT_TYPES from improver.metadata.probabilistic import find_threshold_coordinate +from improver.utilities.common_input_handle import as_cube from improver.utilities.cube_checker import check_cube_coordinates @@ -743,6 +744,7 @@ def maximum_in_height( ValueError: If the cube has no height levels between the lower_height_bound and upper_height_bound """ + cube = as_cube(cube) height_levels = cube.coord("height").points # replace None in bounds with a numerical value either below or above the range of height diff --git a/improver/utilities/interpolation.py b/improver/utilities/interpolation.py index 7db3d43593..7e4e54c6d2 100644 --- a/improver/utilities/interpolation.py +++ b/improver/utilities/interpolation.py @@ -15,6 +15,7 @@ from scipy.spatial.qhull import QhullError from improver import BasePlugin +from improver.utilities.common_input_handle import as_cube def interpolate_missing_data( @@ -99,6 +100,19 @@ class InterpolateUsingDifference(BasePlugin): field. """ + def __init__(self, limit_as_maximum: bool = True) -> None: + """ + Initialise the plugin. + + Args: + limit_as_maximum: + If True the test against the values allowed by the limit array + is that if the interpolated values exceed the limit they should + be set to the limit value. If False, the test is whether the + interpolated values fall below the limit value. + """ + self._limit_as_maximum = limit_as_maximum + def __repr__(self) -> str: """String representation of plugin.""" return "" @@ -125,11 +139,7 @@ def _check_inputs(cube: Cube, reference_cube: Cube, limit: Optional[Cube]) -> No ) def process( - self, - cube: Cube, - reference_cube: Cube, - limit: Optional[Cube] = None, - limit_as_maximum: bool = True, + self, cube: Cube, reference_cube: Cube, limit: Optional[Cube] = None ) -> Cube: """ Apply plugin to input data. @@ -140,7 +150,7 @@ def process( regions. reference_cube: A cube that covers the entire domain that it shares with - cube. + cube. This cube is used to calculate the difference field. limit: A cube of limiting values to apply to the cube that is being filled in. This can be used to ensure that the resulting values @@ -148,11 +158,6 @@ def process( limit values should be used as a minima or maxima is determined by the limit_as_maximum option. These values should be on an x-y grid of the same size as an x-y slice of cube. - limit_as_maximum: - If True the test against the values allowed by the limit array - is that if the interpolated values exceed the limit they should - be set to the limit value. If False, the test is whether the - interpolated values fall below the limit value. Return: A copy of the input cube in which the missing data has been @@ -164,6 +169,10 @@ def process( ValueError: If the reference cube is not complete across the entire domain. """ + cube = as_cube(cube) + reference_cube = as_cube(reference_cube) + if limit: + limit = as_cube(limit) if not np.ma.is_masked(cube.data): warnings.warn( "Input cube unmasked, no data to fill in, returning unchanged." @@ -212,7 +221,7 @@ def process( ) if limit is not None: - if limit_as_maximum: + if self._limit_as_maximum: result.data[invalid_points] = np.clip( result.data[invalid_points], None, limit.data[invalid_points] ) diff --git a/improver/utilities/temporal_interpolation.py b/improver/utilities/temporal_interpolation.py index 3664deeb8f..d887c65e26 100644 --- a/improver/utilities/temporal_interpolation.py +++ b/improver/utilities/temporal_interpolation.py @@ -16,12 +16,12 @@ from improver import BasePlugin from improver.metadata.constants import FLOAT_DTYPE from improver.metadata.constants.time_types import TIME_COORDS +from improver.utilities.complex_conversion import complex_to_deg, deg_to_complex from improver.utilities.cube_manipulation import MergeCubes from improver.utilities.round import round_close from improver.utilities.solar import DayNightMask, calc_solar_elevation from improver.utilities.spatial import lat_lon_determine, transform_grid_to_lat_lon from improver.utilities.temporal import iris_time_to_datetime -from improver.wind_calculations.wind_direction import WindDirection class TemporalInterpolation(BasePlugin): @@ -628,8 +628,8 @@ def process(self, cube_t0: Cube, cube_t1: Cube) -> CubeList: # interpolations (esp. the 0/360 wraparound) are handled in a sane # fashion. if cube_t0.units == "degrees" and cube_t1.units == "degrees": - cube_t0.data = WindDirection.deg_to_complex(cube_t0.data) - cube_t1.data = WindDirection.deg_to_complex(cube_t1.data) + cube_t0.data = deg_to_complex(cube_t0.data) + cube_t1.data = deg_to_complex(cube_t1.data) # Convert accumulations into rates to allow interpolation using trends # in the data and to accommodate non-uniform output intervals. This also @@ -646,9 +646,7 @@ def process(self, cube_t0: Cube, cube_t1: Cube) -> CubeList: interpolated_cube = cube.interpolate(time_list, iris.analysis.Linear()) if cube_t0.units == "degrees" and cube_t1.units == "degrees": - interpolated_cube.data = WindDirection.complex_to_deg( - interpolated_cube.data - ) + interpolated_cube.data = complex_to_deg(interpolated_cube.data) if self.period_inputs: # Add bounds to the time coordinates of the interpolated outputs diff --git a/improver/wind_calculations/vertical_updraught.py b/improver/wind_calculations/vertical_updraught.py index 4d4c429366..b55f3e0652 100644 --- a/improver/wind_calculations/vertical_updraught.py +++ b/improver/wind_calculations/vertical_updraught.py @@ -4,7 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """This module contains the VerticalUpdraught plugin""" -from typing import List +from typing import Union import numpy as np from iris.cube import Cube, CubeList @@ -14,6 +14,7 @@ create_new_diagnostic_cube, generate_mandatory_attributes, ) +from improver.utilities.common_input_handle import as_cubelist from improver.utilities.cube_checker import spatial_coords_match @@ -47,7 +48,7 @@ def __init__(self, model_id_attr: str = None): self._minimum_cape = 10.0 # J kg-1. Minimum value to diagnose updraught from self._minimum_precip = 5.0 # mm h-1. Minimum value to diagnose updraught from - def _parse_inputs(self, inputs: List[Cube]) -> None: + def _parse_inputs(self, cubes: CubeList) -> None: """ Separates input CubeList into CAPE and precipitation rate objects with standard units and raises Exceptions if it can't, or finds excess data. @@ -59,7 +60,6 @@ def _parse_inputs(self, inputs: List[Cube]) -> None: ValueError: If additional cubes are found """ - cubes = CubeList(inputs) try: (self.cape, self.precip) = cubes.extract(self.cube_names) except ValueError as e: @@ -69,7 +69,7 @@ def _parse_inputs(self, inputs: List[Cube]) -> None: if len(cubes) > 2: extras = [c.name() for c in cubes if c.name() not in self.cube_names] raise ValueError(f"Unexpected Cube(s) found in inputs: {extras}") - if not spatial_coords_match(inputs): + if not spatial_coords_match(cubes): raise ValueError(f"Spatial coords of input Cubes do not match: {cubes}") time_error_msg = self._input_times_error() if time_error_msg: @@ -148,7 +148,7 @@ def _make_updraught_cube(self, data: np.ndarray) -> Cube: ) return cube - def process(self, inputs: List[Cube]) -> Cube: + def process(self, *cubes: Union[Cube, CubeList]) -> Cube: """Executes methods to calculate updraught from CAPE and precipitation rate and packages this as a Cube with appropriate metadata. @@ -160,7 +160,8 @@ def process(self, inputs: List[Cube]) -> Cube: Cube: Containing maximum vertical updraught """ - self._parse_inputs(inputs) + cubes = as_cubelist(*cubes) + self._parse_inputs(cubes) return self._make_updraught_cube( self._updraught_from_cape() + self._updraught_increment_from_precip() ) diff --git a/improver/wind_calculations/wind_direction.py b/improver/wind_calculations/wind_direction.py index 6f6d3675f3..bd1f8cea3e 100644 --- a/improver/wind_calculations/wind_direction.py +++ b/improver/wind_calculations/wind_direction.py @@ -3,9 +3,6 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Module containing wind direction averaging plugins.""" - -from typing import Union - import iris import numpy as np from iris.coords import CellMethod @@ -14,6 +11,7 @@ from improver import PostProcessingPlugin from improver.nbhood.nbhood import NeighbourhoodProcessing +from improver.utilities.complex_conversion import complex_to_deg, deg_to_complex from improver.utilities.cube_checker import check_cube_coordinates @@ -104,68 +102,6 @@ def _reset(self) -> None: self.wdir_mean_complex = None self.r_vals_slice = None - @staticmethod - def deg_to_complex( - angle_deg: Union[ndarray, float], radius: Union[ndarray, float] = 1 - ) -> Union[ndarray, float]: - """Converts degrees to complex values. - - The radius value can be used to weigh values - but it is set - to 1 for now. - - Args: - angle_deg: - 3D array or float - wind direction angles in degrees. - radius: - 3D array or float - radius value for each point, default=1. - - Returns: - 3D array or float - wind direction translated to - complex numbers. - """ - # Convert from degrees to radians. - angle_rad = np.deg2rad(angle_deg) - # Derive real and imaginary components (also known as a and b) - real = radius * np.cos(angle_rad) - imag = radius * np.sin(angle_rad) - - # Combine components into a complex number and return. - return real + 1j * imag - - @staticmethod - def complex_to_deg(complex_in: ndarray) -> ndarray: - """Converts complex to degrees. - - The "np.angle" function returns negative numbers when the input - is greater than 180. Therefore additional processing is needed - to ensure that the angle is between 0-359. - - Args: - complex_in: - 3D array - wind direction angles in - complex number form. - - Returns: - 3D array - wind direction in angle form - - Raises: - TypeError: If complex_in is not an array. - """ - - if not isinstance(complex_in, np.ndarray): - msg = "Input data is not a numpy array, but {}" - raise TypeError(msg.format(type(complex_in))) - - angle = np.angle(complex_in, deg=True) - - # Convert angles so they are in the range [0, 360) - angle = np.mod(np.float32(angle), 360) - - # We don't need 64 bit precision. - angle = angle.astype(np.float32) - - return angle - def calc_wind_dir_mean(self) -> None: """Find the mean wind direction using complex average which actually signifies a point between all of the data points in POLAR @@ -186,7 +122,7 @@ def calc_wind_dir_mean(self) -> None: along an axis using np.mean(). """ self.wdir_mean_complex = np.mean(self.wdir_complex, axis=self.realization_axis) - self.wdir_slice_mean.data = self.complex_to_deg(self.wdir_mean_complex) + self.wdir_slice_mean.data = complex_to_deg(self.wdir_mean_complex) def find_r_values(self) -> None: """Find radius values from complex numbers. @@ -309,7 +245,7 @@ def process(self, cube_ens_wdir: Cube) -> Cube: ): self._reset() # Extract wind direction data. - self.wdir_complex = self.deg_to_complex(wdir_slice.data) + self.wdir_complex = deg_to_complex(wdir_slice.data) (self.realization_axis,) = wdir_slice.coord_dims("realization") # Copies input cube and remove realization dimension to create # cubes for storing results. diff --git a/improver_tests/lightning/test_LightningFromCapePrecip.py b/improver_tests/lightning/test_LightningFromCapePrecip.py index 4750ced3a9..b42a31f155 100644 --- a/improver_tests/lightning/test_LightningFromCapePrecip.py +++ b/improver_tests/lightning/test_LightningFromCapePrecip.py @@ -3,8 +3,8 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Test methods in lightning.LightningFromCapePrecip""" - from datetime import datetime +from unittest.mock import patch, sentinel import numpy as np import pytest @@ -19,6 +19,22 @@ ) +class HaltExecution(Exception): + pass + + +@patch("improver.lightning.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + LightningFromCapePrecip()(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + @pytest.fixture(name="cape_cube") def cape_cube_fixture() -> Cube: """ @@ -123,8 +139,8 @@ def test_3h_cubes(cape_cube, precip_cube, expected_cube): def test_with_model_attribute(cape_cube, precip_cube, expected_cube): """Run the plugin with model_id_attr and check the result cube matches the expected_cube""" expected_cube.attributes["mosg__model_configuration"] = "gl_ens" - result = LightningFromCapePrecip()( - CubeList([cape_cube, precip_cube]), model_id_attr="mosg__model_configuration" + result = LightningFromCapePrecip(model_id_attr="mosg__model_configuration")( + CubeList([cape_cube, precip_cube]) ) assert result.xml().splitlines(keepends=True) == expected_cube.xml().splitlines( keepends=True diff --git a/improver_tests/nbhood/nbhood/test_MetaNeighbourhood.py b/improver_tests/nbhood/nbhood/test_MetaNeighbourhood.py new file mode 100644 index 0000000000..b957a06796 --- /dev/null +++ b/improver_tests/nbhood/nbhood/test_MetaNeighbourhood.py @@ -0,0 +1,73 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +from unittest.mock import patch, sentinel + +import pytest + +from improver.nbhood.nbhood import MetaNeighbourhood + + +class HaltExecution(Exception): + pass + + +@patch("improver.nbhood.nbhood.as_cube") +def test_as_cube_called(mock_as_cube): + mock_as_cube.side_effect = [None, HaltExecution] + try: + MetaNeighbourhood( + lead_times=[1, 2, 3], + radii=[1, 2, 3], + neighbourhood_output=sentinel.ngoutput, + )(sentinel.cube, mask=sentinel.mask) + except HaltExecution: + pass + mock_as_cube.assert_any_call(sentinel.cube) + mock_as_cube.assert_any_call(sentinel.mask) + + +@pytest.mark.parametrize( + "neighbourhood_output, weighted_mode, neighbourhood_shape, degrees_as_complex, exception_msg", + [ + ( + "percentiles", + True, + "square", + False, + "weighted_mode cannot be used with" 'neighbourhood_output="percentiles"', + ), + ( + "percentiles", + False, + "square", + True, + "Cannot generate percentiles from complex numbers", + ), + ( + "probabilities", + False, + "circular", + True, + "Cannot process complex numbers with circular neighbourhoods", + ), + ], +) +def test___init___exceptions( + neighbourhood_output, + weighted_mode, + neighbourhood_shape, + degrees_as_complex, + exception_msg, +): + """Exception when passing """ + args = [neighbourhood_output] + kwargs = { + "weighted_mode": weighted_mode, + "neighbourhood_shape": neighbourhood_shape, + "degrees_as_complex": degrees_as_complex, + } + kwargs.update(dict(lead_times=[1, 2, 3], radii=[1, 2, 3])) + with pytest.raises(RuntimeError, match=exception_msg): + MetaNeighbourhood(*args, **kwargs) diff --git a/improver_tests/precipitation_type/freezing_rain/test_FreezingRain.py b/improver_tests/precipitation_type/freezing_rain/test_FreezingRain.py index dbeab1df03..ddd8f19cac 100644 --- a/improver_tests/precipitation_type/freezing_rain/test_FreezingRain.py +++ b/improver_tests/precipitation_type/freezing_rain/test_FreezingRain.py @@ -5,6 +5,7 @@ """ Tests of FreezingRain plugin""" import itertools +from unittest.mock import patch, sentinel import iris import pytest @@ -19,6 +20,22 @@ TIME_WINDOW_TYPE = ["instantaneous", "period"] +class HaltExecution(Exception): + pass + + +@patch("improver.precipitation_type.freezing_rain.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + FreezingRain()(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + def modify_coordinate(cube, coord): """Modify a coordinate to enable testing for mismatches.""" points = cube.coord(coord).points.copy() diff --git a/improver_tests/precipitation_type/hail_fraction/test_HailFraction.py b/improver_tests/precipitation_type/hail_fraction/test_HailFraction.py index b00133d0c1..4e513adb4a 100644 --- a/improver_tests/precipitation_type/hail_fraction/test_HailFraction.py +++ b/improver_tests/precipitation_type/hail_fraction/test_HailFraction.py @@ -3,6 +3,7 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Tests for the HailFraction plugin.""" +from unittest.mock import patch, sentinel import iris import numpy as np @@ -18,6 +19,22 @@ } +class HaltExecution(Exception): + pass + + +@patch("improver.precipitation_type.hail_fraction.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + HailFraction()(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + def setup_cubes(): """Set up cubes for testing.""" vertical_updraught_data = np.zeros((2, 2), dtype=np.float32) @@ -33,7 +50,7 @@ def setup_cubes(): hail_size_data = np.zeros((2, 2), dtype=np.float32) hail_size = set_up_variable_cube( hail_size_data, - name="size_of_hail_stones", + name="diameter_of_hail_stones", units="m", spatial_grid="equalarea", attributes=COMMON_ATTRS, @@ -73,7 +90,7 @@ def setup_cubes(): altitude_data = np.zeros((2, 2), dtype=np.float32) altitude = set_up_variable_cube( altitude_data, - name="altitude", + name="surface_altitude", units="m", spatial_grid="equalarea", attributes=COMMON_ATTRS, diff --git a/improver_tests/precipitation_type/snow_splitter/test_snow_splitter.py b/improver_tests/precipitation_type/snow_splitter/test_snow_splitter.py index 346b0cab34..98f801598a 100644 --- a/improver_tests/precipitation_type/snow_splitter/test_snow_splitter.py +++ b/improver_tests/precipitation_type/snow_splitter/test_snow_splitter.py @@ -4,6 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """Tests for the SnowSplitter plugin""" from datetime import datetime +from unittest.mock import patch, sentinel import numpy as np import pytest @@ -19,6 +20,24 @@ } +class HaltExecution(Exception): + pass + + +@patch("improver.precipitation_type.snow_splitter.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + SnowSplitter(output_is_rain=sentinel.output_is_rain)( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + @pytest.fixture() def snow_cube() -> Cube: """Set up a r, y, x cube of probability of snow at surface""" diff --git a/improver_tests/psychrometric_calculations/hail_size/test_hail_size.py b/improver_tests/psychrometric_calculations/hail_size/test_HailSize.py similarity index 93% rename from improver_tests/psychrometric_calculations/hail_size/test_hail_size.py rename to improver_tests/psychrometric_calculations/hail_size/test_HailSize.py index b999437f80..5b90de48cf 100644 --- a/improver_tests/psychrometric_calculations/hail_size/test_hail_size.py +++ b/improver_tests/psychrometric_calculations/hail_size/test_HailSize.py @@ -3,6 +3,7 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Unit tests for the HailSize plugin""" +from unittest.mock import patch, sentinel import numpy as np import pytest @@ -20,13 +21,29 @@ pytest.importorskip("stratify") +class HaltExecution(Exception): + pass + + +@patch("improver.psychrometric_calculations.hail_size.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + HailSize()(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + @pytest.fixture def ccl_temperature() -> Cube: """Set up a r, y, x cube of cloud condensation level temperature data""" data = np.full((2, 3, 2), fill_value=300, dtype=np.float32) ccl_temperature_cube = set_up_variable_cube( data, - name="temperature_at_cloud_condensation_level", + name="air_temperature_at_condensation_level", units="K", attributes=LOCAL_MANDATORY_ATTRIBUTES, ) @@ -39,7 +56,7 @@ def ccl_pressure() -> Cube: data = np.full((2, 3, 2), fill_value=97500, dtype=np.float32) ccl_pressure_cube = set_up_variable_cube( data, - name="pressure_at_cloud_condensation_level", + name="air_pressure_at_condensation_level", units="Pa", attributes=LOCAL_MANDATORY_ATTRIBUTES, ) @@ -80,7 +97,7 @@ def temperature_on_pressure_levels() -> Cube: data, pressure=True, height_levels=np.arange(100000, 29999, -10000), - name="temperature_on_pressure_levels", + name="air_temperature", units="K", attributes=LOCAL_MANDATORY_ATTRIBUTES, ) @@ -236,9 +253,9 @@ def test_spatial_coord_mismatch(variable, request): cubes = CubeList(request.getfixturevalue(fix) for fix in fixtures) cubes.append(variable_slice) - (ccl_temperature,) = cubes.extract("temperature_at_cloud_condensation_level") - (ccl_pressure,) = cubes.extract("pressure_at_cloud_condensation_level") - (temperature_on_pressure,) = cubes.extract("temperature_on_pressure_levels") + (ccl_temperature,) = cubes.extract("air_temperature_at_condensation_level") + (ccl_pressure,) = cubes.extract("air_pressure_at_condensation_level") + (temperature_on_pressure,) = cubes.extract("air_temperature") (wet_bulb_freezing,) = cubes.extract("wet_bulb_freezing_level_altitude") (orography,) = cubes.extract("surface_altitude") diff --git a/improver_tests/psychrometric_calculations/precip_phase_probability/test_PrecipPhaseProbability.py b/improver_tests/psychrometric_calculations/precip_phase_probability/test_PrecipPhaseProbability.py index a876e4e43c..087c831b28 100644 --- a/improver_tests/psychrometric_calculations/precip_phase_probability/test_PrecipPhaseProbability.py +++ b/improver_tests/psychrometric_calculations/precip_phase_probability/test_PrecipPhaseProbability.py @@ -3,8 +3,8 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Unit tests for psychrometric_calculations PrecipPhaseProbability plugin.""" - import operator +from unittest.mock import patch, sentinel import iris import numpy as np @@ -38,6 +38,22 @@ FALLING_LEVEL_DATA[1, 0, 0] = 25 +class HaltExecution(Exception): + pass + + +@patch("improver.psychrometric_calculations.precip_phase_probability.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + PrecipPhaseProbability()(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + def check_metadata(result, phase): """ Checks that the meta-data of the cube "result" are as expected. diff --git a/improver_tests/psychrometric_calculations/test_PhaseChangeLevel.py b/improver_tests/psychrometric_calculations/test_PhaseChangeLevel.py index 5daa3a2827..82304de348 100644 --- a/improver_tests/psychrometric_calculations/test_PhaseChangeLevel.py +++ b/improver_tests/psychrometric_calculations/test_PhaseChangeLevel.py @@ -908,11 +908,13 @@ def test_too_many_cubes(self): ) ) - def test_empty_cube_list(self): + def test_too_few_cubes(self): """Tests that an error is raised if there is an empty list.""" msg = "Expected 4" with self.assertRaisesRegex(ValueError, msg): - PhaseChangeLevel(phase_change="snow-sleet").process(CubeList([])) + PhaseChangeLevel(phase_change="snow-sleet").process( + CubeList([self.wet_bulb_temperature_cube]) + ) if __name__ == "__main__": diff --git a/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_MetaWetBulbFreezingLevel.py b/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_MetaWetBulbFreezingLevel.py new file mode 100644 index 0000000000..80d1d65bd7 --- /dev/null +++ b/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_MetaWetBulbFreezingLevel.py @@ -0,0 +1,23 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +from unittest.mock import patch, sentinel + +from improver.psychrometric_calculations.wet_bulb_temperature import ( + MetaWetBulbFreezingLevel, +) + + +class HaltExecution(Exception): + pass + + +@patch("improver.psychrometric_calculations.wet_bulb_temperature.as_cube") +def test_as_cube_called(mock_as_cube): + mock_as_cube.side_effect = HaltExecution + try: + MetaWetBulbFreezingLevel()(sentinel.cube) + except HaltExecution: + pass + mock_as_cube.assert_called_once_with(sentinel.cube) diff --git a/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_WetBulbTemperature.py b/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_WetBulbTemperature.py index a00e5b7d67..de9e7232a0 100644 --- a/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_WetBulbTemperature.py +++ b/improver_tests/psychrometric_calculations/wet_bulb_temperature/test_WetBulbTemperature.py @@ -3,8 +3,8 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Unit tests for psychrometric_calculations WetBulbTemperature""" - import unittest +from unittest.mock import patch, sentinel import iris import numpy as np @@ -16,6 +16,25 @@ from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube +class HaltExecution(Exception): + pass + + +@patch("improver.psychrometric_calculations.wet_bulb_temperature.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + WetBulbTemperature( + precision=sentinel.convergence_condition, + model_id_attr=sentinel.model_id_attr, + )(sentinel.cube1, sentinel.cube2, sentinel.cube3) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with( + sentinel.cube1, sentinel.cube2, sentinel.cube3 + ) + + class Test_psychrometric_variables(IrisTest): """Test calculations of one-line variables: svp in air, latent heat, mixing ratios, etc""" @@ -214,11 +233,11 @@ def test_too_many_cubes(self): with self.assertRaisesRegex(ValueError, msg): WetBulbTemperature().process(CubeList([temp, humid, pressure, temp])) - def test_empty_cube_list(self): - """Tests that an error is raised if there is an empty list.""" + def test_too_few_cubes(self): + """Tests that an error is raised if there isn't 3 cubes provided.""" msg = "Expected 3" with self.assertRaisesRegex(ValueError, msg): - WetBulbTemperature().process(CubeList([])) + WetBulbTemperature().process(CubeList([self.temperature])) if __name__ == "__main__": diff --git a/improver_tests/utilities/complex_conversion/__init__.py b/improver_tests/utilities/complex_conversion/__init__.py new file mode 100644 index 0000000000..b0565c9d7a --- /dev/null +++ b/improver_tests/utilities/complex_conversion/__init__.py @@ -0,0 +1,48 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import numpy as np + +# Data to test complex/degree handling functions. +# Complex angles equivalent to np.arange(0., 360, 10) degrees. +COMPLEX_ANGLES = np.array( + [ + 1.0 + 0j, + 0.984807753 + 0.173648178j, + 0.939692621 + 0.342020143j, + 0.866025404 + 0.5j, + 0.766044443 + 0.642787610j, + 0.642787610 + 0.766044443j, + 0.5 + 0.866025404j, + 0.342020143 + 0.939692621j, + 0.173648178 + 0.984807753j, + 0.0 + 1.0j, + -0.173648178 + 0.984807753j, + -0.342020143 + 0.939692621j, + -0.5 + 0.866025404j, + -0.642787610 + 0.766044443j, + -0.766044443 + 0.642787610j, + -0.866025404 + 0.5j, + -0.939692621 + 0.342020143j, + -0.984807753 + 0.173648178j, + -1.0 + 0.0j, + -0.984807753 - 0.173648178j, + -0.939692621 - 0.342020143j, + -0.866025404 - 0.5j, + -0.766044443 - 0.642787610j, + -0.642787610 - 0.766044443j, + -0.5 - 0.866025404j, + -0.342020143 - 0.939692621j, + -0.173648178 - 0.984807753j, + -0.0 - 1.0j, + 0.173648178 - 0.984807753j, + 0.342020143 - 0.939692621j, + 0.5 - 0.866025404j, + 0.642787610 - 0.766044443j, + 0.766044443 - 0.642787610j, + 0.866025404 - 0.5j, + 0.939692621 - 0.342020143j, + 0.984807753 - 0.173648178j, + ] +) diff --git a/improver_tests/utilities/complex_conversion/test_complex_to_deg.py b/improver_tests/utilities/complex_conversion/test_complex_to_deg.py new file mode 100644 index 0000000000..f335f4652f --- /dev/null +++ b/improver_tests/utilities/complex_conversion/test_complex_to_deg.py @@ -0,0 +1,34 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import numpy as np +import pytest +from numpy.testing import assert_array_almost_equal + +from improver.utilities.complex_conversion import complex_to_deg + +from . import COMPLEX_ANGLES + + +def test_fails_if_data_is_not_array(): + """Test code raises a Type Error if input data not an array.""" + input_data = 0 - 1j + msg = "Input data is not a numpy array, but {}".format(type(input_data)) + with pytest.raises(TypeError, match=msg): + complex_to_deg(input_data) + + +def test_handles_angle_wrap(): + """Test that code correctly handles 360 and 0 degrees.""" + # Input is complex for 0 and 360 deg - both should return 0.0. + input_data = np.array([1 + 0j, 1 - 0j]) + result = complex_to_deg(input_data) + assert (result == 0.0).all() + + +def test_converts_array(): + """Tests that array of complex values are converted to degrees.""" + result = complex_to_deg(COMPLEX_ANGLES) + assert isinstance(result, np.ndarray) + assert_array_almost_equal(result, np.arange(0.0, 360, 10)) diff --git a/improver_tests/utilities/complex_conversion/test_deg_to_complex.py b/improver_tests/utilities/complex_conversion/test_deg_to_complex.py new file mode 100644 index 0000000000..bc0b04bf8b --- /dev/null +++ b/improver_tests/utilities/complex_conversion/test_deg_to_complex.py @@ -0,0 +1,35 @@ +import numpy as np +from numpy.testing import assert_array_almost_equal + +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +from improver.utilities.complex_conversion import deg_to_complex + +from . import COMPLEX_ANGLES + + +def test_converts_single(): + """Tests that degree angle value is converted to complex.""" + expected_out = 0.707106781187 + 0.707106781187j + result = deg_to_complex(45.0) + assert_array_almost_equal(result, expected_out) + + +def test_handles_angle_wrap(): + """Test that code correctly handles 360 and 0 degrees.""" + expected_out = 1 + 0j + result = deg_to_complex(0) + assert_array_almost_equal(result, expected_out) + + expected_out = 1 - 0j + result = deg_to_complex(360) + assert_array_almost_equal(result, expected_out) + + +def test_converts_array(): + """Tests that array of floats is converted to complex array.""" + result = deg_to_complex(np.arange(0.0, 360, 10)) + assert isinstance(result, np.ndarray) + assert_array_almost_equal(result, COMPLEX_ANGLES) diff --git a/improver_tests/utilities/complex_conversion/test_integration.py b/improver_tests/utilities/complex_conversion/test_integration.py new file mode 100644 index 0000000000..86b6da5202 --- /dev/null +++ b/improver_tests/utilities/complex_conversion/test_integration.py @@ -0,0 +1,25 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import numpy as np +from numpy.testing import assert_array_almost_equal + +from improver.utilities.complex_conversion import complex_to_deg, deg_to_complex + +from . import COMPLEX_ANGLES + + +def test_roundtrip_complex_deg_complex(): + """Tests that array of values are converted to degrees and back.""" + tmp_degrees = complex_to_deg(COMPLEX_ANGLES) + result = deg_to_complex(tmp_degrees) + assert_array_almost_equal(result, COMPLEX_ANGLES) + + +def test_roundtrip_deg_complex_deg(): + """Tests that array of values are converted to complex and back.""" + src_degrees = np.arange(0.0, 360, 10) + tmp_complex = deg_to_complex(src_degrees) + result = complex_to_deg(tmp_complex) + assert_array_almost_equal(result, src_degrees) diff --git a/improver_tests/utilities/cube_extraction/test_ExtractSubCube.py b/improver_tests/utilities/cube_extraction/test_ExtractSubCube.py new file mode 100644 index 0000000000..e61b07f83f --- /dev/null +++ b/improver_tests/utilities/cube_extraction/test_ExtractSubCube.py @@ -0,0 +1,48 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +from unittest.mock import patch, sentinel + +import pytest +from iris.cube import Cube + +from improver.utilities.cube_extraction import ExtractSubCube + + +@patch("improver.utilities.cube_extraction.as_cube") +@patch("improver.utilities.cube_extraction.extract_subcube") +def test_ui(mock_extract_subcube, mock_as_cube): + """Ensure 'extract_subcube' is called with the correct arguments.""" + mock_as_cube.side_effect = lambda x: x # identity function + plugin = ExtractSubCube( + sentinel.constraints, + units=sentinel.units, + use_original_units=sentinel.use_original_units, + ignore_failure=sentinel.ignore_failure, + ) + plugin(sentinel.cube) + mock_as_cube.assert_called_once_with(sentinel.cube) + mock_extract_subcube.assert_called_once_with( + sentinel.cube, sentinel.constraints, sentinel.units, sentinel.use_original_units + ) + + +def test_no_matching_constraint_exception(): + """ + Test that a ValueError is raised when no constraints match. + """ + plugin = ExtractSubCube(["dummy_name=dummy_value"], ignore_failure=False) + with pytest.raises(ValueError) as excinfo: + plugin(Cube(0)) + assert str(excinfo.value) == "Constraint(s) could not be matched in input cube" + + +def test_no_matching_constraint_ignore(): + """ + Test that the original cube is returned when no constraints match and ignore specified. + """ + plugin = ExtractSubCube(["dummy_name=dummy_value"], ignore_failure=True) + src_cube = Cube(0) + res = plugin(src_cube) + assert res is src_cube diff --git a/improver_tests/utilities/test_InterpolateUsingDifference.py b/improver_tests/utilities/test_InterpolateUsingDifference.py index 81ef800044..e83c1dba98 100644 --- a/improver_tests/utilities/test_InterpolateUsingDifference.py +++ b/improver_tests/utilities/test_InterpolateUsingDifference.py @@ -3,8 +3,8 @@ # This file is part of IMPROVER and is released under a BSD 3-Clause license. # See LICENSE in the root of the repository for full licensing details. """Unit tests for the InterpolateUsingDifference plugin.""" - import unittest +from unittest.mock import patch, sentinel import numpy as np import pytest @@ -17,6 +17,24 @@ from improver.utilities.interpolation import InterpolateUsingDifference +class HaltExecution(Exception): + pass + + +@patch("improver.utilities.interpolation.as_cube") +def test_as_cube_called(mock_as_cube): + mock_as_cube.side_effect = [None, None, HaltExecution] # halt execution on 2nd call + try: + InterpolateUsingDifference()( + sentinel.cube, sentinel.reference_cube, limit=sentinel.limit_cube + ) + except HaltExecution: + pass + mock_as_cube.assert_any_call(sentinel.cube) + mock_as_cube.assert_any_call(sentinel.limit_cube) + mock_as_cube.assert_any_call(sentinel.reference_cube) + + class Test_Setup(unittest.TestCase): """Set up for InterpolateUsingDifference tests.""" @@ -63,9 +81,9 @@ def test_basic(self): ) -class Test__check_inputs(Test_Setup): +class Test_process_check_inputs(Test_Setup): - """Tests for the private _check_inputs method.""" + """Tests for input check behaviour of process method.""" def test_incomplete_reference_data(self): """Test an exception is raised if the reference field is incomplete.""" @@ -73,9 +91,7 @@ def test_incomplete_reference_data(self): self.snow_sleet.data[1, 1] = np.nan msg = "The reference cube contains np.nan data" with self.assertRaisesRegex(ValueError, msg): - InterpolateUsingDifference()._check_inputs( - self.sleet_rain, self.snow_sleet, None - ) + InterpolateUsingDifference().process(self.sleet_rain, self.snow_sleet) def test_incompatible_reference_cube_units(self): """Test an exception is raised if the reference cube has units that @@ -84,9 +100,7 @@ def test_incompatible_reference_cube_units(self): self.snow_sleet.units = "s" msg = "Reference cube and/or limit do not have units compatible" with self.assertRaisesRegex(ValueError, msg): - InterpolateUsingDifference()._check_inputs( - self.sleet_rain, self.snow_sleet, None - ) + InterpolateUsingDifference().process(self.sleet_rain, self.snow_sleet) def test_incompatible_limit_units(self): """Test an exception is raised if the limit cube has units that @@ -95,8 +109,8 @@ def test_incompatible_limit_units(self): self.limit.units = "s" msg = "Reference cube and/or limit do not have units compatible" with self.assertRaisesRegex(ValueError, msg): - InterpolateUsingDifference()._check_inputs( - self.sleet_rain, self.snow_sleet, limit=self.limit + InterpolateUsingDifference().process( + self.sleet_rain, self.snow_sleet, limit=self.limit, ) def test_convert_units(self): @@ -138,8 +152,8 @@ def test_maximum_limited(self): [[4.0, 4.0, 4.0], [8.5, 8.0, 6.0], [3.0, 3.0, 3.0]], dtype=np.float32 ) - result = InterpolateUsingDifference().process( - self.sleet_rain, self.snow_sleet, limit=self.limit, limit_as_maximum=True + result = InterpolateUsingDifference(limit_as_maximum=True).process( + self.sleet_rain, self.snow_sleet, limit=self.limit, ) assert_array_equal(result.data, expected) @@ -155,8 +169,8 @@ def test_minimum_limited(self): [[4.0, 4.0, 4.0], [10.0, 8.5, 8.5], [3.0, 3.0, 3.0]], dtype=np.float32 ) - result = InterpolateUsingDifference().process( - self.sleet_rain, self.snow_sleet, limit=self.limit, limit_as_maximum=False + result = InterpolateUsingDifference(limit_as_maximum=False).process( + self.sleet_rain, self.snow_sleet, limit=self.limit, ) assert_array_equal(result.data, expected) @@ -176,8 +190,8 @@ def test_multi_realization_limited(self): [[4.0, 4.0, 4.0], [10.0, 8.5, 8.5], [3.0, 3.0, 3.0]], dtype=np.float32 ) - result = InterpolateUsingDifference().process( - sleet_rain, snow_sleet, limit=self.limit, limit_as_maximum=False + result = InterpolateUsingDifference(limit_as_maximum=False).process( + sleet_rain, snow_sleet, limit=self.limit, ) assert_array_equal(result[0].data, expected) @@ -218,11 +232,8 @@ def test_crossing_values(self): self.sleet_rain, self.snow_sleet ) - result_limited = InterpolateUsingDifference().process( - self.sleet_rain, - self.snow_sleet, - limit=self.snow_sleet, - limit_as_maximum=False, + result_limited = InterpolateUsingDifference(limit_as_maximum=False).process( + self.sleet_rain, self.snow_sleet, limit=self.snow_sleet, ) assert_array_equal(result_unlimited.data, expected_unlimited) @@ -279,7 +290,7 @@ def test_convert_units(self): self.limit.convert_units("cm") result = InterpolateUsingDifference().process( - self.sleet_rain, self.snow_sleet, limit=self.limit + self.sleet_rain, self.snow_sleet, limit=self.limit, ) assert_array_equal(result.data, expected) diff --git a/improver_tests/wind_calculations/vertical_updraught/test_VerticalUpdraught.py b/improver_tests/wind_calculations/vertical_updraught/test_VerticalUpdraught.py index df5267515c..b93ea536cb 100644 --- a/improver_tests/wind_calculations/vertical_updraught/test_VerticalUpdraught.py +++ b/improver_tests/wind_calculations/vertical_updraught/test_VerticalUpdraught.py @@ -6,6 +6,7 @@ import re from datetime import datetime from typing import List +from unittest.mock import patch, sentinel import numpy as np import pytest @@ -23,6 +24,22 @@ } +class HaltExecution(Exception): + pass + + +@patch("improver.wind_calculations.vertical_updraught.as_cubelist") +def test_as_cubelist_called(mock_as_cubelist): + mock_as_cubelist.side_effect = HaltExecution + try: + VerticalUpdraught(model_id_attr=sentinel.model_id_attr)( + sentinel.cape, sentinel.precip + ) + except HaltExecution: + pass + mock_as_cubelist.assert_called_once_with(sentinel.cape, sentinel.precip) + + @pytest.fixture(name="cape") def cape_cube_fixture() -> Cube: """Set up a r, y, x cube of CAPE data""" diff --git a/improver_tests/wind_calculations/wind_direction/test_WindDirection.py b/improver_tests/wind_calculations/wind_direction/test_WindDirection.py index 96ffe83538..abb29e59f2 100644 --- a/improver_tests/wind_calculations/wind_direction/test_WindDirection.py +++ b/improver_tests/wind_calculations/wind_direction/test_WindDirection.py @@ -12,7 +12,7 @@ from iris.tests import IrisTest from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube -from improver.wind_calculations.wind_direction import WindDirection +from improver.wind_calculations.wind_direction import WindDirection, deg_to_complex # Data to test complex/degree handling functions. # Complex angles equivalent to np.arange(0., 360, 10) degrees. @@ -179,78 +179,6 @@ def test_basic(self): self.assertEqual(result, msg) -# Test the complex number handling functions. -class Test_deg_to_complex(IrisTest): - """Test the deg_to_complex function.""" - - def test_converts_single(self): - """Tests that degree angle value is converted to complex.""" - expected_out = 0.707106781187 + 0.707106781187j - result = WindDirection().deg_to_complex(45.0) - self.assertAlmostEqual(result, expected_out) - - def test_handles_angle_wrap(self): - """Test that code correctly handles 360 and 0 degrees.""" - expected_out = 1 + 0j - result = WindDirection().deg_to_complex(0) - self.assertAlmostEqual(result, expected_out) - - expected_out = 1 - 0j - result = WindDirection().deg_to_complex(360) - self.assertAlmostEqual(result, expected_out) - - def test_converts_array(self): - """Tests that array of floats is converted to complex array.""" - result = WindDirection().deg_to_complex(np.arange(0.0, 360, 10)) - self.assertIsInstance(result, np.ndarray) - self.assertArrayAlmostEqual(result, COMPLEX_ANGLES) - - -class Test_complex_to_deg(IrisTest): - """Test the complex_to_deg function.""" - - def test_fails_if_data_is_not_array(self): - """Test code raises a Type Error if input data not an array.""" - input_data = 0 - 1j - msg = "Input data is not a numpy array, but {}".format(type(input_data)) - with self.assertRaisesRegex(TypeError, msg): - WindDirection().complex_to_deg(input_data) - - def test_handles_angle_wrap(self): - """Test that code correctly handles 360 and 0 degrees.""" - # Input is complex for 0 and 360 deg - both should return 0.0. - input_data = np.array([1 + 0j, 1 - 0j]) - result = WindDirection().complex_to_deg(input_data) - self.assertTrue((result == 0.0).all()) - - def test_converts_array(self): - """Tests that array of complex values are converted to degrees.""" - result = WindDirection().complex_to_deg(COMPLEX_ANGLES) - self.assertIsInstance(result, np.ndarray) - self.assertArrayAlmostEqual(result, np.arange(0.0, 360, 10)) - - -class Test_complex_to_deg_roundtrip(IrisTest): - """Test the complex_to_deg and deg_to_complex functions together.""" - - def setUp(self): - """Initialise plugin and supply data for tests""" - self.plugin = WindDirection() - self.cube = make_wdir_cube_534() - - def test_from_deg(self): - """Tests that array of values are converted to complex and back.""" - tmp_complex = self.plugin.deg_to_complex(self.cube.data) - result = self.plugin.complex_to_deg(tmp_complex) - self.assertArrayAlmostEqual(result, self.cube.data, decimal=4) - - def test_from_complex(self): - """Tests that array of values are converted to degrees and back.""" - tmp_degrees = self.plugin.complex_to_deg(COMPLEX_ANGLES) - result = self.plugin.deg_to_complex(tmp_degrees) - self.assertArrayAlmostEqual(result, COMPLEX_ANGLES) - - class Test_calc_wind_dir_mean(IrisTest): """Test the calc_wind_dir_mean function.""" @@ -259,7 +187,7 @@ def setUp(self): self.plugin = WindDirection() # 5x3x4 3D Array containing wind direction in angles. cube = make_wdir_cube_534() - self.plugin.wdir_complex = self.plugin.deg_to_complex(cube.data) + self.plugin.wdir_complex = deg_to_complex(cube.data) self.plugin.wdir_slice_mean = next(cube.slices_over("realization")) self.plugin.realization_axis = 0 @@ -276,7 +204,7 @@ def test_complex(self): """Test that the function defines correct complex mean.""" self.plugin.calc_wind_dir_mean() result = self.plugin.wdir_mean_complex - expected_complex = self.plugin.deg_to_complex( + expected_complex = deg_to_complex( self.expected_wind_mean, radius=np.absolute(result) ) self.assertArrayAlmostEqual(result, expected_complex) @@ -344,9 +272,7 @@ def test_runs_function_1st_member(self): self.plugin.wdir_slice_mean = cube[0].copy( data=np.array([[180.0, 55.0], [280.0, 0.0]]) ) - self.plugin.wdir_mean_complex = self.plugin.deg_to_complex( - self.plugin.wdir_slice_mean.data - ) + self.plugin.wdir_mean_complex = deg_to_complex(self.plugin.wdir_slice_mean.data) expected_out = np.array([[90.0, 55.0], [280.0, 0.0]]) where_low_r = np.array([[True, False], [False, False]]) self.plugin.wind_dir_decider(where_low_r, cube) @@ -376,7 +302,7 @@ def test_runs_function_nbhood(self): self.plugin.realization_axis = 0 self.plugin.n_realizations = 1 self.plugin.wdir_mean_complex = np.pad( - self.plugin.deg_to_complex(wind_dir_deg_mean), + deg_to_complex(wind_dir_deg_mean), ((4, 4), (4, 4)), "constant", constant_values=(0.0, 0.0),