# (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.
"""Module containing lapse rate calculation plugins."""
from typing import Optional, Tuple
import iris
import numpy as np
from iris.cube import Cube
from iris.exceptions import CoordinateNotFoundError
from numpy import ndarray
from improver import BasePlugin, PostProcessingPlugin
from improver.constants import DALR, ELR
from improver.metadata.utilities import (
create_new_diagnostic_cube,
generate_mandatory_attributes,
)
from improver.utilities import mathematical_operations, neighbourhood_tools
from improver.utilities.cube_checker import spatial_coords_match
from improver.utilities.cube_manipulation import (
enforce_coordinate_ordering,
get_dim_coord_names,
)
[docs]
def compute_lapse_rate_adjustment(
lapse_rate: np.ndarray, orog_diff: np.ndarray, max_orog_diff_limit: float = 50
) -> np.ndarray:
"""Compute the lapse rate adjustment i.e. the lapse rate multiplied by the
relevant orographic difference. The lapse rate is assumed to be appropriate for a
fixed vertical displacement between the source and destination orographies.
If the the vertical displacement is greater than the limit specified,
further vertical ascent or descent is assumed to follow the environmental
lapse rate (also known as standard atmosphere lapse rate). Note that this is an
extension of Sheridan et al., 2018, which applies this vertical displacement limit
for positive lapse rates only.
For the specific case of a deep unresolved valley with a positive lapse rate at
the altitude of the source orography, the atmosphere can be imagined to be
compartmentalised into two regions. Firstly, a cold pool section that extends below
the altitude of the source orography by the value given by the max orography
difference specified (70 m in Sheridan et al., 2018) and secondly, a standard
atmosphere section for the remaining orographic difference. The total lapse rate
adjustment is the sum of the contributions from these two vertical sections.
References:
Sheridan, P., S. Vosper, and S. Smith, 2018: A Physically Based Algorithm for
Downscaling Temperature in Complex Terrain. J. Appl. Meteor. Climatol.,
57, 1907–1929, https://doi.org/10.1175/JAMC-D-17-0140.1.
Args:
lapse_rate: Array containing lapse rate in units of K/m.
orog_diff: Array containing the difference in orography
(destination orography minus source orography) in metres.
max_orog_diff_limit: Maximum vertical displacement in metres to be corrected
using the lapse rate provided. Vertical displacement in excess of this
value will be corrected using the environmental lapse rate (also known
as standard atmosphere lapse rate). This defaults to 50.
Sheridan et al. use 70 m. As lapse rate adjustment could be performed
both for gridded data and for site data in sequence, the adjustments
could accumulate. To limit the possible cumulative effect from multiple
lapse rate corrections, a default lower than 70m has been chosen.
Returns:
The vertical lapse rate adjustment to be applied to correct a
temperature forecast in units of K.
"""
orog_diff = np.broadcast_to(orog_diff, lapse_rate.shape).copy()
orig_orog_diff = orog_diff.copy()
# Constraint if the orographic difference is either greater than the max allowed
# orographic difference (e.g. an unresolved hilltop) or less than the negative of
# the max allowed orographic difference (e.g. an unresolved valley).
condition1 = orog_diff > max_orog_diff_limit
condition2 = orog_diff < -max_orog_diff_limit
orog_diff[condition1] = max_orog_diff_limit
orog_diff[condition2] = -max_orog_diff_limit
vertical_adjustment = np.multiply(orog_diff, lapse_rate)
# Compute an additional lapse rate adjustment for points with an absolute
# orographic difference greater than the maximum allowed.
orig_orog_diff[condition1] = np.clip(
orig_orog_diff[condition1] - max_orog_diff_limit, 0, None
)
orig_orog_diff[condition2] = np.clip(
orig_orog_diff[condition2] + max_orog_diff_limit, None, 0
)
# Assume the Environmental Lapse Rate (also known as Standard Atmosphere
# Lapse Rate).
vertical_adjustment[condition1] += np.multiply(orig_orog_diff[condition1], ELR)
vertical_adjustment[condition2] += np.multiply(orig_orog_diff[condition2], ELR)
return vertical_adjustment
[docs]
class ApplyGriddedLapseRate(PostProcessingPlugin):
"""Class to apply a lapse rate adjustment to a temperature data forecast"""
[docs]
@staticmethod
def _check_dim_coords(temperature: Cube, lapse_rate: Cube) -> None:
"""Throw an error if the dimension coordinates are not the same for
temperature and lapse rate cubes
Args:
temperature
lapse_rate
"""
for crd in temperature.coords(dim_coords=True):
try:
if crd != lapse_rate.coord(crd.name()):
raise ValueError(
'Lapse rate cube coordinate "{}" does not match '
"temperature cube coordinate".format(crd.name())
)
except CoordinateNotFoundError:
raise ValueError(
"Lapse rate cube has no coordinate " '"{}"'.format(crd.name())
)
[docs]
def _calc_orog_diff(self, source_orog: Cube, dest_orog: Cube) -> Cube:
"""Get difference in orography heights, in metres
Args:
source_orog:
2D cube of source orography heights (units modified in place)
dest_orog:
2D cube of destination orography heights (units modified in
place)
Returns:
The difference cube
"""
source_orog.convert_units("m")
dest_orog.convert_units("m")
orog_diff = next(dest_orog.slices(self.xy_coords)) - next(
source_orog.slices(self.xy_coords)
)
return orog_diff
[docs]
def process(
self, temperature: Cube, lapse_rate: Cube, source_orog: Cube, dest_orog: Cube
) -> Cube:
"""Applies lapse rate correction to temperature forecast. All cubes'
units are modified in place.
Args:
temperature:
Input temperature field to be adjusted
lapse_rate:
Cube of pre-calculated lapse rates
source_orog:
2D cube of source orography heights
dest_orog:
2D cube of destination orography heights
Returns:
Lapse-rate adjusted temperature field, in Kelvin
"""
lapse_rate.convert_units("K m-1")
self.xy_coords = [lapse_rate.coord(axis="y"), lapse_rate.coord(axis="x")]
self._check_dim_coords(temperature, lapse_rate)
if not spatial_coords_match([temperature, source_orog]):
raise ValueError(
"Source orography spatial coordinates do not match temperature grid"
)
if not spatial_coords_match([temperature, dest_orog]):
raise ValueError(
"Destination orography spatial coordinates do not match "
"temperature grid"
)
orog_diff = self._calc_orog_diff(source_orog, dest_orog)
adjusted_temperature = []
for lr_slice, t_slice in zip(
lapse_rate.slices(self.xy_coords), temperature.slices(self.xy_coords)
):
newcube = t_slice.copy()
newcube.convert_units("K")
newcube.data += compute_lapse_rate_adjustment(lr_slice.data, orog_diff.data)
adjusted_temperature.append(newcube)
return iris.cube.CubeList(adjusted_temperature).merge_cube()
[docs]
class LapseRate(BasePlugin):
"""
Plugin to calculate the lapse rate from orography and temperature
cubes.
References:
The method applied here is based on the method used in the 2010 paper:
https://rmets.onlinelibrary.wiley.com/doi/abs/10.1002/met.177
Code methodology:
1) Apply land/sea mask to temperature and orography datasets. Mask sea
points as NaN since image processing module does not recognise Numpy
masks.
2) Creates "views" of both datasets, where each view represents a
neighbourhood of points. To do this, each array is padded with
NaN values to a width of half the neighbourhood size.
3) For all the stored orography neighbourhoods - take the neighbours around
the central point and create a mask where the height difference from
the central point is greater than 35m.
4) Loop through array of neighbourhoods and take the height and temperature
of all grid points and calculate the
temperature/height gradient = lapse rate
5) Constrain the returned lapse rates between min_lapse_rate and
max_lapse_rate. These default to > DALR and < -3.0*DALR but are user
configurable
"""
[docs]
def __init__(
self,
max_height_diff: float = 35,
nbhood_radius: int = 7,
max_lapse_rate: float = -3 * DALR,
min_lapse_rate: float = DALR,
) -> None:
"""
The class is called with the default constraints for the processing
code.
Args:
max_height_diff:
Maximum allowable height difference between the central point
and points in the neighbourhood over which the lapse rate will
be calculated (metres).
The default value of 35m is from the referenced paper.
nbhood_radius:
Radius of neighbourhood around each point. The neighbourhood
will be a square array with side length 2*nbhood_radius + 1.
The default value of 7 is from the referenced paper.
max_lapse_rate:
Maximum lapse rate allowed.
min_lapse_rate:
Minimum lapse rate allowed.
"""
self.max_height_diff = max_height_diff
self.nbhood_radius = nbhood_radius
self.max_lapse_rate = max_lapse_rate
self.min_lapse_rate = min_lapse_rate
if self.max_lapse_rate < self.min_lapse_rate:
msg = "Maximum lapse rate is less than minimum lapse rate"
raise ValueError(msg)
if self.nbhood_radius < 0:
msg = "Neighbourhood radius is less than zero"
raise ValueError(msg)
if self.max_height_diff < 0:
msg = "Maximum height difference is less than zero"
raise ValueError(msg)
# nbhood_size=3 corresponds to a 3x3 array centred on the
# central point.
self.nbhood_size = int((2 * nbhood_radius) + 1)
# Used in the neighbourhood checks, ensures that the center
# of the array is non NaN.
self.ind_central_point = self.nbhood_size // 2
def __repr__(self) -> str:
"""Represent the configured plugin instance as a string."""
desc = (
"<LapseRate: max_height_diff: {}, nbhood_radius: {},"
"max_lapse_rate: {}, min_lapse_rate: {}>".format(
self.max_height_diff,
self.nbhood_radius,
self.max_lapse_rate,
self.min_lapse_rate,
)
)
return desc
[docs]
def _create_windows(self, temp: ndarray, orog: ndarray) -> Tuple[ndarray, ndarray]:
"""Uses neighbourhood tools to pad and generate rolling windows
of the temp and orog datasets.
Args:
temp:
2D array (single realization) of temperature data, in Kelvin
orog:
2D array of orographies, in metres
Returns:
- Rolling windows of the padded temperature dataset.
- Rolling windows of the padded orography dataset.
"""
window_shape = (self.nbhood_size, self.nbhood_size)
orog_windows = neighbourhood_tools.pad_and_roll(
orog, window_shape, mode="constant", constant_values=np.nan
)
temp_windows = neighbourhood_tools.pad_and_roll(
temp, window_shape, mode="constant", constant_values=np.nan
)
return temp_windows, orog_windows
[docs]
def _generate_lapse_rate_array(
self,
temperature_data: ndarray,
orography_data: ndarray,
land_sea_mask_data: ndarray,
) -> ndarray:
"""
Calculate lapse rates and apply filters
Args:
temperature_data:
2D array (single realization) of temperature data, in Kelvin
orography_data:
2D array of orographies, in metres
land_sea_mask_data:
2D land-sea mask
Returns:
Lapse rate values
"""
# Fill sea points with NaN values.
temperature_data = np.where(land_sea_mask_data, temperature_data, np.nan)
# Preallocate output array
lapse_rate_array = np.empty_like(temperature_data, dtype=np.float32)
# Pads the data with nans and generates masked windows representing
# a neighbourhood for each point.
temp_nbhood_window, orog_nbhood_window = self._create_windows(
temperature_data, orography_data
)
# Zips together the windows for temperature and orography
# then finds the gradient of the surface temperature with
# orography height - i.e. lapse rate.
cnpt = self.ind_central_point
axis = (-2, -1)
for lapse, temp, orog in zip(
lapse_rate_array, temp_nbhood_window, orog_nbhood_window
):
# height_diff_mask is True for points where the height
# difference between the central points and its
# neighbours is greater then max_height_diff.
orog_centre = orog[..., cnpt : cnpt + 1, cnpt : cnpt + 1]
height_diff_mask = np.abs(orog - orog_centre) > self.max_height_diff
temp = np.where(height_diff_mask, np.nan, temp)
# Places NaNs in orog to match temp
orog = np.where(np.isnan(temp), np.nan, orog)
grad = mathematical_operations.fast_linear_fit(
orog, temp, axis=axis, gradient_only=True, with_nan=True
)
# Checks that the standard deviations are not 0
# i.e. there is some variance to fit a gradient to.
tempcheck = np.isclose(np.nanstd(temp, axis=axis), 0)
orogcheck = np.isclose(np.nanstd(orog, axis=axis), 0)
# checks that our central point in the neighbourhood
# is not nan
temp_nan_check = np.isnan(temp[..., cnpt, cnpt])
dalr_mask = tempcheck | orogcheck | temp_nan_check | np.isnan(grad)
grad[dalr_mask] = DALR
lapse[...] = grad
# Enforce upper and lower limits on lapse rate values.
lapse_rate_array = lapse_rate_array.clip(
self.min_lapse_rate, self.max_lapse_rate
)
return lapse_rate_array
[docs]
def process(
self,
temperature: Cube,
orography: Cube,
land_sea_mask: Cube,
model_id_attr: Optional[str] = None,
) -> Cube:
"""Calculates the lapse rate from the temperature and orography cubes.
Args:
temperature:
Cube of air temperatures (K).
orography:
Cube containing orography data (metres)
land_sea_mask:
Cube containing a binary land-sea mask. True for land-points
and False for Sea.
model_id_attr:
Name of the attribute used to identify the source model for
blending. This is inherited from the input temperature cube.
Returns:
Cube containing lapse rate (K m-1)
Raises
------
TypeError: If input cubes are not cubes
ValueError: If input cubes are the wrong units.
"""
if not isinstance(temperature, iris.cube.Cube):
msg = "Temperature input is not a cube, but {}"
raise TypeError(msg.format(type(temperature)))
if not isinstance(orography, iris.cube.Cube):
msg = "Orography input is not a cube, but {}"
raise TypeError(msg.format(type(orography)))
if not isinstance(land_sea_mask, iris.cube.Cube):
msg = "Land/Sea mask input is not a cube, but {}"
raise TypeError(msg.format(type(land_sea_mask)))
# Converts cube units.
temperature_cube = temperature.copy()
temperature_cube.convert_units("K")
orography.convert_units("metres")
# Extract x/y co-ordinates.
x_coord = temperature_cube.coord(axis="x").name()
y_coord = temperature_cube.coord(axis="y").name()
# Extract orography and land/sea mask data.
orography_data = next(orography.slices([y_coord, x_coord])).data
land_sea_mask_data = next(land_sea_mask.slices([y_coord, x_coord])).data
# Fill sea points with NaN values.
orography_data = np.where(land_sea_mask_data, orography_data, np.nan)
# Create list of arrays over "realization" coordinate
has_realization_dimension = False
original_dimension_order = None
if temperature_cube.coords("realization", dim_coords=True):
original_dimension_order = get_dim_coord_names(temperature_cube)
enforce_coordinate_ordering(temperature_cube, "realization")
temp_data_slices = temperature_cube.data
has_realization_dimension = True
else:
temp_data_slices = [temperature_cube.data]
# Calculate lapse rate for each realization
lapse_rate_data = []
for temperature_data in temp_data_slices:
lapse_rate_array = self._generate_lapse_rate_array(
temperature_data, orography_data, land_sea_mask_data
)
lapse_rate_data.append(lapse_rate_array)
lapse_rate_data = np.array(lapse_rate_data)
if not has_realization_dimension:
lapse_rate_data = np.squeeze(lapse_rate_data)
attributes = generate_mandatory_attributes(
[temperature], model_id_attr=model_id_attr
)
lapse_rate_cube = create_new_diagnostic_cube(
"air_temperature_lapse_rate",
"K m-1",
temperature_cube,
attributes,
data=lapse_rate_data,
)
if original_dimension_order:
enforce_coordinate_ordering(lapse_rate_cube, original_dimension_order)
return lapse_rate_cube