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 all 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
115 changes: 82 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: bool = False,
):
"""
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._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.
"""
self.correction_method = apply_additive_correction
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,11 @@ 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 common hour value for the
forecast_reference_time and single coordinate value for forecast_period. Then check
forecast Cube contains the same hour value for the forecast_reference_time and the
same forecast_period coordinate value.

Args:
forecast:
Cube containing forecast data to be bias-corrected.
Expand Down Expand Up @@ -339,16 +395,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.

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 @@ -362,36 +410,37 @@ def process(
filled using an appropriate fill value to leave the forecast data unchanged
in the masked areas.
SamGriffithsMO marked this conversation as resolved.
Show resolved Hide resolved

If no bias correction is provided, then the forecast is returned, unaltered.

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
30 changes: 5 additions & 25 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 All @@ -32,7 +32,7 @@ def process(
sensible post-bias correction.

Args:
input_cubes (iris.cube.Cube or list of iris.cube.Cube):
cubes (iris.cube.Cube or list of iris.cube.Cube):
A list of cubes containing:
- A Cube containing the forecast to be calibrated. The input format is expected
to be realizations.
Expand All @@ -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
)
67 changes: 0 additions & 67 deletions improver_tests/acceptance/test_apply_bias_correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,70 +134,3 @@ def test_fill_masked_bias_data(tmp_path):
]
run_cli(args)
acc.compare(output_path, kgo_path)


def test_no_bias_file(tmp_path):
"""
Test case where no bias values are passed in. Expected behaviour is to
return the forecast value.
"""
kgo_dir = acc.kgo_root() / "apply-bias-correction"
fcst_path = kgo_dir / "20220814T0300Z-PT0003H00M-wind_speed_at_10m.nc"
kgo_path = kgo_dir / "fcst_with_comment" / "kgo.nc"
output_path = tmp_path / "output.nc"
args = [
fcst_path,
"--output",
output_path,
]
with pytest.warns(UserWarning, match=".*no forecast_error.*"):
run_cli(args)
acc.compare(output_path, fcst_path, exclude_attributes="comment")
acc.compare(output_path, kgo_path)


def test_missing_fcst_file(tmp_path):
"""
Test case where no forecast value has been passed in. This should raise
a ValueError.
"""
kgo_dir = acc.kgo_root() / "apply-bias-correction"
bias_file_path = (
kgo_dir
/ "single_bias_file"
/ "bias_data"
/ "20220813T0300Z-PT0003H00M-wind_speed_at_10m.nc"
)
output_path = tmp_path / "output.nc"
args = [
bias_file_path,
"--output",
output_path,
]
with pytest.raises(ValueError, match="No forecast"):
run_cli(args)


def test_multiple_fcst_files(tmp_path):
"""
Test case where multiple forecast values are passed in. This should raise a
ValueError.
"""
kgo_dir = acc.kgo_root() / "apply-bias-correction"
fcst_path = kgo_dir / "20220814T0300Z-PT0003H00M-wind_speed_at_10m.nc"
bias_file_path = (
kgo_dir
/ "single_bias_file"
/ "bias_data"
/ "20220813T0300Z-PT0003H00M-wind_speed_at_10m.nc"
)
output_path = tmp_path / "output.nc"
args = [
fcst_path,
fcst_path,
bias_file_path,
"--output",
output_path,
]
with pytest.raises(ValueError, match="Multiple forecast"):
run_cli(args)
Loading
Loading