From bb0a3e1e03500120715a1b11b98cecb48fa66f3d Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Tue, 3 Mar 2026 15:04:37 +0000 Subject: [PATCH 01/21] Add layer extraction and interpolation plugin --- .../temperature/layer_mean_temperature.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 improver/temperature/layer_mean_temperature.py diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py new file mode 100644 index 0000000000..843b849243 --- /dev/null +++ b/improver/temperature/layer_mean_temperature.py @@ -0,0 +1,42 @@ +# (C) Crown Copyright, Met Office. All rights reserved. +# +# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import iris +import numpy as np + + +class LayerExtractionAndInterpolation: + def __init__(self, metres_to_ft=3.28084): + self.metres_to_ft = metres_to_ft + + def process(self, temp_cube, bottom, top, verbosity=0): + if verbosity: + print(f"Extracting/interpolating levels from {bottom} to {top} ft") + # Extract cube of temperature levels within layer + between_layer_temp_cube = temp_cube.extract( + iris.Constraint( + height=lambda point: bottom / self.metres_to_ft + < point + < top / self.metres_to_ft + ) + ) + # Interpolate temperature at top and base of layer + base_temp = temp_cube.interpolate( + [("height", np.array([bottom / self.metres_to_ft], dtype=np.float32))], + iris.analysis.Linear(), + collapse_scalar=False, + ) + top_temp = temp_cube.interpolate( + [("height", np.array([top / self.metres_to_ft], dtype=np.float32))], + iris.analysis.Linear(), + collapse_scalar=False, + ) + # Merge cubes of temperature at top, bottom and within layer + layer_levels_temp_cube = iris.cube.CubeList( + [base_temp, between_layer_temp_cube, top_temp] + ).concatenate_cube() + if verbosity > 1: + print(layer_levels_temp_cube) + + return layer_levels_temp_cube From c4579b6a4e0e424f908bac1c3990717ddec9b1c3 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Wed, 4 Mar 2026 12:27:50 +0000 Subject: [PATCH 02/21] Add calculate layer mean temperature plugin --- .../temperature/layer_mean_temperature.py | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index 843b849243..5100a25a3b 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -5,8 +5,10 @@ import iris import numpy as np +from improver import BasePlugin -class LayerExtractionAndInterpolation: + +class LayerExtractionAndInterpolation(BasePlugin): def __init__(self, metres_to_ft=3.28084): self.metres_to_ft = metres_to_ft @@ -40,3 +42,62 @@ def process(self, temp_cube, bottom, top, verbosity=0): print(layer_levels_temp_cube) return layer_levels_temp_cube + + +class CalculateLayerMeanTemperature(BasePlugin): + """Calculate the vertically weighted mean temperature for a layer.""" + + def process(self, layer_cube, verbosity=0): + """ + Calculate the mean temperature between the lowest and highest heights in the input cube. + + Args: + layer_cube (iris.cube.Cube): Cube containing temperature at heights within the specified layer + verbosity (int): Set level of output to print. + + Returns: + iris.cube.Cube: 2D cube of layer mean temperature. + """ + + # Set up array for holding sum of products of temperature and vertical distance + layer_temp_product = np.zeros(layer_cube.data.shape[1:]) + + # Estimate mean temperature of layers between 2000-3000ft and + # weight by vertical extent of layer + altitude_array = layer_cube.coord("height").points + for alt_index in range(1, len(altitude_array) - 1): + layer_thickness = ( + altitude_array[alt_index + 1] - altitude_array[alt_index - 1] + ) / 2 + layer_temp_product += layer_cube.data[alt_index, :, :] * layer_thickness + + # Add contributions from base and top + layer_temp_product += ( + layer_cube.data[0, :, :] * (altitude_array[1] - altitude_array[0]) / 2 + ) + layer_temp_product += ( + layer_cube.data[-1, :, :] * (altitude_array[-1] - altitude_array[-2]) / 2 + ) + + # Divide by total thickness to get mean + lmt_array = layer_temp_product / (altitude_array[-1] - altitude_array[0]) + + if verbosity: + print("Layer mean temperature array:", lmt_array) + + # Wrap result in a cube (add metadata as needed) + lmt_cube = iris.cube.Cube( + lmt_array, + var_name="air_temperature", + units="K", + dim_coords_and_dims=( + (layer_cube.coord("projection_y_coordinate"), 0), + (layer_cube.coord("projection_x_coordinate"), 1), + ), + aux_coords_and_dims=( + (layer_cube.coord("forecast_period"), ()), + (layer_cube.coord("forecast_reference_time"), ()), + (layer_cube.coord("time"), ()), + ), + ) + return lmt_cube From 67925e4d60b15304b967ee4bf64a91d183e802d0 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Wed, 4 Mar 2026 13:08:43 +0000 Subject: [PATCH 03/21] Add doctsrtings --- .../temperature/layer_mean_temperature.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index 5100a25a3b..708c376332 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -9,10 +9,39 @@ class LayerExtractionAndInterpolation(BasePlugin): + """ + Plugin to extract and interpolate temperature values at specified layer boundaries. + + This plugin extracts all temperature levels within a specified vertical layer + (between `bottom` and `top` heights, in feet), and interpolates temperature + at the exact base and top of the layer. The output is a cube containing + temperature at all interior levels plus the interpolated base and top. + + """ + def __init__(self, metres_to_ft=3.28084): + """ + Initialise the plugin. + + Args: + metres_to_ft (float): Conversion factor from metres to feet. + """ self.metres_to_ft = metres_to_ft def process(self, temp_cube, bottom, top, verbosity=0): + """ + Extract and interpolate temperature values at layer boundaries. + + Args: + temp_cube (iris.cube.Cube): Input temperature cube with a height coordinate. + bottom (float): Lower boundary of the layer (in feet). + top (float): Upper boundary of the layer (in feet). + verbosity (int): Verbosity level for printing debug information. + + Returns: + iris.cube.Cube: Cube containing temperature at all layer heights + (base, interior, and top). + """ if verbosity: print(f"Extracting/interpolating levels from {bottom} to {top} ft") # Extract cube of temperature levels within layer From 38b9321b68268deb05e53d62edfd536b3f6fab4a Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Wed, 4 Mar 2026 17:12:01 +0000 Subject: [PATCH 04/21] Add unit tests for layer extraction and interpolation --- .../layer_mean_temperature/__init__.py | 0 ...test_layer_extraction_and_interpolation.py | 134 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 improver_tests/temperature/layer_mean_temperature/__init__.py create mode 100644 improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py diff --git a/improver_tests/temperature/layer_mean_temperature/__init__.py b/improver_tests/temperature/layer_mean_temperature/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py new file mode 100644 index 0000000000..dfe4378446 --- /dev/null +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py @@ -0,0 +1,134 @@ +# (C) Crown Copyright, Met Office. All rights reserved. +# +# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import iris +import numpy as np + +from improver.temperature.layer_mean_temperature import LayerExtractionAndInterpolation + +METRES_TO_FT = 3.28084 + + +def make_test_cube(): + """Create a simple 3D temperature cube with shape (height, y, x) for testing. + + Heights are in metres: + - 200 m (~656 ft) + - 220 m (~722 ft) + - 240 m (~787 ft) + """ + data = np.array( + [ + [[280, 281], [282, 283]], # height = 200 + [[285, 286], [287, 288]], # height = 220 m + [[290, 291], [292, 293]], # height = 250 m + ], + dtype=np.float32, + ) + height_points = np.array([200, 220, 240], dtype=np.float32) + y_points = np.array([0, 1], dtype=np.float32) + x_points = np.array([0, 1], dtype=np.float32) + cube = iris.cube.Cube( + data, + dim_coords_and_dims=[ + (iris.coords.DimCoord(height_points, standard_name="height", units="m"), 0), + (iris.coords.DimCoord(y_points, "projection_y_coordinate", units="m"), 1), + (iris.coords.DimCoord(x_points, "projection_x_coordinate", units="m"), 2), + ], + var_name="air_temperature", + units="K", + ) + return cube + + +def test_extract_and_interpolate_layer(): + """Test that LayerExtractionAndInterpolation correctly extracts and interpolates + temperature levels within a specified layer. + + Layer bounds: 600 ft (≈182.88 m) to 800 ft (≈243.84 m). + + Expected output: + - Interpolated base at 600 ft (≈182.88 m) + - Interior levels at 200 m, 220 m, 240 m + - Interpolated top at 800 ft (≈243.84 m) + - Total: 5 height levels, shape (5, 2, 2) + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + # bottom=600 ft converts to ~182.88 m, top=800 ft converts to ~243.84 m + # All three data levels (200, 220, 240 m) fall within the layer bounds, + # so the result should contain 5 heights: interpolated base + 3 interior + interpolated top + result = plugin.process(cube, bottom=600, top=800, verbosity=0) + # Should include interpolated base (600ft), all interior levels, and interpolated top (800ft) + # In this synthetic example, all heights are present, so result should have 5 heights + assert result.shape == (5, 2, 2) + # Check height coordinate values + expected_heights = np.array([182.88, 200, 220, 240, 243.84]) + np.testing.assert_allclose( + result.coord("height").points, expected_heights, rtol=1e-2 + ) + + +def test_extract_and_interpolate_layer_coordinates(): + """Test that LayerExtractionAndInterpolation preserves and returns + the correct coordinates in the output cube. + + Checks: + - Height coordinate exists with correct units. + - projection_y_coordinate exists and matches input. + - projection_x_coordinate exists and matches input. + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + result = plugin.process(cube, bottom=600, top=800, verbosity=0) + + # Check height coordinate exists and has correct units + height_coord = result.coord("height") + assert height_coord.units == "m" + + # Check y coordinate exists and matches input + np.testing.assert_array_equal( + result.coord("projection_y_coordinate").points, + cube.coord("projection_y_coordinate").points, + ) + + # Check x coordinate exists and matches input + np.testing.assert_array_equal( + result.coord("projection_x_coordinate").points, + cube.coord("projection_x_coordinate").points, + ) + + +def test_interpolated_base_and_top_values(): + """Test that LayerExtractionAndInterpolation correctly interpolates + temperature values at the base and top of the layer. + + Layer bounds: 600 ft (≈182.88 m) to 800 ft (≈243.84 m). + + Expected values are derived directly from iris.analysis.Linear() interpolation + to ensure we are testing our plugin's behaviour, not reimplementing the science. + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + result = plugin.process(cube, bottom=600, top=800, verbosity=0) + + # Use Iris directly to get the expected interpolated values at base and top + expected_base = cube.interpolate( + [("height", np.array([600 / METRES_TO_FT], dtype=np.float32))], + iris.analysis.Linear(), + ) + expected_top = cube.interpolate( + [("height", np.array([800 / METRES_TO_FT], dtype=np.float32))], + iris.analysis.Linear(), + ) + + # Check base temperature values match Iris interpolation at all grid points + np.testing.assert_allclose( + result.data[0, :, :], expected_base.data[0, :, :], rtol=1e-5 + ) + + # Check top temperature values match Iris interpolation at all grid points + np.testing.assert_allclose( + result.data[-1, :, :], expected_top.data[0, :, :], rtol=1e-5 + ) From 8b2d3ce0b978582f07a68eae35b4a4add66f9867 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Thu, 5 Mar 2026 13:17:19 +0000 Subject: [PATCH 05/21] Add unit test for CalculateLayerMeanTemperature plugin --- .../test_layer_mean_temperature.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py new file mode 100644 index 0000000000..132cb98fcb --- /dev/null +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py @@ -0,0 +1,94 @@ +# (C) Crown Copyright, Met Office. All rights reserved. +# +# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +import iris +import numpy as np + +from improver.temperature.layer_mean_temperature import CalculateLayerMeanTemperature + + +def make_layer_cube(data): + """Create a simple 3D temperature cube with shape (height, y, x) for testing. + + Args: + data (np.ndarray): Temperature data with shape (height, y, x). + + Heights are in metres: + - 100 m + - 200 m + - 300 m + """ + height_points = np.array([100, 200, 300], dtype=np.float32) + y_points = np.array([0, 1], dtype=np.float32) + x_points = np.array([0, 1], dtype=np.float32) + cube = iris.cube.Cube( + data, + dim_coords_and_dims=[ + (iris.coords.DimCoord(height_points, standard_name="height", units="m"), 0), + (iris.coords.DimCoord(y_points, "projection_y_coordinate", units="m"), 1), + (iris.coords.DimCoord(x_points, "projection_x_coordinate", units="m"), 2), + ], + var_name="air_temperature", + units="K", + ) + # Add scalar coordinates expected by CalculateLayerMeanTemperature + cube.add_aux_coord( + iris.coords.AuxCoord(0, standard_name="forecast_period", units="seconds") + ) + cube.add_aux_coord( + iris.coords.AuxCoord( + 0, standard_name="forecast_reference_time", units="seconds since epoch" + ) + ) + cube.add_aux_coord( + iris.coords.AuxCoord(0, standard_name="time", units="seconds since epoch") + ) + return cube + + +def test_layer_mean_temperature_uniform(): + """Test that CalculateLayerMeanTemperature returns the correct mean + for a uniform temperature field. + + If all temperature values are constant (280 K) at every height level, + the weighted mean must also be 280 K regardless of height spacing or weighting. + """ + # All data values set to 280 K across all heights and grid points + data = np.full((3, 2, 2), 280, dtype=np.float32) + cube = make_layer_cube(data) + plugin = CalculateLayerMeanTemperature() + result = plugin.process(cube, verbosity=0) + + # Mean of a uniform field must equal the uniform value + np.testing.assert_allclose(result.data, 280, rtol=1e-5) + + +def test_layer_mean_temperature_output_shape_and_coordinates(): + """Test that CalculateLayerMeanTemperature returns a 2D cube with correct + dimension and auxiliary coordinates. + + Checks: + - Output cube is 2D (height dimension collapsed). + - projection_y_coordinate and projection_x_coordinate are present. + - Scalar coordinates (forecast_period, time, forecast_reference_time) are preserved. + """ + data = np.full((3, 2, 2), 280, dtype=np.float32) + cube = make_layer_cube(data) + plugin = CalculateLayerMeanTemperature() + result = plugin.process(cube, verbosity=0) + + # Check output is 2D - height dimension has been collapsed + assert result.shape == (2, 2) + + # Check dimension coordinates are present + assert result.coords("projection_y_coordinate") + assert result.coords("projection_x_coordinate") + + # Check height coordinate is no longer a dimension coordinate + assert not result.coords("height", dim_coords=True) + + # Check scalar coordinates are preserved + assert result.coords("forecast_period") + assert result.coords("forecast_reference_time") + assert result.coords("time") From 3d198f77456555eaf9f3cf0f9e95b9410e40e722 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Thu, 5 Mar 2026 14:43:16 +0000 Subject: [PATCH 06/21] Add unit tests for edge cases --- .../temperature/layer_mean_temperature.py | 10 ++- ...test_layer_extraction_and_interpolation.py | 75 +++++++++++++++++++ .../test_layer_mean_temperature.py | 39 +++++++++- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index 708c376332..bf1604e338 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -42,6 +42,9 @@ def process(self, temp_cube, bottom, top, verbosity=0): iris.cube.Cube: Cube containing temperature at all layer heights (base, interior, and top). """ + if bottom >= top: + raise ValueError(f"bottom ({bottom} ft) must be less than top ({top} ft).") + if verbosity: print(f"Extracting/interpolating levels from {bottom} to {top} ft") # Extract cube of temperature levels within layer @@ -64,9 +67,10 @@ def process(self, temp_cube, bottom, top, verbosity=0): collapse_scalar=False, ) # Merge cubes of temperature at top, bottom and within layer - layer_levels_temp_cube = iris.cube.CubeList( - [base_temp, between_layer_temp_cube, top_temp] - ).concatenate_cube() + cubes_to_merge = [base_temp, between_layer_temp_cube, top_temp] + cubes_to_merge = [cube for cube in cubes_to_merge if cube is not None] + layer_levels_temp_cube = iris.cube.CubeList(cubes_to_merge).concatenate_cube() + if verbosity > 1: print(layer_levels_temp_cube) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py index dfe4378446..73842ba54e 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py @@ -4,6 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. import iris import numpy as np +import pytest from improver.temperature.layer_mean_temperature import LayerExtractionAndInterpolation @@ -132,3 +133,77 @@ def test_interpolated_base_and_top_values(): np.testing.assert_allclose( result.data[-1, :, :], expected_top.data[0, :, :], rtol=1e-5 ) + + +def test_verbosity_layer_extraction(capsys): + """Test that LayerExtractionAndInterpolation prints expected output + when verbosity is set to 1. + + Checks that the bottom and top layer bounds are printed to stdout. + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + plugin.process(cube, bottom=600, top=800, verbosity=1) + captured = capsys.readouterr() + assert "600" in captured.out + assert "800" in captured.out + + +def test_layer_bounds_match_data_level(): + """Test that LayerExtractionAndInterpolation handles the case where + the layer bounds exactly match an existing data level. + + Layer bounds: 656 ft (≈200 m) to 787 ft (≈240 m). + Both 200 m and 240 m are exact data levels in the test cube. + + Expected output: + - No duplicate height points in the output cube. + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + + # Pass bounds directly in feet - the plugin handles the conversion internally + result = plugin.process(cube, bottom=656, top=787, verbosity=0) + + # Check no duplicate height points exist in the output + height_points = result.coord("height").points + assert len(height_points) == len( + np.unique(height_points) + ), f"Duplicate height points found: {height_points}" + + +def test_no_interior_levels(): + """Test that LayerExtractionAndInterpolation returns at least the interpolated + base and top when no interior levels exist within the layer bounds. + + Layer bounds: 610 ft (≈185 m) to 640 ft (≈195 m). + No data levels fall between 185 m and 195 m in the test cube + (nearest levels are 200 m, 220 m, 240 m). + + Expected output: + - Interpolated base at 610 ft (≈185 m) + - Interpolated top at 640 ft (≈195 m) + - Total: 2 height levels, shape (2, 2, 2) + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + + # These bounds have no data levels between them + result = plugin.process(cube, bottom=610, top=640, verbosity=0) + + # Should have exactly 2 levels: interpolated base and top only + assert result.shape == (2, 2, 2) + + +def test_bottom_greater_than_top_raises_error(): + """Test that LayerExtractionAndInterpolation raises a ValueError when + the bottom bound is greater than the top bound. + + This is physically nonsensical and should be caught early with a + clear error message rather than producing garbage output silently. + """ + cube = make_test_cube() + plugin = LayerExtractionAndInterpolation() + + with pytest.raises(ValueError, match="bottom .* must be less than top"): + plugin.process(cube, bottom=800, top=600, verbosity=0) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py index 132cb98fcb..974cc1d3f1 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py @@ -64,7 +64,7 @@ def test_layer_mean_temperature_uniform(): np.testing.assert_allclose(result.data, 280, rtol=1e-5) -def test_layer_mean_temperature_output_shape_and_coordinates(): +def test_layer_mean_temperature_output_shape_and_coordinates_exists(): """Test that CalculateLayerMeanTemperature returns a 2D cube with correct dimension and auxiliary coordinates. @@ -92,3 +92,40 @@ def test_layer_mean_temperature_output_shape_and_coordinates(): assert result.coords("forecast_period") assert result.coords("forecast_reference_time") assert result.coords("time") + + +def test_layer_mean_temperature_scalar_coordinate_values_preserved(): + """Test that CalculateLayerMeanTemperature preserves scalar coordinate + values from the input cube in the output cube. + + Checks that forecast_period, forecast_reference_time and time coordinate + values are unchanged between input and output. + """ + data = np.full((3, 2, 2), 280, dtype=np.float32) + cube = make_layer_cube(data) + plugin = CalculateLayerMeanTemperature() + result = plugin.process(cube, verbosity=0) + + # Check scalar coordinate values are preserved from input to output + assert ( + result.coord("forecast_period").points == cube.coord("forecast_period").points + ) + assert ( + result.coord("forecast_reference_time").points + == cube.coord("forecast_reference_time").points + ) + assert result.coord("time").points == cube.coord("time").points + + +def test_verbosity_layer_mean_temperature(capsys): + """Test that CalculateLayerMeanTemperature prints expected output + when verbosity is set to 1. + + Checks that the layer mean temperature array is printed to stdout. + """ + data = np.full((3, 2, 2), 280, dtype=np.float32) + cube = make_layer_cube(data) + plugin = CalculateLayerMeanTemperature() + plugin.process(cube, verbosity=1) + captured = capsys.readouterr() + assert "Layer mean temperature array" in captured.out From 9725fafc2ab14104b7cb17b7ee794e67ff26cad4 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Fri, 6 Mar 2026 10:20:40 +0000 Subject: [PATCH 07/21] refactor: Test file renamed --- ...an_temperature.py => test_calculate_layer_mean_temperature.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename improver_tests/temperature/layer_mean_temperature/{test_layer_mean_temperature.py => test_calculate_layer_mean_temperature.py} (100%) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py similarity index 100% rename from improver_tests/temperature/layer_mean_temperature/test_layer_mean_temperature.py rename to improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py From 0d1ca78f8f9e59e58f404429ad3601b59c450d4c Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Mon, 9 Mar 2026 10:22:18 +0000 Subject: [PATCH 08/21] refac: robert changes --- .../temperature/layer_mean_temperature.py | 64 +++++++++---------- ...> test_layer_temperature_interpolation.py} | 32 +++++----- 2 files changed, 45 insertions(+), 51 deletions(-) rename improver_tests/temperature/layer_mean_temperature/{test_layer_extraction_and_interpolation.py => test_layer_temperature_interpolation.py} (87%) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index bf1604e338..d524057744 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -4,13 +4,16 @@ # See LICENSE in the root of the repository for full licensing details. import iris import numpy as np +from iris.cube import Cube from improver import BasePlugin +METRES_TO_FT = 3.28084 -class LayerExtractionAndInterpolation(BasePlugin): + +class LayerTemperatureInterpolation(BasePlugin): """ - Plugin to extract and interpolate temperature values at specified layer boundaries. + Plugin to interpolate temperature values at specified layer boundaries. This plugin extracts all temperature levels within a specified vertical layer (between `bottom` and `top` heights, in feet), and interpolates temperature @@ -19,50 +22,41 @@ class LayerExtractionAndInterpolation(BasePlugin): """ - def __init__(self, metres_to_ft=3.28084): + def process( + self, temp_cube: Cube, bottom: float, top: float, verbosity: int = 0 + ) -> Cube: """ - Initialise the plugin. + Interpolate temperature values at layer boundaries. Args: - metres_to_ft (float): Conversion factor from metres to feet. - """ - self.metres_to_ft = metres_to_ft - - def process(self, temp_cube, bottom, top, verbosity=0): - """ - Extract and interpolate temperature values at layer boundaries. - - Args: - temp_cube (iris.cube.Cube): Input temperature cube with a height coordinate. - bottom (float): Lower boundary of the layer (in feet). - top (float): Upper boundary of the layer (in feet). - verbosity (int): Verbosity level for printing debug information. + temp_cube: Input temperature cube with a height coordinate. + bottom: Lower boundary of the layer in feet. + top: Upper boundary of the layer in feet. + verbosity: Verbosity level for printing debug information. Returns: - iris.cube.Cube: Cube containing temperature at all layer heights - (base, interior, and top). + Cube containing temperature at all layer heights + (base, interior, and top). """ if bottom >= top: - raise ValueError(f"bottom ({bottom} ft) must be less than top ({top} ft).") + raise ValueError(f"Bottom ({bottom} ft) must be less than top ({top} ft).") if verbosity: - print(f"Extracting/interpolating levels from {bottom} to {top} ft") + print(f"Interpolating temperature at {bottom} ft and {top} ft") # Extract cube of temperature levels within layer between_layer_temp_cube = temp_cube.extract( iris.Constraint( - height=lambda point: bottom / self.metres_to_ft - < point - < top / self.metres_to_ft + height=lambda point: bottom / METRES_TO_FT < point < top / METRES_TO_FT ) ) # Interpolate temperature at top and base of layer base_temp = temp_cube.interpolate( - [("height", np.array([bottom / self.metres_to_ft], dtype=np.float32))], + [("height", np.array([bottom / METRES_TO_FT], dtype=np.float32))], iris.analysis.Linear(), collapse_scalar=False, ) top_temp = temp_cube.interpolate( - [("height", np.array([top / self.metres_to_ft], dtype=np.float32))], + [("height", np.array([top / METRES_TO_FT], dtype=np.float32))], iris.analysis.Linear(), collapse_scalar=False, ) @@ -80,23 +74,23 @@ def process(self, temp_cube, bottom, top, verbosity=0): class CalculateLayerMeanTemperature(BasePlugin): """Calculate the vertically weighted mean temperature for a layer.""" - def process(self, layer_cube, verbosity=0): + def process(self, layer_cube: Cube, verbosity: int = 0) -> Cube: """ - Calculate the mean temperature between the lowest and highest heights in the input cube. + Calculate the altitude-weighted mean temperature across the layer. Args: - layer_cube (iris.cube.Cube): Cube containing temperature at heights within the specified layer - verbosity (int): Set level of output to print. + layer_cube: Cube containing temperature at all heights within + the specified layer (including interpolated base and top). + verbosity: Set level of output to print. Returns: - iris.cube.Cube: 2D cube of layer mean temperature. + 2D cube of layer mean temperature. """ - # Set up array for holding sum of products of temperature and vertical distance layer_temp_product = np.zeros(layer_cube.data.shape[1:]) - # Estimate mean temperature of layers between 2000-3000ft and - # weight by vertical extent of layer + # Estimate mean temperature of layers and + # Weight by vertical extent of layer altitude_array = layer_cube.coord("height").points for alt_index in range(1, len(altitude_array) - 1): layer_thickness = ( @@ -118,7 +112,7 @@ def process(self, layer_cube, verbosity=0): if verbosity: print("Layer mean temperature array:", lmt_array) - # Wrap result in a cube (add metadata as needed) + # Wrap result in a cube and add required metadata lmt_cube = iris.cube.Cube( lmt_array, var_name="air_temperature", diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py similarity index 87% rename from improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py rename to improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 73842ba54e..8145b7dc2e 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_extraction_and_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -6,7 +6,7 @@ import numpy as np import pytest -from improver.temperature.layer_mean_temperature import LayerExtractionAndInterpolation +from improver.temperature.layer_mean_temperature import LayerTemperatureInterpolation METRES_TO_FT = 3.28084 @@ -44,7 +44,7 @@ def make_test_cube(): def test_extract_and_interpolate_layer(): - """Test that LayerExtractionAndInterpolation correctly extracts and interpolates + """Test that LayerTemperatureInterpolation correctly extracts and interpolates temperature levels within a specified layer. Layer bounds: 600 ft (≈182.88 m) to 800 ft (≈243.84 m). @@ -56,7 +56,7 @@ def test_extract_and_interpolate_layer(): - Total: 5 height levels, shape (5, 2, 2) """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() # bottom=600 ft converts to ~182.88 m, top=800 ft converts to ~243.84 m # All three data levels (200, 220, 240 m) fall within the layer bounds, # so the result should contain 5 heights: interpolated base + 3 interior + interpolated top @@ -72,7 +72,7 @@ def test_extract_and_interpolate_layer(): def test_extract_and_interpolate_layer_coordinates(): - """Test that LayerExtractionAndInterpolation preserves and returns + """Test that LayerTemperatureInterpolation preserves and returns the correct coordinates in the output cube. Checks: @@ -81,7 +81,7 @@ def test_extract_and_interpolate_layer_coordinates(): - projection_x_coordinate exists and matches input. """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() result = plugin.process(cube, bottom=600, top=800, verbosity=0) # Check height coordinate exists and has correct units @@ -102,7 +102,7 @@ def test_extract_and_interpolate_layer_coordinates(): def test_interpolated_base_and_top_values(): - """Test that LayerExtractionAndInterpolation correctly interpolates + """Test that LayerTemperatureInterpolation correctly interpolates temperature values at the base and top of the layer. Layer bounds: 600 ft (≈182.88 m) to 800 ft (≈243.84 m). @@ -111,7 +111,7 @@ def test_interpolated_base_and_top_values(): to ensure we are testing our plugin's behaviour, not reimplementing the science. """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() result = plugin.process(cube, bottom=600, top=800, verbosity=0) # Use Iris directly to get the expected interpolated values at base and top @@ -136,13 +136,13 @@ def test_interpolated_base_and_top_values(): def test_verbosity_layer_extraction(capsys): - """Test that LayerExtractionAndInterpolation prints expected output + """Test that LayerTemperatureInterpolation prints expected output when verbosity is set to 1. Checks that the bottom and top layer bounds are printed to stdout. """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() plugin.process(cube, bottom=600, top=800, verbosity=1) captured = capsys.readouterr() assert "600" in captured.out @@ -150,7 +150,7 @@ def test_verbosity_layer_extraction(capsys): def test_layer_bounds_match_data_level(): - """Test that LayerExtractionAndInterpolation handles the case where + """Test that LayerTemperatureInterpolation handles the case where the layer bounds exactly match an existing data level. Layer bounds: 656 ft (≈200 m) to 787 ft (≈240 m). @@ -160,7 +160,7 @@ def test_layer_bounds_match_data_level(): - No duplicate height points in the output cube. """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() # Pass bounds directly in feet - the plugin handles the conversion internally result = plugin.process(cube, bottom=656, top=787, verbosity=0) @@ -173,7 +173,7 @@ def test_layer_bounds_match_data_level(): def test_no_interior_levels(): - """Test that LayerExtractionAndInterpolation returns at least the interpolated + """Test that LayerTemperatureInterpolation returns at least the interpolated base and top when no interior levels exist within the layer bounds. Layer bounds: 610 ft (≈185 m) to 640 ft (≈195 m). @@ -186,7 +186,7 @@ def test_no_interior_levels(): - Total: 2 height levels, shape (2, 2, 2) """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() # These bounds have no data levels between them result = plugin.process(cube, bottom=610, top=640, verbosity=0) @@ -196,14 +196,14 @@ def test_no_interior_levels(): def test_bottom_greater_than_top_raises_error(): - """Test that LayerExtractionAndInterpolation raises a ValueError when + """Test that LayerTemperatureInterpolation raises a ValueError when the bottom bound is greater than the top bound. This is physically nonsensical and should be caught early with a clear error message rather than producing garbage output silently. """ cube = make_test_cube() - plugin = LayerExtractionAndInterpolation() + plugin = LayerTemperatureInterpolation() - with pytest.raises(ValueError, match="bottom .* must be less than top"): + with pytest.raises(ValueError, match="Bottom .* must be less than top"): plugin.process(cube, bottom=800, top=600, verbosity=0) From aa8ea48c3067f1b00a3a9fb968f835a34b85cdbe Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 08:42:35 +0000 Subject: [PATCH 09/21] added a missing unit test correct a docstring comment --- .../test_calculate_layer_mean_temperature.py | 46 +++++++++++++++++-- .../test_layer_temperature_interpolation.py | 12 ++--- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py index 974cc1d3f1..c5fb0a0e1f 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py +++ b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py @@ -19,9 +19,15 @@ def make_layer_cube(data): - 200 m - 300 m """ - height_points = np.array([100, 200, 300], dtype=np.float32) - y_points = np.array([0, 1], dtype=np.float32) - x_points = np.array([0, 1], dtype=np.float32) + # produces height arrays of the form [100.0, 200.0, 300.0, ...] + height_points = np.array( + np.arange(start=100.0, stop=(data.shape[0] + 1) * 100, step=100), + dtype=np.float32, + ) + # produces y-coordinate arrays of the form [ 0.0, 1.0, 2.0, ...] + y_points = np.array(range(data.shape[1]), dtype=np.float32) + # produces x-coordinate arrays of the form [ 0.0, 1.0, 2.0, ...] + x_points = np.array(range(data.shape[2]), dtype=np.float32) cube = iris.cube.Cube( data, dim_coords_and_dims=[ @@ -93,6 +99,9 @@ def test_layer_mean_temperature_output_shape_and_coordinates_exists(): assert result.coords("forecast_reference_time") assert result.coords("time") + # Check mean values are as expected + assert np.allclose(result.data, 280) + def test_layer_mean_temperature_scalar_coordinate_values_preserved(): """Test that CalculateLayerMeanTemperature preserves scalar coordinate @@ -117,6 +126,37 @@ def test_layer_mean_temperature_scalar_coordinate_values_preserved(): assert result.coord("time").points == cube.coord("time").points +def test_layer_mean_temperature_values(): + """Test that CalculateLayerMeanTemperature returns + the correct mean temperature values where the temperature + field is varying + + Reference data was collected from a prior run. + + """ + + # create a varying temperature field, so the resulting + # layer mean temperature will have varying values too + # the input is deterministically calculated (i.e. no random numbers) + data = np.fromfunction( + lambda layer, y, x: 280.0 + 10.0 * np.sin(x) - 5.0 * np.cos(y) + np.sqrt(layer), + (3, 2, 2), + dtype=np.float32, + ) + + cube = make_layer_cube(data) + plugin = CalculateLayerMeanTemperature() + + actual_result = plugin.process(cube, verbosity=0) + + expected_result = [ + [275.8535546875, 284.26826171875], + [278.15205078125, 286.5667529296875], + ] + + assert np.allclose(actual_result.data, expected_result) + + def test_verbosity_layer_mean_temperature(capsys): """Test that CalculateLayerMeanTemperature prints expected output when verbosity is set to 1. diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 8145b7dc2e..89114506bd 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -21,9 +21,9 @@ def make_test_cube(): """ data = np.array( [ - [[280, 281], [282, 283]], # height = 200 + [[280, 281], [282, 283]], # height = 200 m [[285, 286], [287, 288]], # height = 220 m - [[290, 291], [292, 293]], # height = 250 m + [[290, 291], [292, 293]], # height = 240 m ], dtype=np.float32, ) @@ -151,7 +151,7 @@ def test_verbosity_layer_extraction(capsys): def test_layer_bounds_match_data_level(): """Test that LayerTemperatureInterpolation handles the case where - the layer bounds exactly match an existing data level. + the layer bounds are both very close to an existing data level. Layer bounds: 656 ft (≈200 m) to 787 ft (≈240 m). Both 200 m and 240 m are exact data levels in the test cube. @@ -167,9 +167,9 @@ def test_layer_bounds_match_data_level(): # Check no duplicate height points exist in the output height_points = result.coord("height").points - assert len(height_points) == len( - np.unique(height_points) - ), f"Duplicate height points found: {height_points}" + assert len(height_points) == len(np.unique(height_points)), ( + f"Duplicate height points found: {height_points}" + ) def test_no_interior_levels(): From 0e41de9257b97053bce4addb86583c6c8a753982 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 09:06:34 +0000 Subject: [PATCH 10/21] corrected misalignment in docstring --- improver/temperature/layer_mean_temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index d524057744..62a03013aa 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -80,7 +80,7 @@ def process(self, layer_cube: Cube, verbosity: int = 0) -> Cube: Args: layer_cube: Cube containing temperature at all heights within - the specified layer (including interpolated base and top). + the specified layer (including interpolated base and top). verbosity: Set level of output to print. Returns: From 2999b0b9db943e841cdac4ede2a47b8c3640701d Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 09:53:13 +0000 Subject: [PATCH 11/21] change for ruff --- .../test_layer_temperature_interpolation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 89114506bd..57cfd0d28e 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -167,9 +167,9 @@ def test_layer_bounds_match_data_level(): # Check no duplicate height points exist in the output height_points = result.coord("height").points - assert len(height_points) == len(np.unique(height_points)), ( - f"Duplicate height points found: {height_points}" - ) + assert len(height_points) == len( + np.unique(height_points) + ), f"Duplicate height points found: {height_points}" def test_no_interior_levels(): From 4a882a7777fa4d4010d175257df20952c8d08ff4 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 10:14:15 +0000 Subject: [PATCH 12/21] change for ruff --- .../test_layer_temperature_interpolation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 57cfd0d28e..3184f06d02 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -169,7 +169,7 @@ def test_layer_bounds_match_data_level(): height_points = result.coord("height").points assert len(height_points) == len( np.unique(height_points) - ), f"Duplicate height points found: {height_points}" + ), f"Duplicate height points found: {height_points}" # noqa def test_no_interior_levels(): From b887204f874e54f6ed6c0a71e2452733d5c89fa6 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 10:30:00 +0000 Subject: [PATCH 13/21] change for ruff --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 238c5a250d..5fee7ba2ad 100644 --- a/.mailmap +++ b/.mailmap @@ -14,6 +14,7 @@ Caroline Sandford <35029690+cgsandford@users.noreply.github.com> Carwyn Pelley Chris Sampson <44228125+BelligerG@users.noreply.github.com> +David Johnston Daniel Mentiplay Eleanor Smith <40183561+ellesmith88@users.noreply.github.com> <40183561+ellesmith88@users.noreply.github.com> Fiona Rust From 8c0c4db2347637b87c60c24d3be377a2cbfab808 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 10:35:28 +0000 Subject: [PATCH 14/21] change for ruff - bring back to original to combat different versions of ruff --- .../test_layer_temperature_interpolation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 3184f06d02..808d29396e 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -169,8 +169,7 @@ def test_layer_bounds_match_data_level(): height_points = result.coord("height").points assert len(height_points) == len( np.unique(height_points) - ), f"Duplicate height points found: {height_points}" # noqa - + ), f"Duplicate height points found: {height_points}" def test_no_interior_levels(): """Test that LayerTemperatureInterpolation returns at least the interpolated From 96b63be5e77aff40fad950c0463c46e127b32418 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 10:50:02 +0000 Subject: [PATCH 15/21] add a noqa --- .../test_layer_temperature_interpolation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 808d29396e..8974783580 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -167,9 +167,10 @@ def test_layer_bounds_match_data_level(): # Check no duplicate height points exist in the output height_points = result.coord("height").points - assert len(height_points) == len( - np.unique(height_points) - ), f"Duplicate height points found: {height_points}" + assert len(height_points) == len(np.unique(height_points)), ( + f"Duplicate height points found: {height_points}" + ) # noqa + def test_no_interior_levels(): """Test that LayerTemperatureInterpolation returns at least the interpolated From a0dbfc46596902c33fd16093b13663b6db4e85f0 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 11:13:26 +0000 Subject: [PATCH 16/21] resolving ruff issue --- .../test_layer_temperature_interpolation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 8974783580..89114506bd 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -169,7 +169,7 @@ def test_layer_bounds_match_data_level(): height_points = result.coord("height").points assert len(height_points) == len(np.unique(height_points)), ( f"Duplicate height points found: {height_points}" - ) # noqa + ) def test_no_interior_levels(): From d7e79dd01019999c660a6966182bf1602875bbb5 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 11:16:56 +0000 Subject: [PATCH 17/21] revert to 6 out of 7 passes --- .../test_layer_temperature_interpolation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py index 89114506bd..57cfd0d28e 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py +++ b/improver_tests/temperature/layer_mean_temperature/test_layer_temperature_interpolation.py @@ -167,9 +167,9 @@ def test_layer_bounds_match_data_level(): # Check no duplicate height points exist in the output height_points = result.coord("height").points - assert len(height_points) == len(np.unique(height_points)), ( - f"Duplicate height points found: {height_points}" - ) + assert len(height_points) == len( + np.unique(height_points) + ), f"Duplicate height points found: {height_points}" def test_no_interior_levels(): From 5ada2cdfe9c20c394622359f3c197a0978fb90c4 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Tue, 10 Mar 2026 16:23:23 +0000 Subject: [PATCH 18/21] add david in Contributing --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ee5f92789..80035b8234 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,7 @@ below: - Timothy Hume (Bureau of Meteorology, Australia) - Katharine Hurst (Met Office, UK) - Simon Jackson (Met Office, UK) + - David Johnston (Met Office, UK) - Caroline Jones (Met Office, UK) - Peter Jordan (Met Office, UK) - Anzer Khan (Met Office, UK) From 3550488e4f580ad53438c2f20ae7176f396fd829 Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 17:08:12 +0000 Subject: [PATCH 19/21] test to see if permissions are correct --- .../test_calculate_layer_mean_temperature.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py index c5fb0a0e1f..349ef1c20c 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py +++ b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py @@ -19,14 +19,14 @@ def make_layer_cube(data): - 200 m - 300 m """ - # produces height arrays of the form [100.0, 200.0, 300.0, ...] + # produces height arrays of the form [100.0, 200.0, 300.0, 400.0, ...] height_points = np.array( np.arange(start=100.0, stop=(data.shape[0] + 1) * 100, step=100), dtype=np.float32, ) - # produces y-coordinate arrays of the form [ 0.0, 1.0, 2.0, ...] + # produces y-coordinate arrays of the form [ 0.0, 1.0, 2.0, 3.0, ...] y_points = np.array(range(data.shape[1]), dtype=np.float32) - # produces x-coordinate arrays of the form [ 0.0, 1.0, 2.0, ...] + # produces x-coordinate arrays of the form [ 0.0, 1.0, 2.0, 3.0, ...] x_points = np.array(range(data.shape[2]), dtype=np.float32) cube = iris.cube.Cube( data, From a4a93d340da66ff37c9f93c0604af16765b9beee Mon Sep 17 00:00:00 2001 From: mo-DavidJohnJohnston Date: Tue, 10 Mar 2026 17:13:24 +0000 Subject: [PATCH 20/21] nominal chnage to test permissions --- .../test_calculate_layer_mean_temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py index 349ef1c20c..eb880ad347 100644 --- a/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py +++ b/improver_tests/temperature/layer_mean_temperature/test_calculate_layer_mean_temperature.py @@ -19,7 +19,7 @@ def make_layer_cube(data): - 200 m - 300 m """ - # produces height arrays of the form [100.0, 200.0, 300.0, 400.0, ...] + # produces height arrays of the form [ 100.0, 200.0, 300.0, 400.0, ...] height_points = np.array( np.arange(start=100.0, stop=(data.shape[0] + 1) * 100, step=100), dtype=np.float32, From 35e8a8dd03099469785f6ab5590bd57babf6c759 Mon Sep 17 00:00:00 2001 From: Anzer Khan Date: Wed, 11 Mar 2026 12:24:30 +0000 Subject: [PATCH 21/21] cf_units used instead of hardcorded constant --- .../temperature/layer_mean_temperature.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/improver/temperature/layer_mean_temperature.py b/improver/temperature/layer_mean_temperature.py index 62a03013aa..fbba872e62 100644 --- a/improver/temperature/layer_mean_temperature.py +++ b/improver/temperature/layer_mean_temperature.py @@ -4,12 +4,11 @@ # See LICENSE in the root of the repository for full licensing details. import iris import numpy as np +from cf_units import Unit from iris.cube import Cube from improver import BasePlugin -METRES_TO_FT = 3.28084 - class LayerTemperatureInterpolation(BasePlugin): """ @@ -19,7 +18,6 @@ class LayerTemperatureInterpolation(BasePlugin): (between `bottom` and `top` heights, in feet), and interpolates temperature at the exact base and top of the layer. The output is a cube containing temperature at all interior levels plus the interpolated base and top. - """ def process( @@ -29,7 +27,7 @@ def process( Interpolate temperature values at layer boundaries. Args: - temp_cube: Input temperature cube with a height coordinate. + temp_cube: Input temperature cube with a height coordinate in metres. bottom: Lower boundary of the layer in feet. top: Upper boundary of the layer in feet. verbosity: Verbosity level for printing debug information. @@ -41,33 +39,40 @@ def process( if bottom >= top: raise ValueError(f"Bottom ({bottom} ft) must be less than top ({top} ft).") + # Convert layer bounds from feet to metres using cf_units + bottom_m = Unit("ft").convert(bottom, "m") + top_m = Unit("ft").convert(top, "m") + if verbosity: - print(f"Interpolating temperature at {bottom} ft and {top} ft") + print(f"Interpolating temperature at base: {bottom} ft") + # Extract cube of temperature levels within layer between_layer_temp_cube = temp_cube.extract( - iris.Constraint( - height=lambda point: bottom / METRES_TO_FT < point < top / METRES_TO_FT - ) + iris.Constraint(height=lambda point: bottom_m < point < top_m) ) - # Interpolate temperature at top and base of layer + + # Interpolate temperature at base of layer base_temp = temp_cube.interpolate( - [("height", np.array([bottom / METRES_TO_FT], dtype=np.float32))], + [("height", np.array([bottom_m], dtype=np.float32))], iris.analysis.Linear(), collapse_scalar=False, ) + + if verbosity: + print(f"Interpolating temperature at top: {top} ft") + + # Interpolate temperature at top of layer top_temp = temp_cube.interpolate( - [("height", np.array([top / METRES_TO_FT], dtype=np.float32))], + [("height", np.array([top_m], dtype=np.float32))], iris.analysis.Linear(), collapse_scalar=False, ) + # Merge cubes of temperature at top, bottom and within layer cubes_to_merge = [base_temp, between_layer_temp_cube, top_temp] cubes_to_merge = [cube for cube in cubes_to_merge if cube is not None] layer_levels_temp_cube = iris.cube.CubeList(cubes_to_merge).concatenate_cube() - if verbosity > 1: - print(layer_levels_temp_cube) - return layer_levels_temp_cube