diff --git a/.mailmap b/.mailmap index 4a73569085..8864b5ed0b 100644 --- a/.mailmap +++ b/.mailmap @@ -2,6 +2,7 @@ Aaron Hopkinson AndrewCreswick Anna Booton Anja Schubert <57921950+anja-bom@users.noreply.github.com> +Anzer Khan Belinda Trotta <73675905+btrotta-bom@users.noreply.github.com> <73675905+btrotta-bom@users.noreply.github.com> Benjamin Ayliffe Benjamin Owen <71359048+benowen-bom@users.noreply.github.com> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c50b82f3b..6412080091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,7 @@ below: - Simon Jackson (Met Office, UK) - Caroline Jones (Met Office, UK) - Peter Jordan (Met Office, UK) + - Anzer Khan (Met Office, UK) - Bruno P. Kinoshita (NIWA, NZ) - Phoebe Lambert (Met Office, UK) - Lucy Liu (Bureau of Meteorology, Australia) diff --git a/improver/api/__init__.py b/improver/api/__init__.py index 5c51a141cd..617d10f562 100644 --- a/improver/api/__init__.py +++ b/improver/api/__init__.py @@ -29,6 +29,7 @@ "ApplyReliabilityCalibration": "improver.calibration.reliability_calibration", "BaseNeighbourhoodProcessing": "improver.nbhood.nbhood", "CalculateForecastBias": "improver.calibration.simple_bias_correction", + "CalculateWindChill": "improver.temperature.feels_like_temperature", "CalibratedForecastDistributionParameters": "improver.calibration.emos_calibration", "ChooseDefaultWeightsLinear": "improver.blending.weights", "ChooseDefaultWeightsNonLinear": "improver.blending.weights", diff --git a/improver/temperature/feels_like_temperature.py b/improver/temperature/feels_like_temperature.py index e28a9a525e..4221a40955 100644 --- a/improver/temperature/feels_like_temperature.py +++ b/improver/temperature/feels_like_temperature.py @@ -10,6 +10,7 @@ from iris.cube import Cube from numpy import ndarray +from improver import BasePlugin from improver.metadata.utilities import ( create_new_diagnostic_cube, generate_mandatory_attributes, @@ -19,70 +20,119 @@ ) -def _calculate_wind_chill(temperature: ndarray, wind_speed: ndarray) -> ndarray: +class CalculateWindChill(BasePlugin): """ - Calculates the wind chill from 10 m wind speed and temperature based on - the wind chill temperature index from a linear regression equation detailed - in THE NEW WIND CHILL EQUIVALENT TEMPERATURE CHART, Osczevski and - Bluestein, 2005, table 2. - - Args: - temperature: - Air temperature in degrees celsius - wind_speed: - Wind speed in kilometres per hour - - Returns: - Wind chill temperatures in degrees celsius - - References: - Osczevski, R. and Bluestein, M. (2005). THE NEW WIND CHILL EQUIVALENT - TEMPERATURE CHART. Bulletin of the American Meteorological Society, - 86(10), pp.1453-1458. - - Osczevski, R. and Bluestein, M. (2008). Comments on Inconsistencies in - the New Windchill Chart at Low Wind Speeds. Journal of Applied - Meteorology and Climatology, 47(10), pp.2737-2738. - - Science background: - The 2005 Osczevski and Bluestein paper outlines the research and the - assumptions made, and the 2008 paper clarifies poorly explained sections - of the first paper. - - A brief summary of their assumptions are given below: - The model aims to determine a worst-case scenario of wind chill. The wind - speed "threshold" of 4.8 kph (1.34 m/s) stated in the 2005 papers does not - refer to a threshold placed on the input windspeed data, which has no upper - limit, but is the walking speed of an average person. This is therefore - used as the minimum wind speed in their wind chill computer model, because - even where wind speed is zero, a person would still experience wind chill - from the act of walking (the model assumes that the person is walking into - the wind). Furthermore, the equation is not valid for very low wind speeds - and will return wind chill values higher than the air temperature if this - lower wind speed limit is not imposed. Even with this limit, the calculated - wind chill will be higher than the air temperature when the temperature is - above about 11.5C and the wind is 4.8 kph. - The model introduces a compensation factor where it assumes that - the wind speed at 1.5 m (face level) is 2/3 that measured at 10 m. It also - takes into account the thermal resistance of the skin on the human cheek - with the assumption that the face is the most exposed area of skin - during winter. - - The equation outlined in their paper is also not the equation used in their - model (which was computationally expensive) but rather it is a linear - regression equation which mimics the output of their model where wind - speeds are greater than 3kph (0.8m/s) (clarified in the 2008 paper). The - assumption being that lower wind speeds are usually not measured or - reported accurately anyway. + Plugin to calculate wind chill temperature from air temperature and wind speed. + Based on Osczevski and Bluestein (2005). """ - eqn_component = np.clip(wind_speed, 4.824, None) ** 0.16 - wind_chill = ( - 13.12 - + 0.6215 * temperature - - 11.37 * eqn_component - + 0.3965 * temperature * eqn_component - ).astype(np.float32) - return wind_chill + + def __init__(self, model_id_attr: Optional[str] = None): + """Set up the plugin.""" + self.model_id_attr = model_id_attr + + def _calculate_wind_chill( + self, temperature: ndarray, wind_speed: ndarray + ) -> ndarray: + """ + Calculates the wind chill from 10 m wind speed and temperature based on + the wind chill temperature index from a linear regression equation detailed + in THE NEW WIND CHILL EQUIVALENT TEMPERATURE CHART, Osczevski and + Bluestein, 2005, table 2. + + Args: + temperature: + Air temperature in degrees celsius + wind_speed: + Wind speed in kilometres per hour + + Returns: + Wind chill temperatures in degrees celsius + + References: + Osczevski, R. and Bluestein, M. (2005). THE NEW WIND CHILL EQUIVALENT + TEMPERATURE CHART. Bulletin of the American Meteorological Society, + 86(10), pp.1453-1458. + + Osczevski, R. and Bluestein, M. (2008). Comments on Inconsistencies in + the New Windchill Chart at Low Wind Speeds. Journal of Applied + Meteorology and Climatology, 47(10), pp.2737-2738. + + Science background: + The 2005 Osczevski and Bluestein paper outlines the research and the + assumptions made, and the 2008 paper clarifies poorly explained sections + of the first paper. + + A brief summary of their assumptions are given below: + The model aims to determine a worst-case scenario of wind chill. The wind + speed "threshold" of 4.8 kph (1.34 m/s) stated in the 2005 papers does not + refer to a threshold placed on the input windspeed data, which has no upper + limit, but is the walking speed of an average person. This is therefore + used as the minimum wind speed in their wind chill computer model, because + even where wind speed is zero, a person would still experience wind chill + from the act of walking (the model assumes that the person is walking into + the wind). Furthermore, the equation is not valid for very low wind speeds + and will return wind chill values higher than the air temperature if this + lower wind speed limit is not imposed. Even with this limit, the calculated + wind chill will be higher than the air temperature when the temperature is + above about 11.5C and the wind is 4.8 kph. + The model introduces a compensation factor where it assumes that + the wind speed at 1.5 m (face level) is 2/3 that measured at 10 m. It also + takes into account the thermal resistance of the skin on the human cheek + with the assumption that the face is the most exposed area of skin + during winter. + + The equation outlined in their paper is also not the equation used in their + model (which was computationally expensive) but rather it is a linear + regression equation which mimics the output of their model where wind + speeds are greater than 3kph (0.8m/s) (clarified in the 2008 paper). The + assumption being that lower wind speeds are usually not measured or + reported accurately anyway. + """ + eqn_component = np.clip(wind_speed, 4.824, None) ** 0.16 + wind_chill = ( + 13.12 + + 0.6215 * temperature + - 11.37 * eqn_component + + 0.3965 * temperature * eqn_component + ).astype(np.float32) + return wind_chill + + def process(self, temperature: Cube, wind_speed: Cube) -> Cube: + """ + Calculate the wind chill temperature using Iris cubes. + + Args: + temperature: + Cube of air temperature. + wind_speed: + Cube of 10m wind speed. + + Returns: + Cube of wind chill temperature (same shape and grid as inputs). + """ + orig_temp_units = temperature.units + t_cube = temperature.copy() + t_cube.convert_units("degC") + w_cube = wind_speed.copy() + w_cube.convert_units("km h-1") + + wind_chill_data = self._calculate_wind_chill(t_cube.data, w_cube.data) + + attributes = generate_mandatory_attributes( + [temperature, wind_speed], + model_id_attr=self.model_id_attr, + ) + wind_chill_cube = create_new_diagnostic_cube( + "wind_chill_temperature", + "degC", + temperature, + attributes, + data=wind_chill_data, + ) + # Match input temperature units + wind_chill_cube.convert_units(orig_temp_units) + + return wind_chill_cube def _calculate_apparent_temperature( @@ -211,8 +261,9 @@ def calculate_feels_like_temperature( t_celsius, w_cube.data, rh_cube.data, p_cube.data ) - w_cube.convert_units("km h-1") - wind_chill = _calculate_wind_chill(t_celsius, w_cube.data) + wind_chill_plugin = CalculateWindChill(model_id_attr=model_id_attr) + wind_chill_cube = wind_chill_plugin.process(t_cube, w_cube) + wind_chill = wind_chill_cube.data feels_like_temperature = _feels_like_temperature( t_celsius, apparent_temperature, wind_chill diff --git a/improver_tests/temperature/feels_like_temperature/test_feels_like_temperature.py b/improver_tests/temperature/feels_like_temperature/test_feels_like_temperature.py index 6b904fdfcc..e5a3129baa 100644 --- a/improver_tests/temperature/feels_like_temperature/test_feels_like_temperature.py +++ b/improver_tests/temperature/feels_like_temperature/test_feels_like_temperature.py @@ -7,15 +7,83 @@ import unittest import numpy as np +import pytest from iris.tests import IrisTest from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube from improver.temperature.feels_like_temperature import ( + CalculateWindChill, _calculate_apparent_temperature, - _calculate_wind_chill, calculate_feels_like_temperature, ) +MANDATORY_ATTRIBUTES = { + "source": "Met Office Unified Model", + "institution": "Met Office", + "title": "UKV Model Forecast on UK 2 km Standard Grid", +} + + +@pytest.fixture +def temperature_cube(): + """Simple temperature cube (273.15 K = 0°C).""" + data = np.full((3, 3), 273.15, dtype=np.float32) + return set_up_variable_cube( + data, + name="air_temperature", + units="K", + standard_grid_metadata="uk_det", + attributes=MANDATORY_ATTRIBUTES, + ) + + +@pytest.fixture +def wind_speed_cube(): + """Simple wind-speed cube (10 m/s).""" + data = np.full((3, 3), 10.0, dtype=np.float32) + return set_up_variable_cube( + data, + name="wind_speed", + units="m s-1", + standard_grid_metadata="uk_det", + attributes=MANDATORY_ATTRIBUTES, + ) + + +def test__calculate_wind_chill_values(): + """Direct test of the internal wind chill equation method.""" + temperature = np.full((1, 3), 1.7) + wind_speed = np.full((1, 3), 3) * 60 * 60 / 1000.0 + expected = np.full((1, 3), -1.4754, dtype=np.float32) + + plugin = CalculateWindChill() + result = plugin._calculate_wind_chill(temperature, wind_speed) + + np.testing.assert_almost_equal(result, expected, decimal=4) + + +def test_process_outputs_expected_cube(temperature_cube, wind_speed_cube): + """Test that process() returns correct data, preserves metadata, and + keeps coordinates identical to the input cube.""" + plugin = CalculateWindChill() + result = plugin.process(temperature_cube, wind_speed_cube) + + assert result.name() == "wind_chill_temperature" + assert str(result.units) == str(temperature_cube.units) + + for key, val in MANDATORY_ATTRIBUTES.items(): + assert key in result.attributes + assert result.attributes[key] == val + + input_coords = [coord.name() for coord in temperature_cube.coords(dim_coords=True)] + output_coords = [coord.name() for coord in result.coords(dim_coords=True)] + all_coords = set(input_coords + output_coords) + for coord_name in all_coords: + assert temperature_cube.coord(coord_name) == result.coord(coord_name) + expected_data = np.full((3, 3), 266.09708, dtype=np.float32) + assert result.data.shape == temperature_cube.data.shape + np.testing.assert_allclose(result.data, expected_data, rtol=1e-6) + class Test__calculate_apparent_temperature(IrisTest): """Test the apparent temperature function.""" @@ -33,18 +101,6 @@ def test_values(self): self.assertArrayAlmostEqual(result, expected_result, decimal=4) -class Test__calculate_wind_chill(IrisTest): - """Test the wind chill function.""" - - def test_values(self): - """Test output values when from the wind chill equation.""" - temperature = np.full((1, 3), 1.7) - wind_speed = np.full((1, 3), 3) * 60 * 60 / 1000.0 - expected_result = np.full((1, 3), -1.4754, dtype=np.float32) - result = _calculate_wind_chill(temperature, wind_speed) - self.assertArrayAlmostEqual(result, expected_result, decimal=4) - - class Test_calculate_feels_like_temperature(IrisTest): """Test the feels like temperature function."""