Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate CLI functionality: simple_bias_correction #2018

Merged
merged 9 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Meabh NicGuidhir <meabh.nicguidhir@metoffice.gov.uk> <meabh.nicguidhir@metoffice
Neil Crosswaite <neil.crosswaite@metoffice.gov.uk> <43375279+neilCrosswaite@users.noreply.github.com>
Paul Abernethy <paul.abernethy@metoffice.gov.uk> <paul.abernethy@metoffice.gov.uk>
Peter Jordan <peter.jordan@metoffice.gov.uk> <52462411+mo-peterjordan@users.noreply.github.com>
Sam Griffiths <sam.griffiths@metoffice.gov.uk> <122271903+SamGriffithsMO@users.noreply.github.com>
Shafiat Dewan <87321907+ShafiatDewan@users.noreply.github.com> <87321907+ShafiatDewan@users.noreply.github.com>
Shubhendra Singh Chauhan <withshubh@gmail.com> <withshubh@gmail.com>
Simon Jackson <simon.jackson@metoffice.gov.uk> <simon.jackson@metoffice.gov.uk>
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ below:
- Zhiliang Fan (Bureau of Meteorology, Australia)
- Ben Fitzpatrick (Met Office, UK)
- Tom Gale (Bureau of Meteorology, Australia)
- Sam Griffiths (Met Office, UK)
- Ben Hooper (Met Office, UK)
- Aaron Hopkinson (Met Office, UK)
- Kathryn Howard (Met Office, UK)
Expand Down
112 changes: 79 additions & 33 deletions improver/calibration/simple_bias_correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
# See LICENSE in the root of the repository for full licensing details.
"""Simple bias correction plugins."""

from typing import Dict, Optional
import warnings
from typing import Dict, Optional, Union

import iris
import numpy.ma as ma
from iris.cube import Cube, CubeList
from numpy import ndarray

