From 08d5aff96d4804ff2b913a3b218772a7cd645484 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Wed, 26 Nov 2025 16:35:23 +0000 Subject: [PATCH 01/10] Adding working, documented implementation of the DuffMoistureCode class --- improver/fire_weather/duff_moisture_code.py | 268 ++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 improver/fire_weather/duff_moisture_code.py diff --git a/improver/fire_weather/duff_moisture_code.py b/improver/fire_weather/duff_moisture_code.py new file mode 100644 index 0000000000..1fee23342f --- /dev/null +++ b/improver/fire_weather/duff_moisture_code.py @@ -0,0 +1,268 @@ +# (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. +from datetime import datetime +from typing import cast + +import numpy as np +from iris.cube import Cube, CubeList + +from improver import BasePlugin + + +class DuffMoistureCode(BasePlugin): + """ + Plugin to calculate the Duff Moisture Code (DMC) following + the Canadian Forest Fire Weather Index System. + + The DMC is a numerical rating of the average moisture content of loosely + compacted organic layers of moderate depth. It indicates fuel consumption + in moderate duff layers and medium-size woody material. + Higher values indicate drier conditions. + + This process is adapted directly from: + Equations and FORTRAN Program for the + Canadian Forest Fire Weather Index System + (C.E. Van Wagner and T.L. Pickett, 1985). + Page 6, Equations 11-16. + + Expected input units: + - Temperature: degrees Celsius + - Precipitation: mm (24-hour accumulation) + - Relative humidity: percentage (0-100) + - Previous DMC: dimensionless + - Month: integer (1-12) for day length factor lookup + """ + + temperature: Cube + precipitation: Cube + relative_humidity: Cube + input_dmc: Cube + month: int + previous_dmc: np.ndarray + + # Day length factors for DMC calculation (L_e values from Table 3) + # Index 0 is unused, indices 1-12 correspond to months January-December + DMC_DAY_LENGTH_FACTORS = [ + 0.0, # Placeholder for index 0 + 6.5, # January + 7.5, # February + 9.0, # March + 12.8, # April + 13.9, # May + 13.9, # June + 12.4, # July + 10.9, # August + 9.4, # September + 8.0, # October + 7.0, # November + 6.0, # December + ] + + def load_input_cubes(self, cubes: tuple[Cube] | CubeList, month: int): + """Loads the required input cubes for the DMC calculation. These + are stored internally as Cube objects. + + Args: + cubes (tuple[Cube] | CubeList): Input cubes containing the necessary data. + month (int): Month of the year (1-12) for day length factor lookup. + + Raises: + ValueError: If the number of cubes does not match the expected + number (4), or if month is out of range. + """ + names_to_extract = [ + "air_temperature", + "lwe_thickness_of_precipitation_amount", + "relative_humidity", + "duff_moisture_code", + ] + if len(cubes) != len(names_to_extract): + raise ValueError( + f"Expected {len(names_to_extract)} cubes, found {len(cubes)}" + ) + + if not (1 <= month <= 12): + raise ValueError(f"Month must be between 1 and 12, got {month}") + + self.month = month + + # Load the cubes into class attributes + ( + self.temperature, + self.precipitation, + self.relative_humidity, + self.input_dmc, + ) = tuple(cast(Cube, CubeList(cubes).extract_cube(n)) for n in names_to_extract) + + # Ensure the cubes are set to the correct units + self.temperature.convert_units("degC") + self.precipitation.convert_units("mm") + self.relative_humidity.convert_units("1") + self.input_dmc.convert_units("1") + + def _perform_rainfall_adjustment(self): + """Updates the previous DMC value based on available precipitation + accumulation data for the previous 24 hours. This is done element-wise + for each grid point. + + From Van Wagner and Pickett (1985), Page 6: Equations 11-15, + and Steps 2a-2e corresponding to rainfall adjustment. + """ + # Only adjust if precipitation > 1.5 mm + precip_mask = self.precipitation.data > 1.5 + + # Step 2a: Calculate effective rainfall via Equation 11 + effective_rain = 0.92 * self.precipitation.data - 1.27 + + # Step 2b: Calculate initial moisture content from previous DMC via Equation 12 + moisture_content_initial = 20.0 + np.exp(5.6348 - self.previous_dmc / 43.43) + + # Step 2c: Calculate the slope variable based on previous DMC via Equations 13a, 13b, 13c + # In the original algorithm, the slope variable is referred to as 'b' + # VECTORIZATION NOTE: This structure matches the original algorithm while being vectorized + slope_variable = np.where( + self.previous_dmc <= 33.0, + # Equation 13a: DMC <= 33 + 100.0 / (0.5 + 0.3 * self.previous_dmc), + np.where( + self.previous_dmc <= 65.0, + # Equation 13b: 33 < DMC <= 65 + 14.0 - 1.3 * np.log(self.previous_dmc), + # Equation 13c: DMC > 65 + 6.2 * np.log(self.previous_dmc) - 17.2, + ), + ) + + # Step 2d: Calculate moisture content after rain via Equation 14 + moisture_content_after_rain = moisture_content_initial + ( + 1000.0 * effective_rain + ) / (48.77 + slope_variable * effective_rain) + + # Step 2e: Calculate DMC after rain via Equation 15 + # This is modified to avoid log of zero or negative values + log_arg = np.clip(moisture_content_after_rain - 20.0, 1e-6, None) + dmc_after_rain = 244.72 - 43.43 * np.log(log_arg) + + # Apply lower bound of 0 + dmc_after_rain = np.maximum(dmc_after_rain, 0.0) + + # Update previous_dmc where precipitation > 1.5 + self.previous_dmc = np.where(precip_mask, dmc_after_rain, self.previous_dmc) + + def _calculate_drying_rate(self) -> np.ndarray: + """Calculates the drying rate for DMC. This is multiplied by 100 for + computational efficiency in the final DMC calculation. The original + algorithm calculates K and then multilies it by 100 in the DMC equation. + + From Van Wagner and Pickett (1985), Page 6: Equation 16, Steps 3 & 4. + + Returns: + np.ndarray: The drying rate value. + """ + # Apply temperature lower bound of -1.1°C + temp_adjusted = np.maximum(self.temperature.data, -1.1) + + # Step 3: Get day length factor for current month + day_length_factor = self.DMC_DAY_LENGTH_FACTORS[self.month] + + # Step 4: Calculate drying rate via Equation 16 + drying_rate = ( + 1.894 + * (temp_adjusted + 1.1) + * (100.0 - self.relative_humidity.data) + * day_length_factor + * 1e-4 + ) + + return drying_rate + + def _calculate_dmc(self, drying_rate: np.ndarray) -> np.ndarray: + """Calculates the Duff Moisture Code from previous DMC and drying rate. + Note that the drying rate is expected to be pre-multiplied by 100 + for computational efficiency. This mathematically matches the original + algorithm, but is more efficient to implement this way. + + From Van Wagner and Pickett (1985), Page 6: Equation 16. + + Args: + drying_rate (np.ndarray): The drying rate (RK). + + Returns: + np.ndarray: The calculated DMC value. + """ + # Equation 16: Calculate DMC + dmc = self.previous_dmc + drying_rate + + # Apply lower bound of 0 + dmc = np.maximum(dmc, 0.0) + + return dmc + + def _make_dmc_cube(self, dmc_data: np.ndarray) -> Cube: + """Converts a DMC data array into an iris.cube.Cube object + with relevant metadata copied from the input DMC cube, and updated + time coordinates from the precipitation cube. Time bounds are + removed from the output. + + Args: + dmc_data (np.ndarray): The DMC data + + Returns: + Cube: An iris.cube.Cube containing the DMC data with updated + metadata and coordinates. + """ + dmc_cube = self.input_dmc.copy(data=dmc_data.astype(np.float32)) + + # Update forecast_reference_time from precipitation cube + frt_coord = self.precipitation.coord("forecast_reference_time").copy() + dmc_cube.replace_coord(frt_coord) + + # Update time coordinate from precipitation cube (without bounds) + time_coord = self.precipitation.coord("time").copy() + time_coord.bounds = None + dmc_cube.replace_coord(time_coord) + + return dmc_cube + + def process( + self, + cubes: tuple[Cube] | CubeList, + month: int | None = None, + ) -> Cube: + """Calculate the Duff Moisture Code (DMC). + + Args: + cubes (Cube | CubeList): Input cubes containing: + air_temperature: Temperature in degrees Celsius + lwe_thickness_of_precipitation_amount: 24-hour precipitation in mm + relative_humidity: Relative humidity as a percentage (0-100) + duff_moisture_code: Previous day's DMC value + month (int | None): Month of the year (1-12) for day length factor lookup. + If None, defaults to the current month. + + Returns: + Cube: The calculated DMC values for the current day. + """ + if month is None: + month = datetime.now().month + + self.load_input_cubes(cubes, month) + + # Step 1: Set today's DMC value to the previous day's DMC value + self.previous_dmc = self.input_dmc.data.copy() + + # Step 2: Perform rainfall adjustment, if precipitation > 1.5 mm + self._perform_rainfall_adjustment() + + # Steps 3 & 4: Calculate drying rate + drying_rate = self._calculate_drying_rate() + + # Step 5: Calculate DMC from adjusted previous DMC and drying rate + output_dmc = self._calculate_dmc(drying_rate) + + # Convert DMC data to a cube and return + dmc_cube = self._make_dmc_cube(output_dmc) + + return dmc_cube From 655379e7799e74b4ca26601e2671094e2d7c0395 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Wed, 26 Nov 2025 17:05:46 +0000 Subject: [PATCH 02/10] Adding initial versions of tests for DMC --- .../fire_weather/test_duff_moisture_code.py | 705 ++++++++++++++++++ 1 file changed, 705 insertions(+) create mode 100644 improver_tests/fire_weather/test_duff_moisture_code.py diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py new file mode 100644 index 0000000000..56a845071d --- /dev/null +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -0,0 +1,705 @@ +# (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. + +from datetime import datetime + +import numpy as np +import pytest +from cf_units import Unit +from iris.coords import AuxCoord +from iris.cube import Cube, CubeList + +from improver.fire_weather.duff_moisture_code import DuffMoistureCode + + +def make_cube( + data: np.ndarray, + name: str, + units: str, + add_time_coord: bool = False, +) -> Cube: + """Create a dummy Iris Cube with specified data, name, units, and optional + time coordinates. + + All cubes include a forecast_reference_time coordinate by default. + + Args: + data (np.ndarray): The data array for the cube. + name (str): The long name for the cube. + units (str): The units for the cube. + add_time_coord (bool): Whether to add a time coordinate with bounds. + + Returns: + Cube: The constructed Iris Cube with the given properties. + """ + arr = np.array(data, dtype=np.float64) + cube = Cube(arr, long_name=name) + cube.units = units + + # Always add forecast_reference_time + time_origin = "hours since 1970-01-01 00:00:00" + calendar = "gregorian" + + # Default forecast reference time: 2025-10-20 00:00:00 + frt = datetime(2025, 10, 20, 0, 0) + frt_coord = AuxCoord( + np.array([frt.timestamp() / 3600], dtype=np.float64), + standard_name="forecast_reference_time", + units=Unit(time_origin, calendar=calendar), + ) + cube.add_aux_coord(frt_coord) + + # Optionally add time coordinate with bounds + if add_time_coord: + # Default valid time: 2025-10-20 12:00:00 with 12-hour bounds + valid_time = datetime(2025, 10, 20, 12, 0) + time_bounds = np.array( + [ + [ + (valid_time.timestamp() - 43200) / 3600, # 12 hours earlier + valid_time.timestamp() / 3600, + ] + ], + dtype=np.float64, + ) + time_coord = AuxCoord( + np.array([valid_time.timestamp() / 3600], dtype=np.float64), + standard_name="time", + bounds=time_bounds, + units=Unit(time_origin, calendar=calendar), + ) + cube.add_aux_coord(time_coord) + + return cube + + +def input_cubes( + temp_val: float = 20.0, + precip_val: float = 1.0, + rh_val: float = 50.0, + dmc_val: float = 6.0, + shape: tuple[int, int] = (5, 5), + temp_units: str = "degC", + precip_units: str = "mm", + rh_units: str = "1", + dmc_units: str = "1", +) -> list[Cube]: + """Create a list of dummy input cubes for DMC tests, with configurable units. + + All cubes have forecast_reference_time. Precipitation and DMC cubes also have + time coordinates with bounds. + + Args: + temp_val (float): Temperature value for all grid points. + precip_val (float): Precipitation value for all grid points. + rh_val (float): Relative humidity value for all grid points. + dmc_val (float): DMC value for all grid points. + shape (tuple[int, int]): Shape of the grid for each cube. + temp_units (str): Units for temperature cube. + precip_units (str): Units for precipitation cube. + rh_units (str): Units for relative humidity cube. + dmc_units (str): Units for DMC cube. + + Returns: + list[Cube]: List of Iris Cubes for temperature, precipitation, relative humidity, and DMC. + """ + temp = make_cube(np.full(shape, temp_val), "air_temperature", temp_units) + # Precipitation cube needs time coordinates for _make_dmc_cube + precip = make_cube( + np.full(shape, precip_val), + "lwe_thickness_of_precipitation_amount", + precip_units, + add_time_coord=True, + ) + rh = make_cube(np.full(shape, rh_val), "relative_humidity", rh_units) + # DMC cube needs time coordinates for _make_dmc_cube to copy metadata + dmc = make_cube( + np.full(shape, dmc_val), + "duff_moisture_code", + dmc_units, + add_time_coord=True, + ) + return [temp, precip, rh, dmc] + + +@pytest.mark.parametrize( + "temp_val, precip_val, rh_val, dmc_val", + [ + # Case 0: Typical mid-range values + (20.0, 1.0, 50.0, 6.0), + # Case 1: All zeros (edge case) + (0.0, 0.0, 0.0, 0.0), + # Case 2: All maximums/extremes + (100.0, 100.0, 100.0, 100.0), + # Case 3: Low temperature, low precip, low relative humidity, low DMC + (-10.0, 0.5, 10.0, 2.0), + # Case 4: High temp, high precip, high relative humidity, high DMC + (30.0, 10.0, 90.0, 120.0), + ], +) +def test_load_input_cubes( + temp_val: float, + precip_val: float, + rh_val: float, + dmc_val: float, +) -> None: + """Test DuffMoistureCode.load_input_cubes with various input conditions. + + Args: + temp_val (float): Temperature value for all grid points. + precip_val (float): Precipitation value for all grid points. + rh_val (float): Relative humidity value for all grid points. + dmc_val (float): DMC value for all grid points. + + Raises: + AssertionError: If the loaded cubes do not match expected shapes and types. + """ + cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + + attributes = [ + plugin.temperature, + plugin.precipitation, + plugin.relative_humidity, + plugin.input_dmc, + ] + input_values = [temp_val, precip_val, rh_val, dmc_val] + + for attr, val in zip(attributes, input_values): + assert isinstance(attr, Cube) + assert attr.data.shape == (5, 5) + assert np.allclose(attr.data, val) + + # Check that month is set correctly + assert plugin.month == 7 + + +@pytest.mark.parametrize( + "param, input_val, input_unit, expected_val", + [ + # Case 0: Temperature: Kelvin -> degC + ("temperature", 293.15, "K", 20.0), + # Case 1: Precipitation: m -> mm + ("precipitation", 0.001, "m", 1.0), + # Case 2: Relative humidity: percentage -> fraction + ("relative_humidity", 10.0, "%", 0.1), + # Case 3: Input DMC: no conversion needed (dimensionless) + ("input_dmc", 6.0, "1", 6.0), + ], +) +def test_load_input_cubes_unit_conversion( + param: str, + input_val: float, + input_unit: str, + expected_val: float, +) -> None: + """ + Test that load_input_cubes correctly converts a single alternative unit for each input cube. + + Args: + param (str): Name of the parameter to test (e.g., 'temperature', 'precipitation', etc.). + input_val (float): Value to use for the tested parameter. + input_unit (str): Unit to use for the tested parameter. + expected_val (float): Expected value after conversion. + + Raises: + AssertionError: If the converted value does not match the expected value. + """ + + # Override the value and unit for the parameter being tested + if param == "temperature": + cubes = input_cubes(temp_val=input_val, temp_units=input_unit) + elif param == "precipitation": + cubes = input_cubes(precip_val=input_val, precip_units=input_unit) + elif param == "relative_humidity": + cubes = input_cubes(rh_val=input_val, rh_units=input_unit) + elif param == "input_dmc": + cubes = input_cubes(dmc_val=input_val, dmc_units=input_unit) + + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + # Check only the parameter being tested + result = getattr(plugin, param) + assert np.allclose(result.data, expected_val) + + +@pytest.mark.parametrize( + "num_cubes, should_raise, expected_message", + [ + # Case 0: Correct number of cubes (4) + (4, False, None), + # Case 1: Too few cubes (3 instead of 4) + (3, True, "Expected 4 cubes, found 3"), + # Case 2: No cubes (0 instead of 4) + (0, True, "Expected 4 cubes, found 0"), + # Case 3: Too many cubes (5 instead of 4) + (5, True, "Expected 4 cubes, found 5"), + ], +) +def test_load_input_cubes_wrong_number_raises_error( + num_cubes: int, + should_raise: bool, + expected_message: str, +) -> None: + """Test that load_input_cubes raises ValueError when given wrong number of cubes. + + Args: + num_cubes (int): Number of cubes to provide to load_input_cubes. + should_raise (bool): Whether a ValueError should be raised. + expected_message (str): Expected error message (or None if no error expected). + + Raises: + AssertionError: If ValueError behavior does not match expectations. + """ + # Create a list with the specified number of cubes + cubes = input_cubes() + if num_cubes < len(cubes): + cubes = cubes[:num_cubes] + elif num_cubes > len(cubes): + # Add extra dummy cube(s) to test "too many cubes" case + for _ in range(num_cubes - len(cubes)): + cubes.append(make_cube(np.full((5, 5), 0.0), "extra_cube", "1")) + + plugin = DuffMoistureCode() + + if should_raise: + with pytest.raises(ValueError, match=expected_message): + plugin.load_input_cubes(CubeList(cubes), month=7) + else: + # Should not raise - verify it loads successfully + plugin.load_input_cubes(CubeList(cubes), month=7) + assert isinstance(plugin.temperature, Cube) + + +@pytest.mark.parametrize( + "month, should_raise, expected_message", + [ + # Valid months + (1, False, None), + (6, False, None), + (12, False, None), + # Invalid months + (0, True, "Month must be between 1 and 12, got 0"), + (13, True, "Month must be between 1 and 12, got 13"), + (-1, True, "Month must be between 1 and 12, got -1"), + ], +) +def test_load_input_cubes_month_validation( + month: int, + should_raise: bool, + expected_message: str, +) -> None: + """Test that load_input_cubes validates month parameter correctly. + + Args: + month (int): Month value to test. + should_raise (bool): Whether a ValueError should be raised. + expected_message (str): Expected error message (or None if no error expected). + + Raises: + AssertionError: If month validation does not match expectations. + """ + cubes = input_cubes() + plugin = DuffMoistureCode() + + if should_raise: + with pytest.raises(ValueError, match=expected_message): + plugin.load_input_cubes(CubeList(cubes), month=month) + else: + # Should not raise - verify it loads successfully + plugin.load_input_cubes(CubeList(cubes), month=month) + assert plugin.month == month + + +@pytest.mark.parametrize( + "precip_val, prev_dmc, expected_dmc", + [ + # Case 0: No rain, DMC unchanged + (0.0, 10.0, 10.0), + # Case 1: Rain below threshold (1.5 mm), DMC unchanged + (1.0, 10.0, 10.0), + # Case 2: Rain on threshold limit, DMC unchanged + (1.5, 10.0, 10.0), + # Case 3: Rain above threshold, DMC decreases + (2.0, 10.0, 8.32), + # Case 4: Heavy rain with low previous DMC + (10.0, 10.0, 4.71), + # Case 5: Heavy rain with high previous DMC + (10.0, 50.0, 25.70), + # Case 6: Moderate rain with moderate DMC + (5.0, 30.0, 19.17), + # Case 7: Rain with DMC <= 33 (tests Equation 13a) + (5.0, 20.0, 12.50), + # Case 8: Rain with 33 < DMC <= 65 (tests Equation 13b) + (5.0, 45.0, 29.63), + # Case 9: Rain with DMC > 65 (tests Equation 13c) + (5.0, 80.0, 51.77), + # Case 10: Rain with very low DMC near log domain edge + (10.0, 2.0, 0.36), + ], +) +def test__perform_rainfall_adjustment( + precip_val: float, + prev_dmc: float, + expected_dmc: float, +) -> None: + """Test _perform_rainfall_adjustment for various rainfall and DMC scenarios. + + Tests include: no adjustment (precip <= 1.5), and various rainfall amounts + with different previous DMC values. + + Args: + precip_val (float): Precipitation value for all grid points. + prev_dmc (float): Previous DMC value for all grid points. + expected_dmc (float): Expected DMC after adjustment. + + Raises: + AssertionError: If the DMC adjustment does not match expectations. + """ + cubes = input_cubes(precip_val=precip_val, dmc_val=prev_dmc) + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + # previous_dmc is set in load_input_cubes, but we overwrite for explicit test control + plugin.previous_dmc = np.full(plugin.precipitation.data.shape, prev_dmc) + plugin._perform_rainfall_adjustment() + adjusted_dmc = plugin.previous_dmc + # Check that all points are modified by the correct amount + assert np.allclose(adjusted_dmc, expected_dmc, atol=0.05) + + +def test__perform_rainfall_adjustment_spatially_varying() -> None: + """Test rainfall adjustment with spatially varying data (vectorization check).""" + shape = (4, 4) + # Produce a checkerboard precipitation pattern (5mm and 0mm alternating) + precip_data = np.zeros(shape) + precip_data[::2, ::2] = precip_data[1::2, 1::2] = 5.0 + + dmc_data = np.array( + [ + [10.0, 25.0, 40.0, 70.0], + [15.0, 30.0, 50.0, 80.0], + [20.0, 35.0, 60.0, 90.0], + [12.0, 28.0, 45.0, 75.0], + ] + ) + + cubes = [ + make_cube(np.full(shape, 20.0), "air_temperature", "degC"), + make_cube( + precip_data, + "lwe_thickness_of_precipitation_amount", + "mm", + add_time_coord=True, + ), + make_cube(np.full(shape, 50.0), "relative_humidity", "1"), + make_cube(dmc_data, "duff_moisture_code", "1", add_time_coord=True), + ] + + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + plugin.previous_dmc = dmc_data.copy() + plugin._perform_rainfall_adjustment() + + # No-rain cells unchanged, rain cells decreased + assert np.allclose(plugin.previous_dmc[0, 1], 25.0) and np.allclose( + plugin.previous_dmc[0, 3], dmc_data[0, 3] + ) + assert np.all(plugin.previous_dmc[::2, ::2] <= dmc_data[::2, ::2]) + assert np.all(plugin.previous_dmc[1::2, 1::2] <= dmc_data[1::2, 1::2]) + + +@pytest.mark.parametrize( + "temp_val, rh_val, month, expected_rate", + [ + # Case 0: Typical mid-range values for July + (20.0, 50.0, 7, 2.478), + # Case 1: Cold, dry January + (0.0, 0.0, 1, 0.135), + # Case 2: Hot, humid June + (30.0, 90.0, 6, 0.819), + # Case 3: Very cold December + (-10.0, 10.0, 12, 0.000), + # Case 4: Spring conditions (April) + (15.0, 60.0, 4, 1.561), + # Case 5: Temperature at lower bound (-1.1°C) + (-1.1, 50.0, 7, 0.000), + # Case 6: Temperature just below lower bound (should be clipped to -1.1°C) + (-5.0, 50.0, 7, 0.000), + ], +) +def test__calculate_drying_rate( + temp_val: float, + rh_val: float, + month: int, + expected_rate: float, +) -> None: + """ + Test _calculate_drying_rate for various temperature, relative humidity, and month combinations. + + Args: + temp_val (float): Temperature value for all grid points. + rh_val (float): Relative humidity value for all grid points. + month (int): Month of the year (1-12). + expected_rate (float): Expected drying rate value. + + Raises: + AssertionError: If the drying rate calculation does not match expectations. + """ + cubes = input_cubes(temp_val=temp_val, rh_val=rh_val) + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=month) + rate = plugin._calculate_drying_rate() + # Check output type and shape + assert isinstance(rate, np.ndarray) + assert rate.shape == cubes[0].data.shape + # Check that drying rate matches expected value + assert np.allclose(rate, expected_rate, atol=0.01) + + +def test__calculate_drying_rate_spatially_varying() -> None: + """Test drying rate with spatially varying temperature/relative humidity (vectorization check).""" + temp_data = np.array([[-5.0, 0.0, 10.0], [15.0, 20.0, 25.0], [30.0, 35.0, 40.0]]) + rh_data = np.array([[20.0, 30.0, 40.0], [50.0, 60.0, 70.0], [80.0, 90.0, 95.0]]) + + cubes = [ + make_cube(temp_data, "air_temperature", "degC"), + make_cube( + np.zeros((3, 3)), + "lwe_thickness_of_precipitation_amount", + "mm", + add_time_coord=True, + ), + make_cube(rh_data, "relative_humidity", "1"), + make_cube( + np.full((3, 3), 10.0), "duff_moisture_code", "1", add_time_coord=True + ), + ] + + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + rate = plugin._calculate_drying_rate() + + # relative humidity dominates: low relative humidity beats high temp + assert rate[2, 0] > rate[2, 1] and rate[2, 0] > rate[2, 2] + # Temperature effect: warmer produces higher rate (same relative humidity column) + assert rate[1, 2] > rate[0, 2] + # Below temp bound gives zero, mid conditions beat extreme humid + assert rate[0, 0] == 0.0 and rate[1, 1] > rate[2, 2] and np.all(rate >= 0.0) + + +@pytest.mark.parametrize( + "prev_dmc, drying_rate, expected_dmc", + [ + # Case 0: Typical values + (10.0, 0.3, 10.3), + # Case 1: Zero previous DMC + (0.0, 0.1, 0.1), + # Case 2: No drying + (50.0, 0.0, 50.0), + # Case 3: High values + (100.0, 1.0, 101.0), + # Case 4: Would be negative without lower bound + (0.0, -0.5, 0.0), + ], +) +def test__calculate_dmc( + prev_dmc: float, + drying_rate: float, + expected_dmc: float, +) -> None: + """Test _calculate_dmc for various previous DMC and drying rate values. + + Args: + prev_dmc (float): Previous DMC value. + drying_rate (float): Drying rate value. + expected_dmc (float): Expected DMC output value. + + Raises: + AssertionError: If the DMC calculation does not match expectations. + """ + plugin = DuffMoistureCode() + plugin.previous_dmc = np.array([prev_dmc]) + dmc = plugin._calculate_dmc(np.array([drying_rate])) + # Check output type and shape + assert isinstance(dmc, np.ndarray) + assert dmc.shape == (1,) + # Check that DMC matches expected output + assert np.allclose(dmc, expected_dmc, atol=0.01) + + +@pytest.mark.parametrize( + "dmc_value, shape", + [ + # Case 0: Typical mid-range DMC value with standard grid + (10.0, (5, 5)), + # Case 1: Low DMC value with different grid size + (0.0, (3, 4)), + # Case 2: High DMC value with larger grid + (50.0, (10, 10)), + # Case 3: Very high DMC (edge case) with small grid + (200.0, (2, 2)), + # Case 4: Standard DMC value + (6.0, (5, 5)), + ], +) +def test__make_dmc_cube( + dmc_value: float, + shape: tuple[int, int], +) -> None: + """ + Test _make_dmc_cube to ensure it creates an Iris Cube with correct properties + for various DMC values and grid shapes. + + Args: + dmc_value (float): DMC data value to use for all grid points. + shape (tuple[int, int]): Shape of the grid. + + Raises: + AssertionError: If the created cube does not have expected properties. + """ + # Create input cubes with specified shape + cubes = input_cubes(shape=shape) + + # Initialize the plugin and load cubes + plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) + + # Create test DMC data + dmc_data = np.full(shape, dmc_value, dtype=np.float64) + + # Call the method under test + result_cube = plugin._make_dmc_cube(dmc_data) + + # Check that result is an Iris Cube with correct type and shape + assert isinstance(result_cube, Cube) + assert result_cube.data.dtype == np.float32 + assert result_cube.data.shape == shape + assert np.allclose(result_cube.data, dmc_value, atol=0.001) + + # Check that the cube has the correct name and units + assert result_cube.long_name == "duff_moisture_code" + assert result_cube.units == "1" + + # Check that forecast_reference_time is copied from precipitation cube + result_frt = result_cube.coord("forecast_reference_time") + expected_frt = plugin.precipitation.coord("forecast_reference_time") + assert result_frt.points[0] == expected_frt.points[0] + assert result_frt.units == expected_frt.units + + # Check that time coordinate is copied from precipitation cube + result_time = result_cube.coord("time") + expected_time = plugin.precipitation.coord("time") + assert result_time.points[0] == expected_time.points[0] + assert result_time.units == expected_time.units + + # Check that time coordinate has no bounds (removed by _make_dmc_cube) + assert result_time.bounds is None + + +def test_process_default_month() -> None: + """Test that process method works with default month parameter.""" + cubes = input_cubes() + plugin = DuffMoistureCode() + + # Should not raise - uses current month by default + result = plugin.process(CubeList(cubes)) + + # Check that the month is set to current month + from datetime import datetime + + current_month = datetime.utcnow().month + assert plugin.month == current_month + + # Check that result is valid + assert hasattr(result, "data") + assert result.data.shape == cubes[0].data.shape + assert isinstance(result.data[0][0], (float, np.floating)) + + +def test_process_spatially_varying() -> None: + """Integration test with spatially varying data (vectorization check).""" + temp_data = np.array([[10.0, 15.0, 20.0], [15.0, 20.0, 25.0], [20.0, 25.0, 30.0]]) + precip_data = np.array([[0.0, 2.0, 5.0], [0.0, 0.0, 10.0], [0.0, 0.0, 0.0]]) + rh_data = np.array([[40.0, 50.0, 60.0], [50.0, 60.0, 70.0], [60.0, 70.0, 80.0]]) + dmc_data = np.array([[5.0, 15.0, 30.0], [10.0, 50.0, 70.0], [20.0, 40.0, 90.0]]) + + cubes = [ + make_cube(temp_data, "air_temperature", "degC"), + make_cube( + precip_data, + "lwe_thickness_of_precipitation_amount", + "mm", + add_time_coord=True, + ), + make_cube(rh_data, "relative_humidity", "1"), + make_cube(dmc_data, "duff_moisture_code", "1", add_time_coord=True), + ] + + result = DuffMoistureCode().process(CubeList(cubes), month=7) + + # Verify shape, type, and all non-negative + assert ( + result.data.shape == (3, 3) + and result.data.dtype == np.float32 + and np.all(result.data >= 0.0) + ) + # Hot/dry/no-rain increases DMC; heavy rain decreases; unique values (no broadcast errors) + assert ( + result.data[2, 0] > dmc_data[2, 0] and result.data[0, 2] <= dmc_data[0, 2] + 2.0 + ) + assert len(np.unique(result.data)) > 1 + + +@pytest.mark.parametrize( + "temp_val, precip_val, rh_val, dmc_val, month, expected_output", + [ + # Case 0: Typical mid-range values + (20.0, 1.0, 50.0, 6.0, 7, 8.48), + # Case 1: All zeros (edge case) + (0.0, 0.0, 0.0, 0.0, 1, 0.14), + # Case 2: High temp, no precip, low relative humidity, high DMC (produces high output DMC) + (35.0, 0.0, 15.0, 90.0, 6, 98.08), + # Case 3: Low temp, high precip, high relative humidity (produces low output DMC) + (10.0, 15.0, 95.0, 85.0, 8, 40.77), + # Case 4: Precipitation just below threshold (should not adjust) + (20.0, 0.4, 50.0, 85.0, 5, 87.78), + ], +) +def test_process( + temp_val: float, + precip_val: float, + rh_val: float, + dmc_val: float, + month: int, + expected_output: float, +) -> None: + """Integration test for the complete DMC calculation process. + + Tests end-to-end functionality with various environmental conditions and + verifies the final DMC output matches expected values. + + Args: + temp_val (float): Temperature value for all grid points. + precip_val (float): Precipitation value for all grid points. + rh_val (float): Relative humidity value for all grid points. + dmc_val (float): DMC value for all grid points. + month (int): Month of the year (1-12). + expected_output (float): Expected DMC output value for all grid points. + + Raises: + AssertionError: If the process output does not match expectations. + """ + cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) + plugin = DuffMoistureCode() + result = plugin.process(CubeList(cubes), month=month) + + # Check output type and shape + assert hasattr(result, "data") + assert result.data.shape == cubes[0].data.shape + + # Check that DMC matches expected output within tolerance + data = np.array(result.data) + assert np.allclose(data, expected_output, atol=0.05) From 4bea2c1aec3c1939d6315c28f4fad4b83738ead1 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Thu, 27 Nov 2025 15:15:00 +0000 Subject: [PATCH 03/10] Fixing warnings from log(0)s --- improver/fire_weather/duff_moisture_code.py | 12 +- .../fire_weather/test_duff_moisture_code.py | 108 +++++++++--------- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/improver/fire_weather/duff_moisture_code.py b/improver/fire_weather/duff_moisture_code.py index 1fee23342f..5a2bffad9d 100644 --- a/improver/fire_weather/duff_moisture_code.py +++ b/improver/fire_weather/duff_moisture_code.py @@ -122,6 +122,8 @@ def _perform_rainfall_adjustment(self): # Step 2c: Calculate the slope variable based on previous DMC via Equations 13a, 13b, 13c # In the original algorithm, the slope variable is referred to as 'b' # VECTORIZATION NOTE: This structure matches the original algorithm while being vectorized + # Clip previous_dmc to avoid log(0) warnings in equations 13b and 13c + dmc_clipped = np.maximum(self.previous_dmc, 1e-10) slope_variable = np.where( self.previous_dmc <= 33.0, # Equation 13a: DMC <= 33 @@ -129,20 +131,22 @@ def _perform_rainfall_adjustment(self): np.where( self.previous_dmc <= 65.0, # Equation 13b: 33 < DMC <= 65 - 14.0 - 1.3 * np.log(self.previous_dmc), + 14.0 - 1.3 * np.log(dmc_clipped), # Equation 13c: DMC > 65 - 6.2 * np.log(self.previous_dmc) - 17.2, + 6.2 * np.log(dmc_clipped) - 17.2, ), ) # Step 2d: Calculate moisture content after rain via Equation 14 + # Protect against division by zero (though mathematically unlikely) + denominator = 48.77 + slope_variable * effective_rain moisture_content_after_rain = moisture_content_initial + ( 1000.0 * effective_rain - ) / (48.77 + slope_variable * effective_rain) + ) / np.maximum(denominator, 1e-10) # Step 2e: Calculate DMC after rain via Equation 15 # This is modified to avoid log of zero or negative values - log_arg = np.clip(moisture_content_after_rain - 20.0, 1e-6, None) + log_arg = np.clip(moisture_content_after_rain - 20.0, 1e-10, None) dmc_after_rain = 244.72 - 43.43 * np.log(log_arg) # Apply lower bound of 0 diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index 56a845071d..258ed8f059 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -362,7 +362,7 @@ def test__perform_rainfall_adjustment( cubes = input_cubes(precip_val=precip_val, dmc_val=prev_dmc) plugin = DuffMoistureCode() plugin.load_input_cubes(CubeList(cubes), month=7) - # previous_dmc is set in load_input_cubes, but we overwrite for explicit test control + # previous_dmc is set in load_input_cubes, overwriting for explicit test control plugin.previous_dmc = np.full(plugin.precipitation.data.shape, prev_dmc) plugin._perform_rainfall_adjustment() adjusted_dmc = plugin.previous_dmc @@ -599,6 +599,58 @@ def test__make_dmc_cube( assert result_time.bounds is None +@pytest.mark.parametrize( + "temp_val, precip_val, rh_val, dmc_val, month, expected_output", + [ + # Case 0: Typical mid-range values + (20.0, 1.0, 50.0, 6.0, 7, 8.48), + # Case 1: All zeros (edge case) + (0.0, 0.0, 0.0, 0.0, 1, 0.14), + # Case 2: High temp, no precip, low relative humidity, high DMC (produces high output DMC) + (35.0, 0.0, 15.0, 90.0, 6, 98.08), + # Case 3: Low temp, high precip, high relative humidity (produces low output DMC) + (10.0, 15.0, 95.0, 85.0, 8, 40.77), + # Case 4: Precipitation just below threshold (should not adjust) + (20.0, 0.4, 50.0, 85.0, 5, 87.78), + ], +) +def test_process( + temp_val: float, + precip_val: float, + rh_val: float, + dmc_val: float, + month: int, + expected_output: float, +) -> None: + """Integration test for the complete DMC calculation process. + + Tests end-to-end functionality with various environmental conditions and + verifies the final DMC output matches expected values. + + Args: + temp_val (float): Temperature value for all grid points. + precip_val (float): Precipitation value for all grid points. + rh_val (float): Relative humidity value for all grid points. + dmc_val (float): DMC value for all grid points. + month (int): Month of the year (1-12). + expected_output (float): Expected DMC output value for all grid points. + + Raises: + AssertionError: If the process output does not match expectations. + """ + cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) + plugin = DuffMoistureCode() + result = plugin.process(CubeList(cubes), month=month) + + # Check output type and shape + assert hasattr(result, "data") + assert result.data.shape == cubes[0].data.shape + + # Check that DMC matches expected output within tolerance + data = np.array(result.data) + assert np.allclose(data, expected_output, atol=0.05) + + def test_process_default_month() -> None: """Test that process method works with default month parameter.""" cubes = input_cubes() @@ -610,7 +662,7 @@ def test_process_default_month() -> None: # Check that the month is set to current month from datetime import datetime - current_month = datetime.utcnow().month + current_month = datetime.now().month assert plugin.month == current_month # Check that result is valid @@ -651,55 +703,3 @@ def test_process_spatially_varying() -> None: result.data[2, 0] > dmc_data[2, 0] and result.data[0, 2] <= dmc_data[0, 2] + 2.0 ) assert len(np.unique(result.data)) > 1 - - -@pytest.mark.parametrize( - "temp_val, precip_val, rh_val, dmc_val, month, expected_output", - [ - # Case 0: Typical mid-range values - (20.0, 1.0, 50.0, 6.0, 7, 8.48), - # Case 1: All zeros (edge case) - (0.0, 0.0, 0.0, 0.0, 1, 0.14), - # Case 2: High temp, no precip, low relative humidity, high DMC (produces high output DMC) - (35.0, 0.0, 15.0, 90.0, 6, 98.08), - # Case 3: Low temp, high precip, high relative humidity (produces low output DMC) - (10.0, 15.0, 95.0, 85.0, 8, 40.77), - # Case 4: Precipitation just below threshold (should not adjust) - (20.0, 0.4, 50.0, 85.0, 5, 87.78), - ], -) -def test_process( - temp_val: float, - precip_val: float, - rh_val: float, - dmc_val: float, - month: int, - expected_output: float, -) -> None: - """Integration test for the complete DMC calculation process. - - Tests end-to-end functionality with various environmental conditions and - verifies the final DMC output matches expected values. - - Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - dmc_val (float): DMC value for all grid points. - month (int): Month of the year (1-12). - expected_output (float): Expected DMC output value for all grid points. - - Raises: - AssertionError: If the process output does not match expectations. - """ - cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) - plugin = DuffMoistureCode() - result = plugin.process(CubeList(cubes), month=month) - - # Check output type and shape - assert hasattr(result, "data") - assert result.data.shape == cubes[0].data.shape - - # Check that DMC matches expected output within tolerance - data = np.array(result.data) - assert np.allclose(data, expected_output, atol=0.05) From 0ad206bc5b38afae5801f97b152ad174acb49bd2 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Fri, 28 Nov 2025 11:41:03 +0000 Subject: [PATCH 04/10] Adding test for day length factors --- .../fire_weather/test_duff_moisture_code.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index 258ed8f059..334d57c2ee 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -703,3 +703,24 @@ def test_process_spatially_varying() -> None: result.data[2, 0] > dmc_data[2, 0] and result.data[0, 2] <= dmc_data[0, 2] + 2.0 ) assert len(np.unique(result.data)) > 1 + + +def test_day_length_factors_table() -> None: + """Test that DMC_DAY_LENGTH_FACTORS match the expected values from Van Wagner and Pickett Table 1.""" + expected_factors = [ + 0.0, # Placeholder for index 0 + 6.5, # January + 7.5, # February + 9.0, # March + 12.8, # April + 13.9, # May + 13.9, # June + 12.4, # July + 10.9, # August + 9.4, # September + 8.0, # October + 7.0, # November + 6.0, # December + ] + + assert DuffMoistureCode.DMC_DAY_LENGTH_FACTORS == expected_factors From 0a53f96d7b778a13426a4799d19e68526fe9042c Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Fri, 28 Nov 2025 15:23:04 +0000 Subject: [PATCH 05/10] Adding DMC to API --- improver/api/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/improver/api/__init__.py b/improver/api/__init__.py index 23e704519e..50f608bb79 100644 --- a/improver/api/__init__.py +++ b/improver/api/__init__.py @@ -51,6 +51,7 @@ "DayNightMask": "improver.utilities.solar", "DifferenceBetweenAdjacentGridSquares": "improver.utilities.spatial", "DroughtCode": "improver.fire_weather.drought_code", + "DuffMoistureCode": "improver.fire_weather.duff_moisture_code", "EnforceConsistentForecasts": "improver.utilities.forecast_reference_enforcement", "EnsembleReordering": "improver.ensemble_copula_coupling.ensemble_copula_coupling", "EstimateCoefficientsForEnsembleCalibration": "improver.calibration.emos_calibration", From 69f6823e1e295390f2bd5e4d5577a6b0f8dec518 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Wed, 3 Dec 2025 21:02:15 +0000 Subject: [PATCH 06/10] Changes from ABC refactor --- improver/fire_weather/__init__.py | 3 + improver/fire_weather/duff_moisture_code.py | 150 ++----- .../fire_weather/test_duff_moisture_code.py | 418 ++---------------- 3 files changed, 80 insertions(+), 491 deletions(-) diff --git a/improver/fire_weather/__init__.py b/improver/fire_weather/__init__.py index 554daae2e1..1c6afef94e 100644 --- a/improver/fire_weather/__init__.py +++ b/improver/fire_weather/__init__.py @@ -4,7 +4,10 @@ # See LICENSE in the root of the repository for full licensing details. """Fire Weather Index System components.""" +<<<<<<< HEAD import warnings +======= +>>>>>>> b9459900 (Changes from ABC refactor) from abc import abstractmethod from typing import cast diff --git a/improver/fire_weather/duff_moisture_code.py b/improver/fire_weather/duff_moisture_code.py index 5a2bffad9d..27fbff1a18 100644 --- a/improver/fire_weather/duff_moisture_code.py +++ b/improver/fire_weather/duff_moisture_code.py @@ -2,16 +2,14 @@ # # 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. -from datetime import datetime -from typing import cast import numpy as np -from iris.cube import Cube, CubeList +from iris.cube import Cube -from improver import BasePlugin +from improver.fire_weather import FireWeatherIndexBase -class DuffMoistureCode(BasePlugin): +class DuffMoistureCode(FireWeatherIndexBase): """ Plugin to calculate the Duff Moisture Code (DMC) following the Canadian Forest Fire Weather Index System. @@ -35,6 +33,17 @@ class DuffMoistureCode(BasePlugin): - Month: integer (1-12) for day length factor lookup """ + INPUT_CUBE_NAMES = [ + "air_temperature", + "lwe_thickness_of_precipitation_amount", + "relative_humidity", + "duff_moisture_code", + ] + OUTPUT_CUBE_NAME = "duff_moisture_code" + REQUIRES_MONTH = True + # Disambiguate input DMC (yesterday's value) from output DMC (today's calculated value) + INPUT_ATTRIBUTE_MAPPINGS = {"duff_moisture_code": "input_dmc"} + temperature: Cube precipitation: Cube relative_humidity: Cube @@ -60,47 +69,25 @@ class DuffMoistureCode(BasePlugin): 6.0, # December ] - def load_input_cubes(self, cubes: tuple[Cube] | CubeList, month: int): - """Loads the required input cubes for the DMC calculation. These - are stored internally as Cube objects. - - Args: - cubes (tuple[Cube] | CubeList): Input cubes containing the necessary data. - month (int): Month of the year (1-12) for day length factor lookup. + def _calculate(self) -> np.ndarray: + """Calculate the Duff Moisture Code (DMC). - Raises: - ValueError: If the number of cubes does not match the expected - number (4), or if month is out of range. + Returns: + np.ndarray: The calculated DMC values for the current day. """ - names_to_extract = [ - "air_temperature", - "lwe_thickness_of_precipitation_amount", - "relative_humidity", - "duff_moisture_code", - ] - if len(cubes) != len(names_to_extract): - raise ValueError( - f"Expected {len(names_to_extract)} cubes, found {len(cubes)}" - ) - - if not (1 <= month <= 12): - raise ValueError(f"Month must be between 1 and 12, got {month}") - - self.month = month - - # Load the cubes into class attributes - ( - self.temperature, - self.precipitation, - self.relative_humidity, - self.input_dmc, - ) = tuple(cast(Cube, CubeList(cubes).extract_cube(n)) for n in names_to_extract) - - # Ensure the cubes are set to the correct units - self.temperature.convert_units("degC") - self.precipitation.convert_units("mm") - self.relative_humidity.convert_units("1") - self.input_dmc.convert_units("1") + # Step 1: Set today's DMC value to the previous day's DMC value + self.previous_dmc = self.input_dmc.data.copy() + + # Step 2: Perform rainfall adjustment, if precipitation > 1.5 mm + self._perform_rainfall_adjustment() + + # Steps 3 & 4: Calculate drying rate + drying_rate = self._calculate_drying_rate() + + # Step 5: Calculate DMC from adjusted previous DMC and drying rate + dmc = self._calculate_dmc(drying_rate) + + return dmc def _perform_rainfall_adjustment(self): """Updates the previous DMC value based on available precipitation @@ -158,12 +145,16 @@ def _perform_rainfall_adjustment(self): def _calculate_drying_rate(self) -> np.ndarray: """Calculates the drying rate for DMC. This is multiplied by 100 for computational efficiency in the final DMC calculation. The original - algorithm calculates K and then multilies it by 100 in the DMC equation. + algorithm calculates K and then multiplies it by 100 in the DMC equation. + + Temperature is bounded to a minimum of -1.1°C. Uses the day length factor + for the current month from DMC_DAY_LENGTH_FACTORS. From Van Wagner and Pickett (1985), Page 6: Equation 16, Steps 3 & 4. Returns: - np.ndarray: The drying rate value. + np.ndarray: The drying rate (dimensionless). Shape matches input cube + data shape. This value is added to previous DMC to get today's DMC. """ # Apply temperature lower bound of -1.1°C temp_adjusted = np.maximum(self.temperature.data, -1.1) @@ -203,70 +194,3 @@ def _calculate_dmc(self, drying_rate: np.ndarray) -> np.ndarray: dmc = np.maximum(dmc, 0.0) return dmc - - def _make_dmc_cube(self, dmc_data: np.ndarray) -> Cube: - """Converts a DMC data array into an iris.cube.Cube object - with relevant metadata copied from the input DMC cube, and updated - time coordinates from the precipitation cube. Time bounds are - removed from the output. - - Args: - dmc_data (np.ndarray): The DMC data - - Returns: - Cube: An iris.cube.Cube containing the DMC data with updated - metadata and coordinates. - """ - dmc_cube = self.input_dmc.copy(data=dmc_data.astype(np.float32)) - - # Update forecast_reference_time from precipitation cube - frt_coord = self.precipitation.coord("forecast_reference_time").copy() - dmc_cube.replace_coord(frt_coord) - - # Update time coordinate from precipitation cube (without bounds) - time_coord = self.precipitation.coord("time").copy() - time_coord.bounds = None - dmc_cube.replace_coord(time_coord) - - return dmc_cube - - def process( - self, - cubes: tuple[Cube] | CubeList, - month: int | None = None, - ) -> Cube: - """Calculate the Duff Moisture Code (DMC). - - Args: - cubes (Cube | CubeList): Input cubes containing: - air_temperature: Temperature in degrees Celsius - lwe_thickness_of_precipitation_amount: 24-hour precipitation in mm - relative_humidity: Relative humidity as a percentage (0-100) - duff_moisture_code: Previous day's DMC value - month (int | None): Month of the year (1-12) for day length factor lookup. - If None, defaults to the current month. - - Returns: - Cube: The calculated DMC values for the current day. - """ - if month is None: - month = datetime.now().month - - self.load_input_cubes(cubes, month) - - # Step 1: Set today's DMC value to the previous day's DMC value - self.previous_dmc = self.input_dmc.data.copy() - - # Step 2: Perform rainfall adjustment, if precipitation > 1.5 mm - self._perform_rainfall_adjustment() - - # Steps 3 & 4: Calculate drying rate - drying_rate = self._calculate_drying_rate() - - # Step 5: Calculate DMC from adjusted previous DMC and drying rate - output_dmc = self._calculate_dmc(drying_rate) - - # Convert DMC data to a cube and return - dmc_cube = self._make_dmc_cube(output_dmc) - - return dmc_cube diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index 334d57c2ee..9b940d8ed9 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -3,76 +3,12 @@ # 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. -from datetime import datetime - import numpy as np import pytest -from cf_units import Unit -from iris.coords import AuxCoord from iris.cube import Cube, CubeList from improver.fire_weather.duff_moisture_code import DuffMoistureCode - - -def make_cube( - data: np.ndarray, - name: str, - units: str, - add_time_coord: bool = False, -) -> Cube: - """Create a dummy Iris Cube with specified data, name, units, and optional - time coordinates. - - All cubes include a forecast_reference_time coordinate by default. - - Args: - data (np.ndarray): The data array for the cube. - name (str): The long name for the cube. - units (str): The units for the cube. - add_time_coord (bool): Whether to add a time coordinate with bounds. - - Returns: - Cube: The constructed Iris Cube with the given properties. - """ - arr = np.array(data, dtype=np.float64) - cube = Cube(arr, long_name=name) - cube.units = units - - # Always add forecast_reference_time - time_origin = "hours since 1970-01-01 00:00:00" - calendar = "gregorian" - - # Default forecast reference time: 2025-10-20 00:00:00 - frt = datetime(2025, 10, 20, 0, 0) - frt_coord = AuxCoord( - np.array([frt.timestamp() / 3600], dtype=np.float64), - standard_name="forecast_reference_time", - units=Unit(time_origin, calendar=calendar), - ) - cube.add_aux_coord(frt_coord) - - # Optionally add time coordinate with bounds - if add_time_coord: - # Default valid time: 2025-10-20 12:00:00 with 12-hour bounds - valid_time = datetime(2025, 10, 20, 12, 0) - time_bounds = np.array( - [ - [ - (valid_time.timestamp() - 43200) / 3600, # 12 hours earlier - valid_time.timestamp() / 3600, - ] - ], - dtype=np.float64, - ) - time_coord = AuxCoord( - np.array([valid_time.timestamp() / 3600], dtype=np.float64), - standard_name="time", - bounds=time_bounds, - units=Unit(time_origin, calendar=calendar), - ) - cube.add_aux_coord(time_coord) - - return cube +from improver_tests.fire_weather import make_cube, make_input_cubes def input_cubes( @@ -105,213 +41,28 @@ def input_cubes( Returns: list[Cube]: List of Iris Cubes for temperature, precipitation, relative humidity, and DMC. """ - temp = make_cube(np.full(shape, temp_val), "air_temperature", temp_units) - # Precipitation cube needs time coordinates for _make_dmc_cube - precip = make_cube( - np.full(shape, precip_val), - "lwe_thickness_of_precipitation_amount", - precip_units, - add_time_coord=True, - ) - rh = make_cube(np.full(shape, rh_val), "relative_humidity", rh_units) - # DMC cube needs time coordinates for _make_dmc_cube to copy metadata - dmc = make_cube( - np.full(shape, dmc_val), - "duff_moisture_code", - dmc_units, - add_time_coord=True, + return make_input_cubes( + [ + ("air_temperature", temp_val, temp_units, False), + ("lwe_thickness_of_precipitation_amount", precip_val, precip_units, True), + ("relative_humidity", rh_val, rh_units, False), + ("duff_moisture_code", dmc_val, dmc_units, True), + ], + shape=shape, ) - return [temp, precip, rh, dmc] - - -@pytest.mark.parametrize( - "temp_val, precip_val, rh_val, dmc_val", - [ - # Case 0: Typical mid-range values - (20.0, 1.0, 50.0, 6.0), - # Case 1: All zeros (edge case) - (0.0, 0.0, 0.0, 0.0), - # Case 2: All maximums/extremes - (100.0, 100.0, 100.0, 100.0), - # Case 3: Low temperature, low precip, low relative humidity, low DMC - (-10.0, 0.5, 10.0, 2.0), - # Case 4: High temp, high precip, high relative humidity, high DMC - (30.0, 10.0, 90.0, 120.0), - ], -) -def test_load_input_cubes( - temp_val: float, - precip_val: float, - rh_val: float, - dmc_val: float, -) -> None: - """Test DuffMoistureCode.load_input_cubes with various input conditions. - - Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - dmc_val (float): DMC value for all grid points. - - Raises: - AssertionError: If the loaded cubes do not match expected shapes and types. - """ - cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) - plugin = DuffMoistureCode() - plugin.load_input_cubes(CubeList(cubes), month=7) - - attributes = [ - plugin.temperature, - plugin.precipitation, - plugin.relative_humidity, - plugin.input_dmc, - ] - input_values = [temp_val, precip_val, rh_val, dmc_val] - - for attr, val in zip(attributes, input_values): - assert isinstance(attr, Cube) - assert attr.data.shape == (5, 5) - assert np.allclose(attr.data, val) - - # Check that month is set correctly - assert plugin.month == 7 - - -@pytest.mark.parametrize( - "param, input_val, input_unit, expected_val", - [ - # Case 0: Temperature: Kelvin -> degC - ("temperature", 293.15, "K", 20.0), - # Case 1: Precipitation: m -> mm - ("precipitation", 0.001, "m", 1.0), - # Case 2: Relative humidity: percentage -> fraction - ("relative_humidity", 10.0, "%", 0.1), - # Case 3: Input DMC: no conversion needed (dimensionless) - ("input_dmc", 6.0, "1", 6.0), - ], -) -def test_load_input_cubes_unit_conversion( - param: str, - input_val: float, - input_unit: str, - expected_val: float, -) -> None: - """ - Test that load_input_cubes correctly converts a single alternative unit for each input cube. - Args: - param (str): Name of the parameter to test (e.g., 'temperature', 'precipitation', etc.). - input_val (float): Value to use for the tested parameter. - input_unit (str): Unit to use for the tested parameter. - expected_val (float): Expected value after conversion. - - Raises: - AssertionError: If the converted value does not match the expected value. - """ - - # Override the value and unit for the parameter being tested - if param == "temperature": - cubes = input_cubes(temp_val=input_val, temp_units=input_unit) - elif param == "precipitation": - cubes = input_cubes(precip_val=input_val, precip_units=input_unit) - elif param == "relative_humidity": - cubes = input_cubes(rh_val=input_val, rh_units=input_unit) - elif param == "input_dmc": - cubes = input_cubes(dmc_val=input_val, dmc_units=input_unit) - - plugin = DuffMoistureCode() - plugin.load_input_cubes(CubeList(cubes), month=7) - # Check only the parameter being tested - result = getattr(plugin, param) - assert np.allclose(result.data, expected_val) - - -@pytest.mark.parametrize( - "num_cubes, should_raise, expected_message", - [ - # Case 0: Correct number of cubes (4) - (4, False, None), - # Case 1: Too few cubes (3 instead of 4) - (3, True, "Expected 4 cubes, found 3"), - # Case 2: No cubes (0 instead of 4) - (0, True, "Expected 4 cubes, found 0"), - # Case 3: Too many cubes (5 instead of 4) - (5, True, "Expected 4 cubes, found 5"), - ], -) -def test_load_input_cubes_wrong_number_raises_error( - num_cubes: int, - should_raise: bool, - expected_message: str, -) -> None: - """Test that load_input_cubes raises ValueError when given wrong number of cubes. - - Args: - num_cubes (int): Number of cubes to provide to load_input_cubes. - should_raise (bool): Whether a ValueError should be raised. - expected_message (str): Expected error message (or None if no error expected). - Raises: - AssertionError: If ValueError behavior does not match expectations. - """ - # Create a list with the specified number of cubes - cubes = input_cubes() - if num_cubes < len(cubes): - cubes = cubes[:num_cubes] - elif num_cubes > len(cubes): - # Add extra dummy cube(s) to test "too many cubes" case - for _ in range(num_cubes - len(cubes)): - cubes.append(make_cube(np.full((5, 5), 0.0), "extra_cube", "1")) - - plugin = DuffMoistureCode() - - if should_raise: - with pytest.raises(ValueError, match=expected_message): - plugin.load_input_cubes(CubeList(cubes), month=7) - else: - # Should not raise - verify it loads successfully - plugin.load_input_cubes(CubeList(cubes), month=7) - assert isinstance(plugin.temperature, Cube) - - -@pytest.mark.parametrize( - "month, should_raise, expected_message", - [ - # Valid months - (1, False, None), - (6, False, None), - (12, False, None), - # Invalid months - (0, True, "Month must be between 1 and 12, got 0"), - (13, True, "Month must be between 1 and 12, got 13"), - (-1, True, "Month must be between 1 and 12, got -1"), - ], -) -def test_load_input_cubes_month_validation( - month: int, - should_raise: bool, - expected_message: str, -) -> None: - """Test that load_input_cubes validates month parameter correctly. - - Args: - month (int): Month value to test. - should_raise (bool): Whether a ValueError should be raised. - expected_message (str): Expected error message (or None if no error expected). - - Raises: - AssertionError: If month validation does not match expectations. - """ +def test_input_attribute_mapping() -> None: + """Test that INPUT_ATTRIBUTE_MAPPINGS correctly disambiguates input DMC.""" cubes = input_cubes() plugin = DuffMoistureCode() + plugin.load_input_cubes(CubeList(cubes), month=7) - if should_raise: - with pytest.raises(ValueError, match=expected_message): - plugin.load_input_cubes(CubeList(cubes), month=month) - else: - # Should not raise - verify it loads successfully - plugin.load_input_cubes(CubeList(cubes), month=month) - assert plugin.month == month + # Check that the mapping was applied correctly + assert hasattr(plugin, "input_dmc") + assert isinstance(plugin.input_dmc, Cube) + assert plugin.input_dmc.long_name == "duff_moisture_code" + assert np.allclose(plugin.input_dmc.data, 6.0) @pytest.mark.parametrize( @@ -348,16 +99,13 @@ def test__perform_rainfall_adjustment( ) -> None: """Test _perform_rainfall_adjustment for various rainfall and DMC scenarios. - Tests include: no adjustment (precip <= 1.5), and various rainfall amounts - with different previous DMC values. + Tests include no adjustment (precip <= 1.5) and various rainfall amounts with + different previous DMC values. Args: precip_val (float): Precipitation value for all grid points. prev_dmc (float): Previous DMC value for all grid points. expected_dmc (float): Expected DMC after adjustment. - - Raises: - AssertionError: If the DMC adjustment does not match expectations. """ cubes = input_cubes(precip_val=precip_val, dmc_val=prev_dmc) plugin = DuffMoistureCode() @@ -371,7 +119,10 @@ def test__perform_rainfall_adjustment( def test__perform_rainfall_adjustment_spatially_varying() -> None: - """Test rainfall adjustment with spatially varying data (vectorization check).""" + """Test rainfall adjustment with spatially varying input data. + + Verifies vectorized DMC rainfall adjustment with varying values across the grid. + """ shape = (4, 4) # Produce a checkerboard precipitation pattern (5mm and 0mm alternating) precip_data = np.zeros(shape) @@ -436,17 +187,16 @@ def test__calculate_drying_rate( month: int, expected_rate: float, ) -> None: - """ - Test _calculate_drying_rate for various temperature, relative humidity, and month combinations. + """Test _calculate_drying_rate with various temperature, relative humidity, + and month combinations. + + Verifies drying rate calculation for DMC. Args: temp_val (float): Temperature value for all grid points. rh_val (float): Relative humidity value for all grid points. month (int): Month of the year (1-12). expected_rate (float): Expected drying rate value. - - Raises: - AssertionError: If the drying rate calculation does not match expectations. """ cubes = input_cubes(temp_val=temp_val, rh_val=rh_val) plugin = DuffMoistureCode() @@ -460,7 +210,10 @@ def test__calculate_drying_rate( def test__calculate_drying_rate_spatially_varying() -> None: - """Test drying rate with spatially varying temperature/relative humidity (vectorization check).""" + """Test drying rate with spatially varying temperature and relative humidity. + + Verifies vectorized drying rate calculation with varying values across the grid. + """ temp_data = np.array([[-5.0, 0.0, 10.0], [15.0, 20.0, 25.0], [30.0, 35.0, 40.0]]) rh_data = np.array([[20.0, 30.0, 40.0], [50.0, 60.0, 70.0], [80.0, 90.0, 95.0]]) @@ -512,13 +265,12 @@ def test__calculate_dmc( ) -> None: """Test _calculate_dmc for various previous DMC and drying rate values. + Verifies DMC calculation from previous DMC and drying rate. + Args: prev_dmc (float): Previous DMC value. drying_rate (float): Drying rate value. expected_dmc (float): Expected DMC output value. - - Raises: - AssertionError: If the DMC calculation does not match expectations. """ plugin = DuffMoistureCode() plugin.previous_dmc = np.array([prev_dmc]) @@ -530,75 +282,6 @@ def test__calculate_dmc( assert np.allclose(dmc, expected_dmc, atol=0.01) -@pytest.mark.parametrize( - "dmc_value, shape", - [ - # Case 0: Typical mid-range DMC value with standard grid - (10.0, (5, 5)), - # Case 1: Low DMC value with different grid size - (0.0, (3, 4)), - # Case 2: High DMC value with larger grid - (50.0, (10, 10)), - # Case 3: Very high DMC (edge case) with small grid - (200.0, (2, 2)), - # Case 4: Standard DMC value - (6.0, (5, 5)), - ], -) -def test__make_dmc_cube( - dmc_value: float, - shape: tuple[int, int], -) -> None: - """ - Test _make_dmc_cube to ensure it creates an Iris Cube with correct properties - for various DMC values and grid shapes. - - Args: - dmc_value (float): DMC data value to use for all grid points. - shape (tuple[int, int]): Shape of the grid. - - Raises: - AssertionError: If the created cube does not have expected properties. - """ - # Create input cubes with specified shape - cubes = input_cubes(shape=shape) - - # Initialize the plugin and load cubes - plugin = DuffMoistureCode() - plugin.load_input_cubes(CubeList(cubes), month=7) - - # Create test DMC data - dmc_data = np.full(shape, dmc_value, dtype=np.float64) - - # Call the method under test - result_cube = plugin._make_dmc_cube(dmc_data) - - # Check that result is an Iris Cube with correct type and shape - assert isinstance(result_cube, Cube) - assert result_cube.data.dtype == np.float32 - assert result_cube.data.shape == shape - assert np.allclose(result_cube.data, dmc_value, atol=0.001) - - # Check that the cube has the correct name and units - assert result_cube.long_name == "duff_moisture_code" - assert result_cube.units == "1" - - # Check that forecast_reference_time is copied from precipitation cube - result_frt = result_cube.coord("forecast_reference_time") - expected_frt = plugin.precipitation.coord("forecast_reference_time") - assert result_frt.points[0] == expected_frt.points[0] - assert result_frt.units == expected_frt.units - - # Check that time coordinate is copied from precipitation cube - result_time = result_cube.coord("time") - expected_time = plugin.precipitation.coord("time") - assert result_time.points[0] == expected_time.points[0] - assert result_time.units == expected_time.units - - # Check that time coordinate has no bounds (removed by _make_dmc_cube) - assert result_time.bounds is None - - @pytest.mark.parametrize( "temp_val, precip_val, rh_val, dmc_val, month, expected_output", [ @@ -624,8 +307,7 @@ def test_process( ) -> None: """Integration test for the complete DMC calculation process. - Tests end-to-end functionality with various environmental conditions and - verifies the final DMC output matches expected values. + Verifies end-to-end DMC calculation with various environmental conditions. Args: temp_val (float): Temperature value for all grid points. @@ -634,9 +316,6 @@ def test_process( dmc_val (float): DMC value for all grid points. month (int): Month of the year (1-12). expected_output (float): Expected DMC output value for all grid points. - - Raises: - AssertionError: If the process output does not match expectations. """ cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) plugin = DuffMoistureCode() @@ -651,28 +330,11 @@ def test_process( assert np.allclose(data, expected_output, atol=0.05) -def test_process_default_month() -> None: - """Test that process method works with default month parameter.""" - cubes = input_cubes() - plugin = DuffMoistureCode() - - # Should not raise - uses current month by default - result = plugin.process(CubeList(cubes)) - - # Check that the month is set to current month - from datetime import datetime - - current_month = datetime.now().month - assert plugin.month == current_month - - # Check that result is valid - assert hasattr(result, "data") - assert result.data.shape == cubes[0].data.shape - assert isinstance(result.data[0][0], (float, np.floating)) - - def test_process_spatially_varying() -> None: - """Integration test with spatially varying data (vectorization check).""" + """Integration test with spatially varying input data. + + Verifies vectorized DMC implementation with varying values across the grid. + """ temp_data = np.array([[10.0, 15.0, 20.0], [15.0, 20.0, 25.0], [20.0, 25.0, 30.0]]) precip_data = np.array([[0.0, 2.0, 5.0], [0.0, 0.0, 10.0], [0.0, 0.0, 0.0]]) rh_data = np.array([[40.0, 50.0, 60.0], [50.0, 60.0, 70.0], [60.0, 70.0, 80.0]]) @@ -705,8 +367,8 @@ def test_process_spatially_varying() -> None: assert len(np.unique(result.data)) > 1 -def test_day_length_factors_table() -> None: - """Test that DMC_DAY_LENGTH_FACTORS match the expected values from Van Wagner and Pickett Table 1.""" +def test_dmc_day_length_factors_table() -> None: + """Test that DMC_DAY_LENGTH_FACTORS match the expected values from lookup table.""" expected_factors = [ 0.0, # Placeholder for index 0 6.5, # January From ec9c5283e69e2ade96c4cb242832337484c18d85 Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Tue, 16 Dec 2025 16:11:36 +0000 Subject: [PATCH 07/10] Adding missing tests for valid inputs/outputs --- improver/fire_weather/__init__.py | 3 - .../fire_weather/test_duff_moisture_code.py | 135 +++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/improver/fire_weather/__init__.py b/improver/fire_weather/__init__.py index 1c6afef94e..554daae2e1 100644 --- a/improver/fire_weather/__init__.py +++ b/improver/fire_weather/__init__.py @@ -4,10 +4,7 @@ # See LICENSE in the root of the repository for full licensing details. """Fire Weather Index System components.""" -<<<<<<< HEAD import warnings -======= ->>>>>>> b9459900 (Changes from ABC refactor) from abc import abstractmethod from typing import cast diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index 9b940d8ed9..05f4685604 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -17,7 +17,7 @@ def input_cubes( rh_val: float = 50.0, dmc_val: float = 6.0, shape: tuple[int, int] = (5, 5), - temp_units: str = "degC", + temp_units: str = "Celsius", precip_units: str = "mm", rh_units: str = "1", dmc_units: str = "1", @@ -138,7 +138,7 @@ def test__perform_rainfall_adjustment_spatially_varying() -> None: ) cubes = [ - make_cube(np.full(shape, 20.0), "air_temperature", "degC"), + make_cube(np.full(shape, 20.0), "air_temperature", "Celsius"), make_cube( precip_data, "lwe_thickness_of_precipitation_amount", @@ -218,7 +218,7 @@ def test__calculate_drying_rate_spatially_varying() -> None: rh_data = np.array([[20.0, 30.0, 40.0], [50.0, 60.0, 70.0], [80.0, 90.0, 95.0]]) cubes = [ - make_cube(temp_data, "air_temperature", "degC"), + make_cube(temp_data, "air_temperature", "Celsius"), make_cube( np.zeros((3, 3)), "lwe_thickness_of_precipitation_amount", @@ -341,7 +341,7 @@ def test_process_spatially_varying() -> None: dmc_data = np.array([[5.0, 15.0, 30.0], [10.0, 50.0, 70.0], [20.0, 40.0, 90.0]]) cubes = [ - make_cube(temp_data, "air_temperature", "degC"), + make_cube(temp_data, "air_temperature", "Celsius"), make_cube( precip_data, "lwe_thickness_of_precipitation_amount", @@ -386,3 +386,130 @@ def test_dmc_day_length_factors_table() -> None: ] assert DuffMoistureCode.DMC_DAY_LENGTH_FACTORS == expected_factors + + +@pytest.mark.parametrize( + "temp_val, precip_val, rh_val, dmc_val, expected_error", + [ + # Temperature too high + (150.0, 1.0, 50.0, 6.0, "temperature contains values above valid maximum"), + # Temperature too low + (-150.0, 1.0, 50.0, 6.0, "temperature contains values below valid minimum"), + # Precipitation negative + (20.0, -5.0, 50.0, 6.0, "precipitation contains values below valid minimum"), + # Relative humidity above 100% + ( + 20.0, + 1.0, + 150.0, + 6.0, + "relative_humidity contains values above valid maximum", + ), + # Relative humidity negative + ( + 20.0, + 1.0, + -10.0, + 6.0, + "relative_humidity contains values below valid minimum", + ), + # DMC negative + (20.0, 1.0, 50.0, -5.0, "input_dmc contains values below valid minimum"), + ], +) +def test_invalid_input_ranges_raise_errors( + temp_val: float, + precip_val: float, + rh_val: float, + dmc_val: float, + expected_error: str, +) -> None: + """Test that invalid input values raise appropriate ValueError. + + Verifies that the base class validation catches physically meaningless + or out-of-range input values and raises descriptive errors. + + Args: + temp_val (float): Temperature value for all grid points. + precip_val (float): Precipitation value for all grid points. + rh_val (float): Relative humidity value for all grid points. + dmc_val (float): DMC value for all grid points. + expected_error (str): Expected error message substring. + """ + cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) + plugin = DuffMoistureCode() + + with pytest.raises(ValueError, match=expected_error): + plugin.load_input_cubes(CubeList(cubes), month=7) + + +@pytest.mark.parametrize( + "invalid_input_type,expected_error", + [ + ("temperature_nan", "temperature contains NaN"), + ("temperature_inf", "temperature contains infinite"), + ("precipitation_nan", "precipitation contains NaN"), + ("precipitation_inf", "precipitation contains infinite"), + ("relative_humidity_nan", "relative_humidity contains NaN"), + ("input_dmc_nan", "input_dmc contains NaN"), + ("input_dmc_inf", "input_dmc contains infinite"), + ], +) +def test_nan_and_inf_values_raise_errors( + invalid_input_type: str, expected_error: str +) -> None: + """Test that NaN and Inf values in inputs raise appropriate ValueError. + + Verifies that the validation catches non-finite values (NaN, Inf) in input data. + + Args: + invalid_input_type (str): Which input to make invalid and how. + expected_error (str): Expected error message substring. + """ + # Start with valid values + temp_val, precip_val, rh_val, dmc_val = 20.0, 1.0, 50.0, 6.0 + + # Replace the appropriate value with NaN or Inf + if invalid_input_type == "temperature_nan": + temp_val = np.nan + elif invalid_input_type == "temperature_inf": + temp_val = np.inf + elif invalid_input_type == "precipitation_nan": + precip_val = np.nan + elif invalid_input_type == "precipitation_inf": + precip_val = np.inf + elif invalid_input_type == "relative_humidity_nan": + rh_val = np.nan + elif invalid_input_type == "input_dmc_nan": + dmc_val = np.nan + elif invalid_input_type == "input_dmc_inf": + dmc_val = np.inf + + cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) + plugin = DuffMoistureCode() + + with pytest.raises(ValueError, match=expected_error): + plugin.load_input_cubes(CubeList(cubes), month=7) + + +def test_output_validation_no_warning_for_valid_output() -> None: + """Test that valid output values do not trigger warnings. + + Uses valid inputs to verify that as long as the output + stays within the expected range (0-400 for DMC), no warning is issued. + """ + # Use normal valid inputs + cubes = input_cubes(temp_val=20.0, precip_val=0.0, rh_val=50.0, dmc_val=50.0) + plugin = DuffMoistureCode() + + # Process should complete without warnings since output stays in valid range + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + result = plugin.process(CubeList(cubes), month=7) + + assert isinstance(result, Cube) + # Verify output is within expected range + assert np.all(result.data >= 0.0) + assert np.all(result.data <= 400.0) From 3e9968059e973ec5ebe8c4586c97031ea773a96e Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Mon, 5 Jan 2026 11:31:27 +0000 Subject: [PATCH 08/10] Adding docstring changes that standardise the docstring format to match the rest of IMPROVER --- improver/fire_weather/__init__.py | 60 ++++--- improver/fire_weather/duff_moisture_code.py | 9 +- .../fine_fuel_moisture_content.py | 32 ++-- improver_tests/fire_weather/__init__.py | 18 +- .../fire_weather/test_duff_moisture_code.py | 98 +++++++---- .../test_fine_fuel_moisture_content.py | 161 ++++++++++++------ .../test_fire_weather_index_base.py | 130 +++++++------- 7 files changed, 311 insertions(+), 197 deletions(-) diff --git a/improver/fire_weather/__init__.py b/improver/fire_weather/__init__.py index 554daae2e1..ab6857505f 100644 --- a/improver/fire_weather/__init__.py +++ b/improver/fire_weather/__init__.py @@ -104,18 +104,20 @@ class FireWeatherIndexBase(BasePlugin): } def load_input_cubes(self, cubes: tuple[Cube] | CubeList, month: int | None = None): - """Loads the required input cubes for the calculation. These - are stored internally as Cube objects. + """Loads the required input cubes for the calculation. These are stored + internally as Cube objects. Args: - cubes (tuple[iris.cube.Cube] | iris.cube.CubeList): Input cubes containing the necessary data. - month (int | None): Month of the year (1-12), required only if REQUIRES_MONTH is True. + cubes: + Input cubes containing the necessary data. + month: + Month of the year (1-12), required only if REQUIRES_MONTH is True. Defaults to None. Raises: - ValueError: If the number of cubes does not match the expected - number, if month is required but not provided, or if month - is out of range. + ValueError: + If the number of cubes does not match the expected number, if + month is required but not provided, or if month is out of range. """ if len(cubes) != len(self.INPUT_CUBE_NAMES): raise ValueError( @@ -152,10 +154,11 @@ def _get_attribute_name(self, standard_name: str) -> str: """Convert a cube standard name to an attribute name. Args: - standard_name (str): The cube's standard name + standard_name: + The cube's standard name Returns: - str: The attribute name to use for storing the cube + The attribute name to use for storing the cube Examples: "air_temperature" -> "temperature" @@ -176,11 +179,14 @@ def _validate_input_range(self, cube: Cube, attr_name: str) -> None: """Validate that input data falls within expected physical ranges. Args: - cube (iris.cube.Cube): The input cube to validate - attr_name (str): The attribute name for the cube + cube: + The input cube to validate + attr_name: + The attribute name for the cube Raises: - ValueError: If any values fall outside the valid range for this input type, + ValueError: + If any values fall outside the valid range for this input type, or if data contains NaN or Inf values """ if attr_name not in self._VALID_RANGES: @@ -223,13 +229,15 @@ def _make_output_cube( 24-hour accumulation period. Args: - data (np.ndarray): The output data array - template_cube (iris.cube.Cube | None): The cube to use as a template for metadata. + data: + The output data array + template_cube: + The cube to use as a template for metadata. If None, uses the first input cube. Defaults to None Returns: - iris.cube.Cube: The output cube containing the output data with proper - metadata and coordinates. + The output cube containing the output data with proper metadata + and coordinates. """ if template_cube is None: # Use first input cube as template @@ -267,7 +275,8 @@ def _calculate(self) -> np.ndarray: the specific calculation logic for that component. Raises: - NotImplementedError: This method must be implemented by subclasses. + NotImplementedError: + This method must be implemented by subclasses. """ raise NotImplementedError("Subclasses must implement the _calculate method.") @@ -275,15 +284,18 @@ def process(self, cubes: tuple[Cube] | CubeList, month: int | None = None) -> Cu """Calculate the fire weather index component. Args: - cubes (tuple[iris.cube.Cube] | iris.cube.CubeList): Input cubes as specified by INPUT_CUBE_NAMES - month (int | None): Month parameter (1-12), required only if REQUIRES_MONTH is True + cubes: + Input cubes as specified by INPUT_CUBE_NAMES + month: + Month parameter (1-12), required only if REQUIRES_MONTH is True Defaults to None. Returns: - iris.cube.Cube: The calculated output cube. + The calculated output cube. Warns: - UserWarning: If output values fall outside typical expected ranges + UserWarning: + If output values fall outside typical expected ranges """ self.load_input_cubes(cubes, month) output_data = self._calculate() @@ -298,10 +310,12 @@ def _validate_output_range(self, output_cube: Cube) -> None: """Check if output values fall within expected ranges and issue warnings if not. Args: - output_cube (iris.cube.Cube): The output cube to validate + output_cube: + The output cube to validate Warns: - UserWarning: If output contains NaN, Inf, or values outside expected ranges + UserWarning: + If output contains NaN, Inf, or values outside expected ranges """ output_name = output_cube.name() diff --git a/improver/fire_weather/duff_moisture_code.py b/improver/fire_weather/duff_moisture_code.py index 27fbff1a18..5fafbc656a 100644 --- a/improver/fire_weather/duff_moisture_code.py +++ b/improver/fire_weather/duff_moisture_code.py @@ -153,8 +153,8 @@ def _calculate_drying_rate(self) -> np.ndarray: From Van Wagner and Pickett (1985), Page 6: Equation 16, Steps 3 & 4. Returns: - np.ndarray: The drying rate (dimensionless). Shape matches input cube - data shape. This value is added to previous DMC to get today's DMC. + The drying rate (dimensionless). Shape matches input cube data + shape. This value is added to previous DMC to get today's DMC. """ # Apply temperature lower bound of -1.1°C temp_adjusted = np.maximum(self.temperature.data, -1.1) @@ -182,10 +182,11 @@ def _calculate_dmc(self, drying_rate: np.ndarray) -> np.ndarray: From Van Wagner and Pickett (1985), Page 6: Equation 16. Args: - drying_rate (np.ndarray): The drying rate (RK). + drying_rate: + The drying rate, represented by 'K' in the original equations. Returns: - np.ndarray: The calculated DMC value. + The calculated DMC value. """ # Equation 16: Calculate DMC dmc = self.previous_dmc + drying_rate diff --git a/improver/fire_weather/fine_fuel_moisture_content.py b/improver/fire_weather/fine_fuel_moisture_content.py index 752f8d71e8..cd0ac724e8 100644 --- a/improver/fire_weather/fine_fuel_moisture_content.py +++ b/improver/fire_weather/fine_fuel_moisture_content.py @@ -54,7 +54,7 @@ def _calculate(self) -> np.ndarray: """Calculate the Fine Fuel Moisture Code (FFMC). Returns: - np.ndarray: The calculated FFMC values for the current day. + The calculated FFMC values for the current day. """ # Step 1 & 2: Calculate the previous day's moisture content self._calculate_moisture_content() @@ -196,9 +196,9 @@ def _calculate_EMC_for_drying_phase(self) -> np.ndarray: From Van Wagner and Pickett (1985), Page 5: Equation 4, and Step 4. Returns: - np.ndarray: The Equilibrium Moisture Content for the drying phase (E_d). - Array shape matches the input cube data shape. Values are in moisture - content units (dimensionless). + The Equilibrium Moisture Content for the drying phase (E_d).Array + shape matches the input cube data shape. Values are in moisture + content units (dimensionless). """ # Equation 4: Calculate EMC for drying phase (E_d) E_d = ( @@ -219,11 +219,12 @@ def _calculate_moisture_content_through_drying_rate( From Van Wagner and Pickett (1985), Page 5: Equations 6a, 6b, 8, and Step 5. Args: - E_d (np.ndarray): The Equilibrium Moisture Content for the drying phase. + E_d: + The Equilibrium Moisture Content for the drying phase. Returns: - np.ndarray: Array of moisture content (dimensionless) with drying applied - at all grid points. Shape matches input cube data shape. + Array of moisture content (dimensionless) with drying applied at all + grid points. Shape matches input cube data shape. """ # Equation 6a: Calculate the log drying rate intermediate step k_o = 0.424 * ( @@ -247,9 +248,9 @@ def _calculate_EMC_for_wetting_phase(self) -> np.ndarray: From Van Wagner and Pickett (1985), Page 5: Equation 5, and Step 6. Returns: - np.ndarray: The Equilibrium Moisture Content for the wetting phase (E_w). - Array shape matches the input cube data shape. Values are in moisture - content units (dimensionless). + The Equilibrium Moisture Content for the wetting phase (E_w). Array + shape matches the input cube data shape. Values are in moisture + content units (dimensionless). """ # Equation 5: Calculate the EMC for the wetting phase (E_w) E_w = ( @@ -270,11 +271,12 @@ def _calculate_moisture_content_through_wetting_equilibrium( From Van Wagner and Pickett (1985), Page 5: Equations 7a, 7b, 9, and Step 7. Args: - E_w (np.ndarray): The Equilibrium Moisture Content for the wetting phase. + E_w: + The Equilibrium Moisture Content for the wetting phase. Returns: - np.ndarray: Array of moisture content (dimensionless) with wetting applied - at all grid points. Shape matches input cube data shape. + Array of moisture content (dimensionless) with wetting applied at all + grid points. Shape matches input cube data shape. """ # Equation 7a: Calculate the log wetting rate intermediate step k_l = 0.424 * ( @@ -298,8 +300,8 @@ def _calculate_ffmc_from_moisture_content(self) -> np.ndarray: From Van Wagner and Pickett (1985), Page 5: Equation 10, and Step 9. Returns: - np.ndarray: The calculated FFMC values (dimensionless, range 0-101). - Shape matches input cube data shape. + The calculated FFMC values (dimensionless, range 0-101). Array shape + matches input cube data shape. """ # Equation 10: Calculate FFMC from moisture content ffmc = 59.5 * (250.0 - self.moisture_content) / (147.2 + self.moisture_content) diff --git a/improver_tests/fire_weather/__init__.py b/improver_tests/fire_weather/__init__.py index 550fbd78e0..627d238340 100644 --- a/improver_tests/fire_weather/__init__.py +++ b/improver_tests/fire_weather/__init__.py @@ -29,10 +29,14 @@ def make_cube( with consistent time coordinates across all fire weather tests. Args: - data: The data array for the cube. - name: The variable name for the cube (can be standard_name or long_name). - units: The units for the cube. - add_time_coord: Whether to add time bounds (for accumulation periods). + data: + The data array for the cube. + name: + The variable name for the cube (can be standard_name or long_name). + units: + The units for the cube. + add_time_coord: + Whether to add time bounds (for accumulation periods). Returns: Iris Cube with the given properties, including forecast_reference_time @@ -59,13 +63,15 @@ def make_input_cubes( a consistent shape and default values. Args: - cube_specs: List of tuples, each containing: + cube_specs: + List of tuples, each containing: (name, value, units, add_time_coord) - name: Variable name (standard_name or long_name) - value: Scalar value to fill the cube - units: Units for the cube - add_time_coord: Whether to add time bounds - shape: Shape of the grid for each cube. + shape: + Shape of the grid for each cube. Returns: List of Iris Cubes with the specified properties. diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index 05f4685604..fa1af284a6 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -28,18 +28,27 @@ def input_cubes( time coordinates with bounds. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - dmc_val (float): DMC value for all grid points. - shape (tuple[int, int]): Shape of the grid for each cube. - temp_units (str): Units for temperature cube. - precip_units (str): Units for precipitation cube. - rh_units (str): Units for relative humidity cube. - dmc_units (str): Units for DMC cube. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + dmc_val: + DMC value for all grid points. + shape: + Shape of the grid for each cube. + temp_units: + Units for temperature cube. + precip_units: + Units for precipitation cube. + rh_units: + Units for relative humidity cube. + dmc_units: + Units for DMC cube. Returns: - list[Cube]: List of Iris Cubes for temperature, precipitation, relative humidity, and DMC. + List of Iris Cubes for temperature, precipitation, relative humidity, and DMC. """ return make_input_cubes( [ @@ -103,9 +112,12 @@ def test__perform_rainfall_adjustment( different previous DMC values. Args: - precip_val (float): Precipitation value for all grid points. - prev_dmc (float): Previous DMC value for all grid points. - expected_dmc (float): Expected DMC after adjustment. + precip_val: + Precipitation value for all grid points. + prev_dmc: + Previous DMC value for all grid points. + expected_dmc: + Expected DMC after adjustment. """ cubes = input_cubes(precip_val=precip_val, dmc_val=prev_dmc) plugin = DuffMoistureCode() @@ -193,10 +205,14 @@ def test__calculate_drying_rate( Verifies drying rate calculation for DMC. Args: - temp_val (float): Temperature value for all grid points. - rh_val (float): Relative humidity value for all grid points. - month (int): Month of the year (1-12). - expected_rate (float): Expected drying rate value. + temp_val: + Temperature value for all grid points. + rh_val: + Relative humidity value for all grid points. + month: + Month of the year (1-12). + expected_rate: + Expected drying rate value. """ cubes = input_cubes(temp_val=temp_val, rh_val=rh_val) plugin = DuffMoistureCode() @@ -268,9 +284,12 @@ def test__calculate_dmc( Verifies DMC calculation from previous DMC and drying rate. Args: - prev_dmc (float): Previous DMC value. - drying_rate (float): Drying rate value. - expected_dmc (float): Expected DMC output value. + prev_dmc: + Previous DMC value. + drying_rate: + Drying rate value. + expected_dmc: + Expected DMC output value. """ plugin = DuffMoistureCode() plugin.previous_dmc = np.array([prev_dmc]) @@ -310,12 +329,18 @@ def test_process( Verifies end-to-end DMC calculation with various environmental conditions. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - dmc_val (float): DMC value for all grid points. - month (int): Month of the year (1-12). - expected_output (float): Expected DMC output value for all grid points. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + dmc_val: + DMC value for all grid points. + month: + Month of the year (1-12). + expected_output: + Expected DMC output value for all grid points. """ cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) plugin = DuffMoistureCode() @@ -430,11 +455,16 @@ def test_invalid_input_ranges_raise_errors( or out-of-range input values and raises descriptive errors. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - dmc_val (float): DMC value for all grid points. - expected_error (str): Expected error message substring. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + dmc_val: + DMC value for all grid points. + expected_error: + Expected error message substring. """ cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) plugin = DuffMoistureCode() @@ -463,8 +493,10 @@ def test_nan_and_inf_values_raise_errors( Verifies that the validation catches non-finite values (NaN, Inf) in input data. Args: - invalid_input_type (str): Which input to make invalid and how. - expected_error (str): Expected error message substring. + invalid_input_type: + Which input to make invalid and how. + expected_error: + Expected error message substring. """ # Start with valid values temp_val, precip_val, rh_val, dmc_val = 20.0, 1.0, 50.0, 6.0 diff --git a/improver_tests/fire_weather/test_fine_fuel_moisture_content.py b/improver_tests/fire_weather/test_fine_fuel_moisture_content.py index 60816ba2e4..3cea1dbd89 100644 --- a/improver_tests/fire_weather/test_fine_fuel_moisture_content.py +++ b/improver_tests/fire_weather/test_fine_fuel_moisture_content.py @@ -30,20 +30,31 @@ def input_cubes( time coordinates with bounds. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - wind_val (float): Wind speed value for all grid points. - ffmc_val (float): FFMC value for all grid points. - shape (tuple[int, int]): Shape of the grid for each cube. - temp_units (str): Units for temperature cube. - precip_units (str): Units for precipitation cube. - rh_units (str): Units for relative humidity cube. - wind_units (str): Units for wind speed cube. - ffmc_units (str): Units for FFMC cube. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + wind_val: + Wind speed value for all grid points. + ffmc_val: + FFMC value for all grid points. + shape: + Shape of the grid for each cube. + temp_units: + Units for temperature cube. + precip_units: + Units for precipitation cube. + rh_units: + Units for relative humidity cube. + wind_units: + Units for wind speed cube. + ffmc_units: + Units for FFMC cube. Returns: - list[Cube]: List of Iris Cubes for temperature, precipitation, relative humidity, wind speed, and FFMC. + List of Iris Cubes for temperature, precipitation, relative humidity, wind speed, and FFMC. """ return make_input_cubes( [ @@ -82,11 +93,16 @@ def test__calculate_moisture_content( Verifies that the initial moisture content is calculated correctly from FFMC. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - wind_val (float): Wind speed value for all grid points. - ffmc_val (float): FFMC value for all grid points. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + wind_val: + Wind speed value for all grid points. + ffmc_val: + FFMC value for all grid points. """ cubes = input_cubes(temp_val, precip_val, rh_val, wind_val, ffmc_val) plugin = FineFuelMoistureContent() @@ -140,9 +156,12 @@ def test__perform_rainfall_adjustment( adjustment1 + adjustment2 (mc > 150), and capping at 250. Args: - precip_val (float): Precipitation value for all grid points. - initial_mc_val (float): Initial moisture content value for all grid points. - expected_mc (float): Expected moisture content after adjustment. + precip_val: + Precipitation value for all grid points. + initial_mc_val: + Initial moisture content value for all grid points. + expected_mc: + Expected moisture content after adjustment. """ cubes = input_cubes( precip_val=precip_val, @@ -235,9 +254,12 @@ def test__calculate_EMC_for_drying_phase( Verifies Equilibrium Moisture Content calculation for the drying phase. Args: - temp_val (float): Temperature value for all grid points. - rh_val (float): Relative humidity value for all grid points. - expected_E_d (float): Expected drying phase value. + temp_val: + Temperature value for all grid points. + rh_val: + Relative humidity value for all grid points. + expected_E_d: + Expected drying phase value. """ cubes = input_cubes(temp_val=temp_val, rh_val=rh_val) plugin = FineFuelMoistureContent() @@ -313,12 +335,18 @@ def test__calculate_moisture_content_through_drying_rate( Verifies moisture content calculation through drying rate. Args: - moisture_content (np.ndarray): Moisture content values for all grid points. - relative_humidity (float): Relative humidity value for all grid points. - wind_speed (float): Wind speed value for all grid points. - temperature (float): Temperature value for all grid points. - E_d (np.ndarray): Drying phase values for all grid points. - expected_output (np.ndarray): Expected output moisture content values. + moisture_content: + Moisture content values for all grid points. + relative_humidity: + Relative humidity value for all grid points. + wind_speed: + Wind speed value for all grid points. + temperature: + Temperature value for all grid points. + E_d: + Drying phase values for all grid points. + expected_output: + Expected output moisture content values. """ plugin = FineFuelMoistureContent() plugin.initial_moisture_content = moisture_content.copy() @@ -368,9 +396,12 @@ def test__calculate_EMC_for_wetting_phase( Verifies Equilibrium Moisture Content calculation for the wetting phase. Args: - temp_val (float): Temperature value for all grid points. - rh_val (float): Relative humidity value for all grid points. - expected_E_w (float): Expected wetting phase value. + temp_val: + Temperature value for all grid points. + rh_val: + Relative humidity value for all grid points. + expected_E_w: + Expected wetting phase value. """ cubes = input_cubes(temp_val=temp_val, rh_val=rh_val) plugin = FineFuelMoistureContent() @@ -446,12 +477,18 @@ def test__calculate_moisture_content_through_wetting_equilibrium( Verifies moisture content calculation through wetting equilibrium. Args: - moisture_content (np.ndarray): Moisture content values for all grid points. - relative_humidity (float): Relative humidity value for all grid points. - wind_speed (float): Wind speed value for all grid points. - temperature (float): Temperature value for all grid points. - E_w (np.ndarray): Wetting phase values for all grid points. - expected_output (np.ndarray): Expected output moisture content values. + moisture_content: + Moisture content values for all grid points. + relative_humidity: + Relative humidity value for all grid points. + wind_speed: + Wind speed value for all grid points. + temperature: + Temperature value for all grid points. + E_w: + Wetting phase values for all grid points. + expected_output: + Expected output moisture content values. """ plugin = FineFuelMoistureContent() plugin.initial_moisture_content = moisture_content.copy() @@ -517,8 +554,10 @@ def test__calculate_ffmc_from_moisture_content( Verifies FFMC calculation from moisture content. Args: - moisture_content (np.ndarray): Moisture content values for all grid points. - expected_output (np.ndarray): Expected FFMC output values. + moisture_content: + Moisture content values for all grid points. + expected_output: + Expected FFMC output values. """ plugin = FineFuelMoistureContent() plugin.moisture_content = moisture_content.copy() @@ -558,12 +597,18 @@ def test_process( Verifies end-to-end FFMC calculation with various environmental conditions. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - wind_val (float): Wind speed value for all grid points. - ffmc_val (float): FFMC value for all grid points. - expected_output (float): Expected FFMC output value for all grid points. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + wind_val: + Wind speed value for all grid points. + ffmc_val: + FFMC value for all grid points. + expected_output: + Expected FFMC output value for all grid points. """ cubes = input_cubes(temp_val, precip_val, rh_val, wind_val, ffmc_val) plugin = FineFuelMoistureContent() @@ -694,12 +739,18 @@ def test_invalid_input_ranges_raise_errors( or out-of-range input values and raises descriptive errors. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - wind_val (float): Wind speed value for all grid points. - ffmc_val (float): FFMC value for all grid points. - expected_error (str): Expected error message substring. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + wind_val: + Wind speed value for all grid points. + ffmc_val: + FFMC value for all grid points. + expected_error: + Expected error message substring. """ cubes = input_cubes(temp_val, precip_val, rh_val, wind_val, ffmc_val) plugin = FineFuelMoistureContent() @@ -728,8 +779,10 @@ def test_nan_and_inf_values_raise_errors( Verifies that the validation catches non-finite values (NaN, Inf) in input data. Args: - invalid_input_type (str): Which input to make invalid and how. - expected_error (str): Expected error message substring. + invalid_input_type: + Which input to make invalid and how. + expected_error: + Expected error message substring. """ # Start with valid values temp_val, precip_val, rh_val, wind_val, ffmc_val = 20.0, 1.0, 50.0, 10.0, 85.0 diff --git a/improver_tests/fire_weather/test_fire_weather_index_base.py b/improver_tests/fire_weather/test_fire_weather_index_base.py index bef51d6499..7b369d111d 100644 --- a/improver_tests/fire_weather/test_fire_weather_index_base.py +++ b/improver_tests/fire_weather/test_fire_weather_index_base.py @@ -77,12 +77,15 @@ def input_cubes_basic( """Create basic input cubes for testing. Args: - temp_val (float): Temperature value for all grid points. - rh_val (float): Relative humidity value for all grid points. - shape (tuple[int, int]): Shape of the grid for each cube. + temp_val: + Temperature value for all grid points. + rh_val: + Relative humidity value for all grid points. + shape: + Shape of the grid for each cube. Returns: - list[Cube]: List of Iris Cubes for temperature and relative humidity. + List of Iris Cubes for temperature and relative humidity. """ return make_input_cubes( [ @@ -102,13 +105,17 @@ def input_cubes_with_precip( """Create input cubes including precipitation with time coordinates. Args: - temp_val (float): Temperature value for all grid points. - precip_val (float): Precipitation value for all grid points. - rh_val (float): Relative humidity value for all grid points. - shape (tuple[int, int]): Shape of the grid for each cube. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + shape: + Shape of the grid for each cube. Returns: - list[Cube]: List of Iris Cubes for temperature, precipitation, and RH. + List of Iris Cubes for temperature, precipitation, and RH. """ return make_input_cubes( [ @@ -139,11 +146,10 @@ def test_load_input_cubes_basic(temp_val: float, rh_val: float) -> None: """Test load_input_cubes with basic two-cube setup. Args: - temp_val (float): Temperature value for all grid points. - rh_val (float): Relative humidity value for all grid points. - - Raises: - AssertionError: If the loaded cubes do not match expected properties. + temp_val: + Temperature value for all grid points. + rh_val: + Relative humidity value for all grid points. """ cubes = input_cubes_basic(temp_val, rh_val) plugin = ConcreteFireWeatherIndex() @@ -181,13 +187,14 @@ def test_load_input_cubes_unit_conversion( """Test that load_input_cubes correctly converts units. Args: - param (str): Name of the parameter to test. - input_val (float): Value to use for the tested parameter. - input_unit (str): Unit to use for the tested parameter. - expected_val (float): Expected value after conversion. - - Raises: - AssertionError: If the converted value does not match expectations. + param: + Name of the parameter to test. + input_val: + Value to use for the tested parameter. + input_unit: + Unit to use for the tested parameter. + expected_val: + Expected value after conversion. """ # Create cubes with custom units if param == "temperature": @@ -226,12 +233,12 @@ def test_load_input_cubes_wrong_number_raises_error( """Test that load_input_cubes raises ValueError for wrong number of cubes. Args: - num_cubes (int): Number of cubes to provide. - should_raise (bool): Whether a ValueError should be raised. - expected_message (str): Expected error message. - - Raises: - AssertionError: If ValueError behavior does not match expectations. + num_cubes: + Number of cubes to provide. + should_raise: + Whether a ValueError should be raised. + expected_message: + Expected error message. """ cubes = input_cubes_basic() @@ -314,12 +321,12 @@ def test_load_input_cubes_month_validation( """Test that month parameter is validated correctly. Args: - month (int): Month value to test. - should_raise (bool): Whether a ValueError should be raised. - expected_message (str): Expected error message. - - Raises: - AssertionError: If validation behavior does not match expectations. + month: + Month value to test. + should_raise: + Whether a ValueError should be raised. + expected_message: + Expected error message. """ temp = make_cube(np.full((5, 5), 20.0), "air_temperature", "Celsius") precip = make_cube( @@ -358,11 +365,10 @@ def test_get_attribute_name_standard_conversion( """Test _get_attribute_name with standard name conversions. Args: - standard_name (str): Standard name to convert. - expected_attr_name (str): Expected attribute name. - - Raises: - AssertionError: If attribute name does not match expectations. + standard_name: + Standard name to convert. + expected_attr_name: + Expected attribute name. """ plugin = ConcreteFireWeatherIndex() result = plugin._get_attribute_name(standard_name) @@ -418,11 +424,10 @@ def test_make_output_cube_basic(output_value: float, shape: tuple[int, int]) -> """Test _make_output_cube creates cube with correct properties. Args: - output_value (float): Value for output data. - shape (tuple[int, int]): Shape of the grid. - - Raises: - AssertionError: If output cube does not have expected properties. + output_value: + Value for output data. + shape: + Shape of the grid. """ cubes = input_cubes_basic(shape=shape) plugin = ConcreteFireWeatherIndex() @@ -646,12 +651,12 @@ def test_validate_input_range_raises_error( """Test that _validate_input_range raises ValueError for out-of-range values. Args: - param (str): Parameter name to test. - value (float): Invalid value to test. - expected_error (str): Expected error message substring. - - Raises: - AssertionError: If validation does not raise the expected error. + param: + Parameter name to test. + value: + Invalid value to test. + expected_error: + Expected error message substring. """ # Create cubes with invalid values if param == "temperature": @@ -687,12 +692,12 @@ def test_validate_input_range_nan_inf_raises_error( """Test that _validate_input_range raises ValueError for NaN and Inf values. Args: - param (str): Parameter name to test. - value (float): NaN or Inf value to test. - expected_error (str): Expected error message substring. - - Raises: - AssertionError: If validation does not raise the expected error. + param: + Parameter name to test. + value: + NaN or Inf value to test. + expected_error: + Expected error message substring. """ # Create cubes with NaN or Inf values if param == "temperature": @@ -727,11 +732,10 @@ def test_validate_input_range_accepts_valid_values( """Test that _validate_input_range accepts valid values without error. Args: - temp_val (float): Valid temperature value. - rh_val (float): Valid relative humidity value. - - Raises: - AssertionError: If validation raises an error for valid values. + temp_val: + Valid temperature value. + rh_val: + Valid relative humidity value. """ cubes = input_cubes_basic(temp_val, rh_val) plugin = ConcreteFireWeatherIndex() @@ -873,8 +877,10 @@ def test_validate_output_range_warns_for_out_of_range_values( """Test that _validate_output_range warns when output is outside expected range. Args: - output_value (float): Value to use for output data (outside valid range). - description (str): Description of the test case. + output_value: + Value to use for output data (outside valid range). + description: + Description of the test case. """ cubes = input_cubes_basic(temp_val=20.0, rh_val=50.0) plugin = ConcreteFireWeatherIndexForOutputValidation() From 35d057445db587272d29b54fdbfb5766b3ccf4cd Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Mon, 5 Jan 2026 11:54:43 +0000 Subject: [PATCH 09/10] Changes from review --- .../fire_weather/test_duff_moisture_code.py | 92 +++++++++---------- 1 file changed, 41 insertions(+), 51 deletions(-) diff --git a/improver_tests/fire_weather/test_duff_moisture_code.py b/improver_tests/fire_weather/test_duff_moisture_code.py index fa1af284a6..be4e2b78f0 100644 --- a/improver_tests/fire_weather/test_duff_moisture_code.py +++ b/improver_tests/fire_weather/test_duff_moisture_code.py @@ -2,6 +2,7 @@ # # 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 warnings import numpy as np import pytest @@ -413,33 +414,30 @@ def test_dmc_day_length_factors_table() -> None: assert DuffMoistureCode.DMC_DAY_LENGTH_FACTORS == expected_factors +# Define input parameters for test_invalid_input_ranges_raise_errors cases +TEMPERATURE_TOO_HIGH = 150.0, 1.0, 50.0, 6.0 +TEMPERATURE_TOO_LOW = -150.0, 1.0, 50.0, 6.0 +PRECIPITATION_NEGATIVE = 20.0, -5.0, 50.0, 6.0 +RELATIVE_HUMIDITY_TOO_HIGH = 20.0, 1.0, 150.0, 6.0 +NEGATIVE_RELATIVE_HUMIDITY = 20.0, 1.0, -10.0, 6.0 +DMC_NEGATIVE = 20.0, 1.0, 50.0, -5.0 + + @pytest.mark.parametrize( "temp_val, precip_val, rh_val, dmc_val, expected_error", [ - # Temperature too high - (150.0, 1.0, 50.0, 6.0, "temperature contains values above valid maximum"), - # Temperature too low - (-150.0, 1.0, 50.0, 6.0, "temperature contains values below valid minimum"), - # Precipitation negative - (20.0, -5.0, 50.0, 6.0, "precipitation contains values below valid minimum"), - # Relative humidity above 100% + (*TEMPERATURE_TOO_HIGH, "temperature contains values above valid maximum"), + (*TEMPERATURE_TOO_LOW, "temperature contains values below valid minimum"), + (*PRECIPITATION_NEGATIVE, "precipitation contains values below valid minimum"), ( - 20.0, - 1.0, - 150.0, - 6.0, + *RELATIVE_HUMIDITY_TOO_HIGH, "relative_humidity contains values above valid maximum", ), - # Relative humidity negative ( - 20.0, - 1.0, - -10.0, - 6.0, + *NEGATIVE_RELATIVE_HUMIDITY, "relative_humidity contains values below valid minimum", ), - # DMC negative - (20.0, 1.0, 50.0, -5.0, "input_dmc contains values below valid minimum"), + (*DMC_NEGATIVE, "input_dmc contains values below valid minimum"), ], ) def test_invalid_input_ranges_raise_errors( @@ -473,50 +471,45 @@ def test_invalid_input_ranges_raise_errors( plugin.load_input_cubes(CubeList(cubes), month=7) +TEMP_VAL, PRECIP_VAL, RH_VAL, DMC_VAL = 20.0, 1.0, 5.0, 6.0 + + @pytest.mark.parametrize( - "invalid_input_type,expected_error", + "temp_val, precip_val, rh_val, dmc_val, expected_error", [ - ("temperature_nan", "temperature contains NaN"), - ("temperature_inf", "temperature contains infinite"), - ("precipitation_nan", "precipitation contains NaN"), - ("precipitation_inf", "precipitation contains infinite"), - ("relative_humidity_nan", "relative_humidity contains NaN"), - ("input_dmc_nan", "input_dmc contains NaN"), - ("input_dmc_inf", "input_dmc contains infinite"), + (np.nan, PRECIP_VAL, RH_VAL, DMC_VAL, "temperature contains NaN"), + (np.inf, PRECIP_VAL, RH_VAL, DMC_VAL, "temperature contains infinite"), + (TEMP_VAL, np.nan, RH_VAL, DMC_VAL, "precipitation contains NaN"), + (TEMP_VAL, np.inf, RH_VAL, DMC_VAL, "precipitation contains infinite"), + (TEMP_VAL, PRECIP_VAL, np.nan, DMC_VAL, "relative_humidity contains NaN"), + (TEMP_VAL, PRECIP_VAL, np.inf, DMC_VAL, "relative_humidity contains infinite"), + (TEMP_VAL, PRECIP_VAL, RH_VAL, np.nan, "input_dmc contains NaN"), + (TEMP_VAL, PRECIP_VAL, RH_VAL, np.inf, "input_dmc contains infinite"), ], ) def test_nan_and_inf_values_raise_errors( - invalid_input_type: str, expected_error: str + temp_val: float, + precip_val: float, + rh_val: float, + dmc_val: float, + expected_error: str, ) -> None: """Test that NaN and Inf values in inputs raise appropriate ValueError. Verifies that the validation catches non-finite values (NaN, Inf) in input data. Args: - invalid_input_type: - Which input to make invalid and how. + temp_val: + Temperature value for all grid points. + precip_val: + Precipitation value for all grid points. + rh_val: + Relative humidity value for all grid points. + dmc_val: + DMC value for all grid points. expected_error: Expected error message substring. """ - # Start with valid values - temp_val, precip_val, rh_val, dmc_val = 20.0, 1.0, 50.0, 6.0 - - # Replace the appropriate value with NaN or Inf - if invalid_input_type == "temperature_nan": - temp_val = np.nan - elif invalid_input_type == "temperature_inf": - temp_val = np.inf - elif invalid_input_type == "precipitation_nan": - precip_val = np.nan - elif invalid_input_type == "precipitation_inf": - precip_val = np.inf - elif invalid_input_type == "relative_humidity_nan": - rh_val = np.nan - elif invalid_input_type == "input_dmc_nan": - dmc_val = np.nan - elif invalid_input_type == "input_dmc_inf": - dmc_val = np.inf - cubes = input_cubes(temp_val, precip_val, rh_val, dmc_val) plugin = DuffMoistureCode() @@ -534,9 +527,6 @@ def test_output_validation_no_warning_for_valid_output() -> None: cubes = input_cubes(temp_val=20.0, precip_val=0.0, rh_val=50.0, dmc_val=50.0) plugin = DuffMoistureCode() - # Process should complete without warnings since output stays in valid range - import warnings - with warnings.catch_warnings(): warnings.simplefilter("error") # Turn warnings into errors result = plugin.process(CubeList(cubes), month=7) From 0258d0f9e691181d43a0f3671ee60b844611442f Mon Sep 17 00:00:00 2001 From: Phil Relton Date: Tue, 6 Jan 2026 13:47:31 +0000 Subject: [PATCH 10/10] Fixing review typo --- improver/fire_weather/fine_fuel_moisture_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/improver/fire_weather/fine_fuel_moisture_content.py b/improver/fire_weather/fine_fuel_moisture_content.py index cd0ac724e8..b721c3b05d 100644 --- a/improver/fire_weather/fine_fuel_moisture_content.py +++ b/improver/fire_weather/fine_fuel_moisture_content.py @@ -196,7 +196,7 @@ def _calculate_EMC_for_drying_phase(self) -> np.ndarray: From Van Wagner and Pickett (1985), Page 5: Equation 4, and Step 4. Returns: - The Equilibrium Moisture Content for the drying phase (E_d).Array + The Equilibrium Moisture Content for the drying phase (E_d). Array shape matches the input cube data shape. Values are in moisture content units (dimensionless). """