|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +# ----------------------------------------------------------------------------- |
| 3 | +# (C) British Crown Copyright 2017-2019 Met Office. |
| 4 | +# All rights reserved. |
| 5 | +# |
| 6 | +# Redistribution and use in source and binary forms, with or without |
| 7 | +# modification, are permitted provided that the following conditions are met: |
| 8 | +# |
| 9 | +# * Redistributions of source code must retain the above copyright notice, this |
| 10 | +# list of conditions and the following disclaimer. |
| 11 | +# |
| 12 | +# * Redistributions in binary form must reproduce the above copyright notice, |
| 13 | +# this list of conditions and the following disclaimer in the documentation |
| 14 | +# and/or other materials provided with the distribution. |
| 15 | +# |
| 16 | +# * Neither the name of the copyright holder nor the names of its |
| 17 | +# contributors may be used to endorse or promote products derived from |
| 18 | +# this software without specific prior written permission. |
| 19 | +# |
| 20 | +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| 21 | +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| 22 | +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| 23 | +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE |
| 24 | +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| 25 | +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| 26 | +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| 27 | +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| 28 | +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| 29 | +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
| 30 | +# POSSIBILITY OF SUCH DAMAGE. |
| 31 | +""" |
| 32 | +This module defines plugins used to create nowcast extrapolation forecasts. |
| 33 | +""" |
| 34 | +import warnings |
| 35 | +import numpy as np |
| 36 | + |
| 37 | +from iris.coords import AuxCoord |
| 38 | +from iris.exceptions import CoordinateNotFoundError, InvalidCubeError |
| 39 | + |
| 40 | +from improver.utilities.cube_metadata import ( |
| 41 | + amend_metadata, add_history_attribute) |
| 42 | +from improver.nowcasting.optical_flow import check_input_coords |
| 43 | + |
| 44 | + |
| 45 | +class AdvectField(object): |
| 46 | + """ |
| 47 | + Class to advect a 2D spatial field given velocities along the two vector |
| 48 | + dimensions |
| 49 | + """ |
| 50 | + |
| 51 | + def __init__(self, vel_x, vel_y, metadata_dict=None): |
| 52 | + """ |
| 53 | + Initialises the plugin. Velocities are expected to be on a regular |
| 54 | + grid (such that grid spacing in metres is the same at all points in |
| 55 | + the domain). |
| 56 | +
|
| 57 | + Args: |
| 58 | + vel_x (iris.cube.Cube): |
| 59 | + Cube containing a 2D array of velocities along the x |
| 60 | + coordinate axis |
| 61 | + vel_y (iris.cube.Cube): |
| 62 | + Cube containing a 2D array of velocities along the y |
| 63 | + coordinate axis |
| 64 | +
|
| 65 | + Keyword Args: |
| 66 | + metadata_dict (dict): |
| 67 | + Dictionary containing information for amending the metadata |
| 68 | + of the output cube. Please see the |
| 69 | + :func:`improver.utilities.cube_metadata.amend_metadata` |
| 70 | + for information regarding the allowed contents of the metadata |
| 71 | + dictionary. |
| 72 | + """ |
| 73 | + |
| 74 | + # check each input velocity cube has precisely two non-scalar |
| 75 | + # dimension coordinates (spatial x/y) |
| 76 | + check_input_coords(vel_x) |
| 77 | + check_input_coords(vel_y) |
| 78 | + |
| 79 | + # check input velocity cubes have the same spatial coordinates |
| 80 | + if (vel_x.coord(axis="x") != vel_y.coord(axis="x") or |
| 81 | + vel_x.coord(axis="y") != vel_y.coord(axis="y")): |
| 82 | + raise InvalidCubeError("Velocity cubes on unmatched grids") |
| 83 | + |
| 84 | + vel_x.convert_units('m s-1') |
| 85 | + vel_y.convert_units('m s-1') |
| 86 | + |
| 87 | + self.vel_x = vel_x |
| 88 | + self.vel_y = vel_y |
| 89 | + |
| 90 | + self.x_coord = vel_x.coord(axis="x") |
| 91 | + self.y_coord = vel_x.coord(axis="y") |
| 92 | + |
| 93 | + # Initialise metadata dictionary. |
| 94 | + if metadata_dict is None: |
| 95 | + metadata_dict = {} |
| 96 | + self.metadata_dict = metadata_dict |
| 97 | + |
| 98 | + def __repr__(self): |
| 99 | + """Represent the plugin instance as a string.""" |
| 100 | + result = ('<AdvectField: vel_x={}, vel_y={}, ' |
| 101 | + 'metadata_dict={}>'.format( |
| 102 | + self.vel_x.name(), self.vel_y.name(), |
| 103 | + self.metadata_dict)) |
| 104 | + return result |
| 105 | + |
| 106 | + @staticmethod |
| 107 | + def _increment_output_array(indata, outdata, cond, xdest_grid, ydest_grid, |
| 108 | + xsrc_grid, ysrc_grid, x_weight, y_weight): |
| 109 | + """ |
| 110 | + Calculate and add contribution to the advected array from one source |
| 111 | + grid point, for all points where boolean condition "cond" is valid. |
| 112 | +
|
| 113 | + Args: |
| 114 | + indata (numpy.ndarray): |
| 115 | + 2D numpy array of source data to be advected |
| 116 | + outdata (numpy.ndarray): |
| 117 | + 2D numpy array for advected output, modified in place by |
| 118 | + this method (is both input and output). |
| 119 | + cond (numpy.ndarray): |
| 120 | + 2D boolean mask of points to be processed |
| 121 | + xdest_grid (numpy.ndarray): |
| 122 | + Integer x-coordinates of all points on destination grid |
| 123 | + ydest_grid (numpy.ndarray): |
| 124 | + Integer y-coordinates of all points on destination grid |
| 125 | + xsrc_grid (numpy.ndarray): |
| 126 | + Integer x-coordinates of all points on source grid |
| 127 | + ysrc_grid (numpy.ndarray): |
| 128 | + Integer y-coordinates of all points on source grid |
| 129 | + x_weight (numpy.ndarray): |
| 130 | + Fractional contribution to destination grid of source data |
| 131 | + advected along the x-axis. Positive definite. |
| 132 | + y_weight (numpy.ndarray): |
| 133 | + Fractional contribution to destination grid of source data |
| 134 | + advected along the y-axis. Positive definite. |
| 135 | + """ |
| 136 | + xdest = xdest_grid[cond] |
| 137 | + ydest = ydest_grid[cond] |
| 138 | + xsrc = xsrc_grid[cond] |
| 139 | + ysrc = ysrc_grid[cond] |
| 140 | + outdata[ydest, xdest] += ( |
| 141 | + indata[ysrc, xsrc]*x_weight[ydest, xdest]*y_weight[ydest, xdest]) |
| 142 | + |
| 143 | + def _advect_field(self, data, grid_vel_x, grid_vel_y, timestep): |
| 144 | + """ |
| 145 | + Performs a dimensionless grid-based extrapolation of spatial data |
| 146 | + using advection velocities via a backwards method. Points where data |
| 147 | + cannot be extrapolated (ie the source is out of bounds) are given a |
| 148 | + fill value of np.nan and masked. |
| 149 | +
|
| 150 | + Args: |
| 151 | + data (numpy.ndarray or numpy.ma.MaskedArray): |
| 152 | + 2D numpy data array to be advected |
| 153 | + grid_vel_x (numpy.ndarray): |
| 154 | + Velocity in the x direction (in grid points per second) |
| 155 | + grid_vel_y (numpy.ndarray): |
| 156 | + Velocity in the y direction (in grid points per second) |
| 157 | + timestep (int): |
| 158 | + Advection time step in seconds |
| 159 | +
|
| 160 | + Returns: |
| 161 | + adv_field (numpy.ma.MaskedArray): |
| 162 | + 2D float array of advected data values with masked "no data" |
| 163 | + regions |
| 164 | + """ |
| 165 | + # Cater for special case where timestep (integer) is 0 |
| 166 | + if timestep == 0: |
| 167 | + return data |
| 168 | + |
| 169 | + # Initialise advected field with np.nan |
| 170 | + adv_field = np.full(data.shape, np.nan, dtype=np.float32) |
| 171 | + |
| 172 | + # Set up grids of data coordinates (meshgrid inverts coordinate order) |
| 173 | + ydim, xdim = data.shape |
| 174 | + (xgrid, ygrid) = np.meshgrid(np.arange(xdim), |
| 175 | + np.arange(ydim)) |
| 176 | + |
| 177 | + # For each grid point on the output field, trace its (x,y) "source" |
| 178 | + # location backwards using advection velocities. The source location |
| 179 | + # is generally fractional: eg with advection velocities of 0.5 grid |
| 180 | + # squares per second, the value at [2, 2] is represented by the value |
| 181 | + # that was at [1.5, 1.5] 1 second ago. |
| 182 | + xsrc_point_frac = -grid_vel_x * timestep + xgrid.astype(np.float32) |
| 183 | + ysrc_point_frac = -grid_vel_y * timestep + ygrid.astype(np.float32) |
| 184 | + |
| 185 | + # For all the points where fractional source coordinates are within |
| 186 | + # the bounds of the field, set the output field to 0 |
| 187 | + def point_in_bounds(x, y, nx, ny): |
| 188 | + """Check point (y, x) lies within defined bounds""" |
| 189 | + return (x >= 0.) & (x < nx) & (y >= 0.) & (y < ny) |
| 190 | + |
| 191 | + cond_pt = point_in_bounds(xsrc_point_frac, ysrc_point_frac, xdim, ydim) |
| 192 | + adv_field[cond_pt] = 0 |
| 193 | + |
| 194 | + # Find the integer points surrounding the fractional source coordinates |
| 195 | + xsrc_point_lower = xsrc_point_frac.astype(int) |
| 196 | + ysrc_point_lower = ysrc_point_frac.astype(int) |
| 197 | + x_points = [xsrc_point_lower, xsrc_point_lower + 1] |
| 198 | + y_points = [ysrc_point_lower, ysrc_point_lower + 1] |
| 199 | + |
| 200 | + # Calculate the distance-weighted fractional contribution of points |
| 201 | + # surrounding the source coordinates |
| 202 | + x_weight_upper = xsrc_point_frac - xsrc_point_lower.astype(float) |
| 203 | + y_weight_upper = ysrc_point_frac - ysrc_point_lower.astype(float) |
| 204 | + x_weights = np.array([1. - x_weight_upper, x_weight_upper], |
| 205 | + dtype=np.float32) |
| 206 | + y_weights = np.array([1. - y_weight_upper, y_weight_upper], |
| 207 | + dtype=np.float32) |
| 208 | + |
| 209 | + # Check whether the input data is masked - if so substitute NaNs for |
| 210 | + # the masked data. Note there is an implicit type conversion here: if |
| 211 | + # data is of integer type this unmasking will convert it to float. |
| 212 | + if isinstance(data, np.ma.MaskedArray): |
| 213 | + data = np.where(data.mask, np.nan, data.data) |
| 214 | + |
| 215 | + # Advect data from each of the four source points onto the output grid |
| 216 | + for xpt, xwt in zip(x_points, x_weights): |
| 217 | + for ypt, ywt in zip(y_points, y_weights): |
| 218 | + cond = point_in_bounds(xpt, ypt, xdim, ydim) & cond_pt |
| 219 | + self._increment_output_array( |
| 220 | + data, adv_field, cond, xgrid, ygrid, xpt, ypt, xwt, ywt) |
| 221 | + |
| 222 | + # Replace NaNs with a mask |
| 223 | + adv_field = np.ma.masked_where(~np.isfinite(adv_field), adv_field) |
| 224 | + |
| 225 | + return adv_field |
| 226 | + |
| 227 | + def process(self, cube, timestep): |
| 228 | + """ |
| 229 | + Extrapolates input cube data and updates validity time. The input |
| 230 | + cube should have precisely two non-scalar dimension coordinates |
| 231 | + (spatial x/y), and is expected to be in a projection such that grid |
| 232 | + spacing is the same (or very close) at all points within the spatial |
| 233 | + domain. The input cube should also have a "time" coordinate. |
| 234 | +
|
| 235 | + Args: |
| 236 | + cube (iris.cube.Cube): |
| 237 | + The 2D cube containing data to be advected |
| 238 | + timestep (datetime.timedelta): |
| 239 | + Advection time step |
| 240 | +
|
| 241 | + Returns: |
| 242 | + advected_cube (iris.cube.Cube): |
| 243 | + New cube with updated time and extrapolated data. New data |
| 244 | + are filled with np.nan and masked where source data were |
| 245 | + out of bounds (ie where data could not be advected from outside |
| 246 | + the cube domain). |
| 247 | + """ |
| 248 | + # check that the input cube has precisely two non-scalar dimension |
| 249 | + # coordinates (spatial x/y) and a scalar time coordinate |
| 250 | + check_input_coords(cube, require_time=True) |
| 251 | + |
| 252 | + # check spatial coordinates match those of plugin velocities |
| 253 | + if (cube.coord(axis="x") != self.x_coord or |
| 254 | + cube.coord(axis="y") != self.y_coord): |
| 255 | + raise InvalidCubeError("Input data grid does not match advection " |
| 256 | + "velocities") |
| 257 | + |
| 258 | + # derive velocities in "grid squares per second" |
| 259 | + def grid_spacing(coord): |
| 260 | + """Calculate grid spacing along a given spatial axis""" |
| 261 | + new_coord = coord.copy() |
| 262 | + new_coord.convert_units('m') |
| 263 | + return np.float32(np.diff((new_coord).points)[0]) |
| 264 | + |
| 265 | + grid_vel_x = self.vel_x.data / grid_spacing(cube.coord(axis="x")) |
| 266 | + grid_vel_y = self.vel_y.data / grid_spacing(cube.coord(axis="y")) |
| 267 | + |
| 268 | + # raise a warning if data contains unmasked NaNs |
| 269 | + nan_count = np.count_nonzero(~np.isfinite(cube.data)) |
| 270 | + if nan_count > 0: |
| 271 | + warnings.warn("input data contains unmasked NaNs") |
| 272 | + |
| 273 | + # perform advection and create output cube |
| 274 | + advected_data = self._advect_field(cube.data, grid_vel_x, grid_vel_y, |
| 275 | + timestep.total_seconds()) |
| 276 | + advected_cube = cube.copy(data=advected_data) |
| 277 | + |
| 278 | + # increment output cube time and add a "forecast_period" coordinate |
| 279 | + original_datetime, = \ |
| 280 | + (cube.coord("time").units).num2date(cube.coord("time").points) |
| 281 | + new_datetime = original_datetime + timestep |
| 282 | + |
| 283 | + new_time = (cube.coord("time").units).date2num(new_datetime) |
| 284 | + |
| 285 | + advected_cube.coord("time").points = new_time |
| 286 | + advected_cube.coord("time").convert_units( |
| 287 | + "seconds since 1970-01-01 00:00:00") |
| 288 | + advected_cube.coord("time").points = ( |
| 289 | + np.around(advected_cube.coord("time").points).astype(np.int64)) |
| 290 | + |
| 291 | + try: |
| 292 | + advected_cube.coord("forecast_reference_time").convert_units( |
| 293 | + "seconds since 1970-01-01 00:00:00") |
| 294 | + except CoordinateNotFoundError: |
| 295 | + frt_coord = cube.coord("time").copy() |
| 296 | + frt_coord.rename("forecast_reference_time") |
| 297 | + advected_cube.add_aux_coord(frt_coord) |
| 298 | + advected_cube.coord("forecast_reference_time").convert_units( |
| 299 | + "seconds since 1970-01-01 00:00:00") |
| 300 | + |
| 301 | + frt_points = np.around( |
| 302 | + advected_cube.coord("forecast_reference_time").points |
| 303 | + ).astype(np.int64) |
| 304 | + advected_cube.coord("forecast_reference_time").points = frt_points |
| 305 | + |
| 306 | + forecast_period_seconds = np.int32(timestep.total_seconds()) |
| 307 | + forecast_period_coord = AuxCoord(forecast_period_seconds, |
| 308 | + standard_name="forecast_period", |
| 309 | + units="s") |
| 310 | + try: |
| 311 | + advected_cube.remove_coord("forecast_period") |
| 312 | + except CoordinateNotFoundError: |
| 313 | + pass |
| 314 | + advected_cube.add_aux_coord(forecast_period_coord) |
| 315 | + |
| 316 | + # Modify the source attribute to describe the advected field as a |
| 317 | + # Nowcast |
| 318 | + if "institution" in advected_cube.attributes.keys(): |
| 319 | + advected_cube.attributes["source"] = ( |
| 320 | + "{} Nowcast".format(advected_cube.attributes["institution"])) |
| 321 | + else: |
| 322 | + advected_cube.attributes["source"] = "Nowcast" |
| 323 | + add_history_attribute(advected_cube, "Nowcast") |
| 324 | + |
| 325 | + advected_cube = amend_metadata(advected_cube, **self.metadata_dict) |
| 326 | + return advected_cube |
0 commit comments