from improver import BasePlugin
from improver.calibration import add_warning_comment, split_forecasts_and_bias_files
from improver.calibration.utilities import (
check_forecast_consistency,
create_unified_frt_coord,
Expand All @@ -23,6 +25,7 @@
create_new_diagnostic_cube,
generate_mandatory_attributes,
)
from improver.utilities.common_input_handle import as_cubelist
from improver.utilities.cube_manipulation import (
clip_cube_data,
collapsed,
Expand Down Expand Up @@ -248,11 +251,59 @@ class ApplyBiasCorrection(BasePlugin):
the specified bias values.
"""

def __init__(self):
def __init__(
self,
lower_bound: Optional[float] = None,
upper_bound: Optional[float] = None,
fill_masked_bias_values: Optional[bool] = False,
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved
):
"""
Initialise class for applying simple bias correction.

Args:
lower_bound:
A lower bound below which all values will be remapped to
after the bias correction step.
upper_bound:
An upper bound above which all values will be remapped to
after the bias correction step.
fill_masked_bias_values:
Flag to specify whether masked areas in the bias data
should be filled to an appropriate fill value.
"""
self.correction_method = apply_additive_correction
self._correction_method = apply_additive_correction
self._lower_bound = lower_bound
self._upper_bound = upper_bound
self._fill_masked_bias_values = fill_masked_bias_values

def _split_forecasts_and_bias(self, cubes: CubeList):
"""
Wrapper for the split_forecasts_and_bias_files function.

Args:
cubes:
Cubelist containing the input forecast and bias cubes.

Return:
- Cube containing the forecast data to be bias-corrected.
- Cubelist containing the bias data to use in bias-correction.
Or None if no bias data is provided.
"""
forecast_cube, bias_cubes = split_forecasts_and_bias_files(cubes)

# Check whether bias data supplied, if not then return unadjusted input cube.
# This behaviour is to allow spin-up of the bias-correction terms.
if not bias_cubes:
msg = (
"There are no forecast_error (bias) cubes provided for calibration. "
"The uncalibrated forecast will be returned."
)
warnings.warn(msg)
forecast_cube = add_warning_comment(forecast_cube)
return forecast_cube, None
else:
bias_cubes = as_cubelist(bias_cubes)
return forecast_cube, bias_cubes

def _get_mean_bias(self, bias_values: CubeList) -> Cube:
"""
Expand Down Expand Up @@ -302,6 +353,10 @@ def _check_forecast_bias_consistent(
"""Check that forecast and bias values are defined over the same
valid-hour and forecast-period.

Checks that between the bias_data Cubes there is a single coordinate forecast_reference_time
and single coordinate forecast_period. Then check forecast Cube contains the same single
coordinate forecast_reference_time and single coordinate forecast_period.

SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved
Args:
forecast:
Cube containing forecast data to be bias-corrected.
Expand Down Expand Up @@ -339,16 +394,8 @@ def _check_forecast_bias_consistent(
"Forecast period differ between forecast and bias datasets."
)

def process(
self,
forecast: Cube,
bias: CubeList,
lower_bound: Optional[float] = None,
upper_bound: Optional[float] = None,
fill_masked_bias_values: Optional[bool] = False,
) -> Cube:
"""
Apply bias correction using the specified bias values.
def process(self, *cubes: Union[Cube, CubeList],) -> Cube:
""" Split then apply bias correction using the specified bias values.
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved

Where the bias data is defined point-by-point, the bias-correction will also
be applied in this way enabling a form of statistical downscaling where coherent
Expand All @@ -363,35 +410,34 @@ def process(
in the masked areas.
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved

Args:
forecast:
The cube to which bias correction is to be applied.
bias:
The cubelist containing the bias values for which to use in
the bias correction.
lower_bound:
A lower bound below which all values will be remapped to
after the bias correction step.
upper_bound:
An upper bound above which all values will be remapped to
after the bias correction step.
fill_masked_bias_values:
Flag to specify whether masked areas in the bias data
should be filled to an appropriate fill value.
cubes:
A list of cubes containing:
- A Cube containing the forecast to be calibrated. The input format is expected
to be realizations.
- A cube or cubelist containing forecast bias data over a specified
set of forecast reference times. If a list of cubes is passed in, each cube
should represent the forecast error for a single forecast reference time; the
mean value will then be evaluated over the forecast_reference_time coordinate.
cpelley marked this conversation as resolved.
Show resolved Hide resolved
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved

Returns:
Bias corrected forecast cube.
"""
self._check_forecast_bias_consistent(forecast, bias)
bias = self._get_mean_bias(bias)
cubes = as_cubelist(*cubes)
forecast, bias_cubes = self._split_forecasts_and_bias(cubes)
if bias_cubes is None:
return forecast

self._check_forecast_bias_consistent(forecast, bias_cubes)
bias = self._get_mean_bias(bias_cubes)

corrected_forecast = forecast.copy()
corrected_forecast.data = self.correction_method(
forecast, bias, fill_masked_bias_values
corrected_forecast.data = self._correction_method(
forecast, bias, self._fill_masked_bias_values
)

if (lower_bound is not None) or (upper_bound is not None):
if (self._lower_bound is not None) or (self._upper_bound is not None):
corrected_forecast = clip_cube_data(
corrected_forecast, lower_bound, upper_bound
corrected_forecast, self._lower_bound, self._upper_bound
)

return corrected_forecast
28 changes: 4 additions & 24 deletions improver/cli/apply_bias_correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
@cli.clizefy
@cli.with_output
def process(
*input_cubes: cli.inputcube,
*cubes: cli.inputcube,
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved
lower_bound: float = None,
upper_bound: float = None,
fill_masked_bias_data: bool = False,
Expand Down Expand Up @@ -52,28 +52,8 @@ def process(
iris.cube.Cube:
Forecast cube with bias correction applied on a per member basis.
"""
import warnings

import iris

from improver.calibration import add_warning_comment, split_forecasts_and_bias_files
from improver.calibration.simple_bias_correction import ApplyBiasCorrection

forecast_cube, bias_cubes = split_forecasts_and_bias_files(input_cubes)

# Check whether bias data supplied, if not then return unadjusted input cube.
# This behaviour is to allow spin-up of the bias-correction terms.
if not bias_cubes:
msg = (
"There are no forecast_error (bias) cubes provided for calibration. "
"The uncalibrated forecast will be returned."
)
warnings.warn(msg)
forecast_cube = add_warning_comment(forecast_cube)
return forecast_cube
else:
bias_cubes = iris.cube.CubeList(bias_cubes)
plugin = ApplyBiasCorrection()
return plugin.process(
forecast_cube, bias_cubes, lower_bound, upper_bound, fill_masked_bias_data
)
return ApplyBiasCorrection(lower_bound, upper_bound, fill_masked_bias_data).process(
*cubes
)
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# See LICENSE in the root of the repository for full licensing details.

from datetime import datetime, timedelta
from unittest.mock import patch, sentinel

import iris
import numpy as np
Expand Down Expand Up @@ -155,7 +156,7 @@ def test_apply_additive_correction(
def test__init__():
"""Test that the class functions are set to the expected values."""
plugin = ApplyBiasCorrection()
assert plugin.correction_method == apply_additive_correction
assert plugin._correction_method == apply_additive_correction


@pytest.mark.parametrize("single_input_frt", (False, True))
Expand Down Expand Up @@ -257,7 +258,7 @@ def test_inconsistent_bias_forecast_inputs(forecast_cube, num_bias_inputs):
@pytest.mark.parametrize("num_bias_inputs", (1, 30))
@pytest.mark.parametrize("single_input_frt", (False, True))
@pytest.mark.parametrize("lower_bound", (None, 1))
@pytest.mark.parametrize("upper_bound", (None, 5))
@pytest.mark.parametrize("upper_bound", (None, 4))
@pytest.mark.parametrize("masked_input_data", (True, False))
@pytest.mark.parametrize("masked_bias_data", (True, False))
@pytest.mark.parametrize("fill_masked_bias_data", (True, False))
Expand All @@ -282,12 +283,12 @@ def test_process(
forecast_cube.data, dtype=forecast_cube.data.dtype
)
forecast_cube.data.mask = MASK
result = ApplyBiasCorrection().process(
forecast_cube,
input_bias_cubelist,
result = ApplyBiasCorrection(
lower_bound=lower_bound,
upper_bound=upper_bound,
Copy link
Contributor Author

@SamGriffithsMO SamGriffithsMO Jul 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upper_bound was defined, but has a higher value than the test forecast. I have updated the parameter to make the test fail when upper_bound is not passed as an argument. (Note - highest values in TEST_FCST_DATA ~4.3)

fill_masked_bias_values=fill_masked_bias_data,
)
).process(forecast_cube, input_bias_cubelist)

expected = TEST_FCST_DATA - MEAN_BIAS_DATA
if fill_masked_bias_data and masked_bias_data:
expected = np.where(MASK, TEST_FCST_DATA, expected)
Expand All @@ -314,3 +315,19 @@ def test_process(
# Check coords and attributes are consistent
assert result.coords() == forecast_cube.coords()
assert result.attributes == forecast_cube.attributes


class HaltExecution(Exception):
pass


@patch("improver.calibration.simple_bias_correction.as_cubelist")
def test_as_cubelist_called(mock_as_cubelist):
mock_as_cubelist.side_effect = HaltExecution
try:
ApplyBiasCorrection()(sentinel.cube1, sentinel.cube2, sentinel.cube3)
except HaltExecution:
pass
mock_as_cubelist.assert_called_once_with(
sentinel.cube1, sentinel.cube2, sentinel.cube3
)
Loading