Skip to content

Commit aa7388f

Browse files
fionaRustgavinevans
authored andcommitted
Moved AdvectField plugin into a new module for generating nowcast forecasts. (#836)
1 parent a76f767 commit aa7388f

File tree

6 files changed

+332
-291
lines changed

6 files changed

+332
-291
lines changed

bin/improver-nowcast-extrapolate

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import numpy as np
3838

3939
from iris import Constraint
4040
from improver.argparser import ArgParser
41-
from improver.nowcasting.optical_flow import AdvectField
41+
from improver.nowcasting.forecasting import AdvectField
4242
from improver.nowcasting.utilities import ApplyOrographicEnhancement
4343
from improver.utilities.filename import generate_file_name
4444
from improver.utilities.load import load_cube

bin/improver-nowcast-optical-flow

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import numpy as np
3939
import json
4040

4141
from improver.argparser import ArgParser
42-
from improver.nowcasting.optical_flow import AdvectField, OpticalFlow
42+
from improver.nowcasting.optical_flow import OpticalFlow
43+
from improver.nowcasting.forecasting import AdvectField
4344
from improver.nowcasting.utilities import ApplyOrographicEnhancement
4445
from improver.utilities.cube_manipulation import merge_cubes, strip_var_names
4546
from improver.utilities.filename import generate_file_name
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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

Comments
 (0)