def test_radii_varying_with_lead_time(self): """ Test that a cube is returned when the radius varies with lead time. """ cube = set_up_cube(num_time_points=3) iris.util.promote_aux_coord_to_dim_coord(cube, "time") time_points = cube.coord("time").points fp_points = [2, 3, 4] cube = add_forecast_reference_time_and_forecast_period( cube, time_point=time_points, fp_point=fp_points) radii = [10000, 20000, 30000] lead_times = [2, 3, 4] neighbourhood_method = "circular" plugin = NBHood(neighbourhood_method, radii, lead_times) result = plugin.process(cube) self.assertIsInstance(result, Cube)
def test_source_realizations(self): """Test when the array has source_realization attribute.""" member_list = [0, 1, 2, 3] cube = (set_up_cube_with_no_realizations( source_realizations=member_list)) radii = 15000 ens_factor = 0.8 neighbourhood_method = "circular" plugin = NBHood(neighbourhood_method, radii, ens_factor=ens_factor) result = plugin.process(cube) self.assertIsInstance(result, Cube) expected = np.ones([1, 16, 16]) expected[0, 6:9, 6:9] = ([0.91666667, 0.875, 0.91666667], [0.875, 0.83333333, 0.875], [0.91666667, 0.875, 0.91666667]) self.assertArrayAlmostEqual(result.data, expected)
def test_radii_varying_with_lead_time_with_interpolation(self): """ Test that a cube is returned for the following conditions: 1. The radius varies with lead time. 2. Linear interpolation is required to create values for the radii which are required but were not specified within the 'radii' argument. """ cube = set_up_cube(num_time_points=3) iris.util.promote_aux_coord_to_dim_coord(cube, "time") time_points = cube.coord("time").points fp_points = [2, 3, 4] cube = add_forecast_reference_time_and_forecast_period( cube, time_point=time_points, fp_point=fp_points) radii = [10000, 30000] lead_times = [2, 4] neighbourhood_method = "circular" plugin = NBHood(neighbourhood_method, radii, lead_times) result = plugin.process(cube) self.assertIsInstance(result, Cube)
def test_radii_varying_with_lead_time_check_data(self): """ Test that the expected data is produced when the radius varies with lead time. """ cube = set_up_cube(zero_point_indices=((0, 0, 7, 7), ( 0, 1, 7, 7, ), (0, 2, 7, 7)), num_time_points=3) expected = np.ones_like(cube.data) expected[0, 0, 6:9, 6:9] = ([0.91666667, 0.875, 0.91666667], [0.875, 0.83333333, 0.875], [0.91666667, 0.875, 0.91666667]) expected[0, 1, 5:10, 5:10] = SINGLE_POINT_RANGE_3_CENTROID expected[0, 2, 4:11, 4:11] = ([ 1, 0.9925, 0.985, 0.9825, 0.985, 0.9925, 1 ], [0.9925, 0.98, 0.9725, 0.97, 0.9725, 0.98, 0.9925], [0.985, 0.9725, 0.965, 0.9625, 0.965, 0.9725, 0.985], [ 0.9825, 0.97, 0.9625, 0.96, 0.9625, 0.97, 0.9825 ], [0.985, 0.9725, 0.965, 0.9625, 0.965, 0.9725, 0.985], [0.9925, 0.98, 0.9725, 0.97, 0.9725, 0.98, 0.9925], [1, 0.9925, 0.985, 0.9825, 0.985, 0.9925, 1]) iris.util.promote_aux_coord_to_dim_coord(cube, "time") time_points = cube.coord("time").points fp_points = [2, 3, 4] cube = add_forecast_reference_time_and_forecast_period( cube, time_point=time_points, fp_point=fp_points) radii = [6000, 8000, 10000] lead_times = [2, 3, 4] neighbourhood_method = "circular" plugin = NBHood(neighbourhood_method, radii, lead_times) result = plugin.process(cube) self.assertArrayAlmostEqual(result.data, expected)
class WindDirection(object): """Plugin to calculate average wind direction from ensemble realizations. Science background: Taking an average wind direction is tricky since an average of two wind directions at 10 and 350 degrees is 180 when it should be 0 degrees. Converting the wind direction angles to complex numbers allows us to find a useful numerical average. :: z = a + bi a = r*Cos(theta) b = r*Sin(theta) r = radius The average of two complex numbers is NOT the ANGLE between two points it is the MIDPOINT in cartesian space. Therefore if there are two data points with radius=1 at 90 and 270 degrees then the midpoint is at (0,0) with radius=0 and therefore its average angle is meaningless. :: N | W---x------o------x---E | S In the rare case that a meaningless complex average is calculated, the code rejects the calculated complex average and simply uses the wind direction taken from the first ensemble realization. The steps are: 1) Take data from all ensemble realizations. 2) Convert the wind direction angles to complex numbers. 3) Find complex average and their radius values. 4) Convert the complex average back into degrees. 5) If any point has an radius of nearly zero - replace the calculated average with the wind direction from the first ensemble. 6) Calculate the confidence measure of the wind direction. Step 6 still needs more development so it is only included in the code as a placeholder. Keyword Args: backup_method (str): Backup method to use if the complex numbers approach has low confidence. "first_realization" uses the value of realization zero. "neighbourhood" (default) recalculates using the complex numbers approach with additional realizations extracted from neighbouring grid points from all available realizations. """ def __init__(self, backup_method='neighbourhood'): """Initialise class.""" self.backup_methods = ['first_realization', 'neighbourhood'] self.backup_method = backup_method if self.backup_method not in self.backup_methods: msg = ('Invalid option for keyword backup_method ' '({})'.format(self.backup_method)) raise ValueError(msg) # Any points where the r-values are below the threshold is regarded as # containing ambigous data. self.r_thresh = 0.01 # Creates cubelists to hold data. self.wdir_cube_list = iris.cube.CubeList() self.r_vals_cube_list = iris.cube.CubeList() self.confidence_measure_cube_list = iris.cube.CubeList() # Radius used in neighbourhood plugin as determined in IMPRO-491 self.nb_radius = 6000. # metres # Initialise neighbourhood plugin ready for use self.nbhood = NeighbourhoodProcessing('square', self.nb_radius, weighted_mode=False) def __repr__(self): """Represent the configured plugin instance as a string.""" return ( '<WindDirection: backup_method "{}"; neighbourhood radius "{}"m>' ).format(self.backup_method, self.nb_radius) def _reset(self): """Empties working data objects""" self.realization_axis = None self.wdir_complex = None self.wdir_slice_mean = None self.wdir_mean_complex = None self.r_vals_slice = None self.confidence_slice = None @staticmethod def deg_to_complex(angle_deg, radius=1): """Converts degrees to complex values. The radius value can be used to weigh values - but it is set to 1 for now. Args: angle_deg (np.ndarray or float): 3D array or float - wind direction angles in degrees. Keyword Args: radius (np.ndarray): 3D array or float - radius value for each point, default=1. Returns: (np.ndarray or float): 3D array or float - wind direction translated to complex numbers. """ # Convert from degrees to radians. angle_rad = np.deg2rad(angle_deg) # Derive real and imaginary components (also known as a and b) real = radius * np.cos(angle_rad) imag = radius * np.sin(angle_rad) # Combine components into a complex number and return. return real + 1j * imag @staticmethod def complex_to_deg(complex_in): """Converts complex to degrees. The "np.angle" function returns negative numbers when the input is greater than 180. Therefore additional processing is needed to ensure that the angle is between 0-359. Args: complex_in (np.ndarray): 3D array - wind direction angles in complex number form. Returns: angle (np.ndarray): 3D array - wind direction in angle form Raises ------ TypeError: If complex_in is not an array. """ if not isinstance(complex_in, np.ndarray): msg = "Input data is not a numpy array, but {}" raise TypeError(msg.format(type(complex_in))) angle = np.angle(complex_in, deg=True) # If angle negative value - add 360 degrees. angle = np.where(angle < 0, angle + 360, angle) # If angle == 360 - set to zero degrees. # Due to floating point - need to round value before using # equal operator. round_angle = np.around(angle, 2) angle = np.where(round_angle == 360, 0.0, angle) return angle def calc_wind_dir_mean(self): """Find the mean wind direction using complex average which actually signifies a point between all of the data points in POLAR coordinates - NOT the average DEGREE ANGLE. Uses: self.wdir_complex (np.ndarray or float): 3D array or float - wind direction angles in degrees. self.realization_axis (int): Axis to collapse over. Defines: self.wdir_mean_complex (np.ndarray or float): 3D array or float - wind direction angles as complex numbers collapsed along an axis using np.mean(). self.wdir_slice_mean (np.ndarray or float): 3D array or float - wind direction angles in degrees collapsed along an axis using np.mean(). """ self.wdir_mean_complex = np.mean(self.wdir_complex, axis=self.realization_axis) self.wdir_slice_mean.data = self.complex_to_deg(self.wdir_mean_complex) def find_r_values(self): """Find radius values from complex numbers. Takes input wind direction in complex values and returns array containing r values using Pythagoras theorem. Uses: self.wdir_mean_complex (np.ndarray or float): 3D array or float - wind direction angles in complex numbers. self.wdir_slice_mean (iris.cube.Cube): 3D array or float - mean wind direction angles in complex numbers. Defines: self.r_vals_slice (iris.cube.Cube): Contains r values and inherits meta-data from self.wdir_slice_mean. """ r_vals = (np.sqrt( np.square(self.wdir_mean_complex.real) + np.square(self.wdir_mean_complex.imag))) self.r_vals_slice = self.wdir_slice_mean.copy(data=r_vals) def calc_confidence_measure(self): """Find confidence measure of polar numbers. The average wind direction complex values represent the midpoint between the different values and so have r values between 0-1. 1) From self.wdir_slice_mean - create a new set of complex values. Therefore they will have the same angle but r is fixed as r=1. 2) Find the distance between the mean point and all the ensemble realization wind direction complex values. 3) Find the average distance between the mean point and the wind direction values. Large average distance == low confidence. 4) A confidence value that is between 1 for confident (small spread in ensemble realizations) and 0 for no-confidence. Set to 0 if r value is below threshold as any r value is regarded as meaningless. Uses: self.wdir_complex (np.ndarray): 3D array - wind direction angles in complex numbers. self.wdir_slice_mean (iris.cube.Cube): Contains average wind direction in angles. self.realization_axis (int): Axis to collapse over. self.r_vals_slice.data (np.ndarray): 3D array - Radius taken from average complex wind direction angle. self.r_thresh (float): Any r value below threshold is regarded as meaningless. Defines: self.confidence_slice (iris.cube.Cube): Contains the average distance from mean normalised - used as a confidence value. Inherits meta-data from self.wdir_slice_mean """ # Recalculate complex mean with radius=1. wdir_mean_complex_r1 = self.deg_to_complex(self.wdir_slice_mean.data) # Find difference in the distance between all the observed points and # mean point with fixed r=1. # For maths to work - the "wdir_mean_complex_r1 array" needs to # be "tiled" so that it is the same dimension as "self.wdir_complex". wind_dir_complex_mean_tile = np.tile( wdir_mean_complex_r1, (self.wdir_complex.shape[0], 1, 1)) # Calculate distance from each wind direction data point to the # average point. difference = self.wdir_complex - wind_dir_complex_mean_tile dist_from_mean = np.sqrt( np.square(difference.real) + np.square(difference.imag)) # Find average distance. dist_from_mean_avg = np.mean(dist_from_mean, axis=self.realization_axis) # If we have two points at opposite ends of the compass # (eg. 270 and 90), then their separation distance is 2. # Normalise the array using 2 as the maximum possible value. dist_from_mean_norm = 1 - dist_from_mean_avg * 0.5 # With two points directly opposite (270 and 90) it returns a # confidence value of 0.29289322 instead of zero due to precision # error. # # angles | confidence # 270/90 | 0.29289322 # 270/89 | 0.295985 # 270/88 | 0.299091 # 270/87 | 0.30221 # Therefore any confidence value where the r is less than the threshold # should be set to zero. dist_from_mean_norm = np.where(self.r_vals_slice.data < self.r_thresh, 0.0, dist_from_mean_norm) self.confidence_slice = self.wdir_slice_mean.copy( data=dist_from_mean_norm) def wind_dir_decider(self, where_low_r, wdir_cube): """If the wind direction is so widely scattered that the r value is nearly zero then this indicates that the average wind direction is essentially meaningless. We therefore substitute this meaningless average wind direction value for the wind direction calculated from a larger sample by smoothing across a neighbourhood of points before rerunning the main technique. This is invoked rarely (1 : 100 000) Arguments: where_low_r (np.array): Array of boolean values. True where original wind direction estimate has low confidence. These points are replaced according to self.backup_method wdir_cube (iris.cube.Cube): Contains array of wind direction data (realization, y, x) Uses: self.wdir_slice_mean (iris.cube.Cube): Containing average wind direction angle (in degrees). self.wdir_complex (np.ndarray): 3D array - wind direction angles from ensembles (in complex). self.r_vals_slice.data (np.ndarray): 2D array - Radius taken from average complex wind direction angle. self.r_thresh (float): Any r value below threshold is regarded as meaningless. self.realization_axis (int): Axis to collapse over. self.n_realizations (int): Number of realizations available in the plugin. Used to set the neighbourhood radius as this is used to adjust the radius again in the neighbourhooding plugin. Defines: self.wdir_slice_mean.data (np.ndarray): 2D array - Wind direction degrees where ambigious values have been replaced with data from first ensemble realization. """ if self.backup_method == 'neighbourhood': # Performs smoothing over a 6km square neighbourhood. # Then calculates the mean wind direction. child_class = WindDirection(backup_method="first_realization") child_class.wdir_complex = self.nbhood.process( wdir_cube.copy(data=self.wdir_complex)).data child_class.realization_axis = self.realization_axis child_class.wdir_slice_mean = self.wdir_slice_mean.copy() child_class.calc_wind_dir_mean() improved_values = child_class.wdir_slice_mean.data else: # Takes realization zero (control member). improved_values = wdir_cube.extract( iris.Constraint(realization=0)).data # If the r-value is low - substitute average wind direction value for # the wind direction taken from the first ensemble realization. self.wdir_slice_mean.data = np.where(where_low_r, improved_values, self.wdir_slice_mean.data) def process(self, cube_ens_wdir): """Create a cube containing the wind direction averaged over the ensemble realizations. Args: cube_ens_wdir (iris.cube.Cube): Cube containing wind direction from multiple ensemble realizations. Returns: cube_mean_wdir (iris.cube.Cube): Cube containing the wind direction averaged from the ensemble realizations. cube_r_vals (np.ndarray): 3D array - Radius taken from average complex wind direction angle. cube_confidence_measure (np.ndarray): 3D array - The average distance from mean normalised - used as a confidence value. Raises ------ TypeError: If cube_wdir is not a cube. """ if not isinstance(cube_ens_wdir, iris.cube.Cube): msg = "Wind direction input is not a cube, but {}" raise TypeError(msg.format(type(cube_ens_wdir))) try: cube_ens_wdir.convert_units("degrees") except ValueError as err: msg = "Input cube cannot be converted to degrees: {}".format(err) raise ValueError(msg) # Force input cube to float32. enforce_float32_precision(cube_ens_wdir) self.n_realizations = len(cube_ens_wdir.coord('realization').points) y_coord_name = cube_ens_wdir.coord(axis="y").name() x_coord_name = cube_ens_wdir.coord(axis="x").name() for wdir_slice in cube_ens_wdir.slices( ["realization", y_coord_name, x_coord_name]): self._reset() # Extract wind direction data. self.wdir_complex = self.deg_to_complex(wdir_slice.data) self.realization_axis, = wdir_slice.coord_dims("realization") # Copies input cube and remove realization dimension to create # cubes for storing results. self.wdir_slice_mean = next(wdir_slice.slices_over("realization")) self.wdir_slice_mean.remove_coord("realization") # Derive average wind direction. self.calc_wind_dir_mean() # Find radius values for wind direction average. self.find_r_values() # Calculate the confidence measure based on the difference # between the complex average and the individual ensemble # realizations. self.calc_confidence_measure() # Finds any meaningless averages and substitute with # the wind direction taken from the first ensemble realization. # Mask True if r values below threshold. where_low_r = np.where(self.r_vals_slice.data < self.r_thresh, True, False) # If the any point in the array contains poor r-values, # trigger decider function. if where_low_r.any(): self.wind_dir_decider(where_low_r, wdir_slice) # Append to cubelists. self.wdir_cube_list.append(self.wdir_slice_mean) self.r_vals_cube_list.append(self.r_vals_slice) self.confidence_measure_cube_list.append(self.confidence_slice) # Combine cubelists into cube. cube_mean_wdir = self.wdir_cube_list.merge_cube() cube_r_vals = self.r_vals_cube_list.merge_cube() cube_confidence_measure = ( self.confidence_measure_cube_list.merge_cube()) # Check that the dimensionality of coordinates of the output cube # matches the input cube. first_slice = next(cube_ens_wdir.slices_over(["realization"])) cube_mean_wdir = check_cube_coordinates(first_slice, cube_mean_wdir) # Change cube identifiers. cube_mean_wdir.add_cell_method(CellMethod("mean", coords="realization")) cube_r_vals.long_name = "radius_of_complex_average_wind_from_direction" cube_r_vals.units = None cube_confidence_measure.long_name = ( "confidence_measure_of_wind_from_direction") cube_confidence_measure.units = None return cube_mean_wdir, cube_r_vals, cube_confidence_measure
class NowcastLightning(BasePlugin): """Produce Nowcast of lightning probability. This Plugin selects a first-guess lightning probability field from MOGREPS-UK data matching the nowcast validity-time, and modifies this based on information from the nowcast. For each forecast time, the closest-in-time first-guess lightning probability slice is copied and modified thus: 1: Increase lightning probability where lightning is observed or is nearby. 2: Increase lightning probability where heavy or intense precipitation is observed. 3: Reduce lightning probability where precipitation is light or absent. 4: Increase lightning probability where ice is likely in the observed radar column. In this doc-string, LR is an abbreviation for the Lightning Risk index output by the CDP (Convection Diagnosis Procedure) and the Met Office nowcast. LR can take five values. 5 is the lowest risk and 1 is highest. The default behaviour makes these adjustments to the upper and lower limits of lightning probability: lightning mapping (lightning rate in "min^-1"): upper: lightning rate >= <function> => lightning prob = 1.0 (LR1) The <function> returns a linear value from 0.5 to 2.5 over a 6-hour forecast_period. lower: lightning rate == 0.0 => min lightning prob 0.25 (LR2) precipitation mapping (for prob(precip > 0.5 mm/hr)): upper: precip probability >= 0.1 => max lightning prob 1.0 (LR1) middle: precip probability >= 0.05 => max lightning prob 0.25 (LR2) lower: precip probability >= 0.0 => max lightning prob 0.0067 (LR3) heavy: prob(precip > 7mm/hr) >= 0.4 => min lightning prob 0.25 (LR2) equiv radar refl 37dBZ intense:prob(precip > 35mm/hr) >= 0.2 => min lightning prob 1.0 (LR1) equiv radar refl 48dBZ VII (vertically-integrated ice) mapping (kg/m2): upper: VII 2.0 => max lightning prob 0.9 middle: VII 1.0 => max lightning prob 0.5 lower: VII 0.5 => max lightning prob 0.1 """ #: (tuple): Expected thresholds for vertically-integrated-ice (VII) data. #: These are used for increasing prob(lightning) with column-ice data. #: Units are kg/m2. ice_thresholds = (0.5, 1.0, 2.0) def __init__(self, radius=10000.): """ Initialise class for Nowcast of lightning probability. Args: radius (float): Radius (metres) over which to neighbourhood process the output lightning probability. The value supplied applies at T+0 and increases to 2*radius at T+6 hours. The radius is applied in "process" using the circular neighbourhood plugin. """ self.radius = radius lead_times = [0., 6.] radii = [self.radius, 2*self.radius] self.neighbourhood = NeighbourhoodProcessing( 'circular', radii, lead_times=lead_times) # pl_dict (dict): # Lightning probability values to increase first-guess to if # the lightning_thresholds are exceeded in the nowcast data. # Dict must have keys 1 and 2 and contain float values. # The default values are selected to represent lightning risk # index values of 1 and 2 relating to the key. self.pl_dict = {1: 1., 2: 0.25} # Lightning-rate threshold for Lightning Risk 1 level # (dependent on forecast-length) # Lightning rate thresholds for adjusting the first-guess # lightning probability (strikes per minute == "min^-1"). # lrt_lev1 must be a function that takes "forecast_period" # in minutes and returns the lightning rate threshold for # increasing first-guess lightning probability to risk 1 (LR1). # This gives a decreasing influence on the extrapolated # lightning nowcast over forecast_period while retaining an # influence from the 50 km halo. self.lrt_lev1 = lambda mins: 0.5 + mins * 2. / 360. # Lightning-rate threshold for Lightning Risk 2 level # lrt_lev2 is the lightning rate threshold (as float) for # increasing first-guess lightning probability to risk 2 (LR2). self.lrt_lev2 = 0. # Set values for handling precipitation rate data # precipthr (tuple): # Values for limiting prob(lightning) with prob(precip). # These are the three prob(precip) thresholds and are designed # to prevent a large probability of lightning being output if # the probability of precipitation is very low. self.precipthr = (0.0, 0.05, 0.1) # ltngthr (tuple): # Values for limiting prob(lightning) with prob(precip) # These are the three prob(lightning) values to scale to. self.ltngthr = (0.0067, 0.25, 1.) # probability thresholds for increasing the prob(lightning) # phighthresh for heavy precip (>7mm/hr) # relates to problightning_values[2] # ptorrthresh for intense precip (>35mm/hr) # relates to problightning_values[1] self.phighthresh = 0.4 self.ptorrthresh = 0.2 # ice_scaling (tuple): # Values for increasing prob(lightning) with VII data. # These are the three prob(lightning) values to scale to. self.ice_scaling = (0.1, 0.5, 0.9) def __repr__(self): """ Docstring to describe the repr, which should return a printable representation of the object. """ return """<NowcastLightning: radius={radius}, lightning mapping (lightning rate in "min^-1"): upper: lightning rate {lthru} => min lightning prob {lprobu} lower: lightning rate {lthrl} => min lightning prob {lprobl} >""".format(radius=self.radius, lthru=self.lrt_lev1.__class__, lthrl=self.lrt_lev2, lprobu=self.pl_dict[1], lprobl=self.pl_dict[2]) @staticmethod def _update_metadata(cube): """ Modify the meta data of input cube to resemble a Nowcast of lightning probability. 1. Rename to "probability_of_rate_of_lightning_above_threshold" 2. Remove "threshold" coord (or causes iris.exceptions.CoordinateNotFoundError) 3. Discard all cell_methods Args: cube (iris.cube.Cube): An input cube Returns: iris.cube.Cube: Output cube - a copy of input cube with meta-data relating to a Nowcast of lightning probability. The data array will be a copy of the input cube.data """ new_cube = cube.copy() new_cube.rename("probability_of_rate_of_lightning_above_threshold") threshold_coord = find_threshold_coordinate(new_cube) new_cube.remove_coord(threshold_coord) new_cube.cell_methods = None return new_cube def _modify_first_guess(self, cube, first_guess_lightning_cube, lightning_rate_cube, prob_precip_cube, prob_vii_cube=None): """ Modify first-guess lightning probability with nowcast data. Args: cube (iris.cube.Cube): Provides the meta-data for the Nowcast lightning probability output cube. first_guess_lightning_cube (iris.cube.Cube): First-guess lightning probability. Must have same x & y dimensions as cube. Time dimension should overlap that of cube (closest slice in time is used with a maximum time mismatch of 2 hours). This is included to allow this cube to come from a different modelling system, such as the UM. lightning_rate_cube (iris.cube.Cube): Nowcast lightning rate. Must have same dimensions as cube. prob_precip_cube (iris.cube.Cube): Nowcast precipitation probability (threshold > 0.5, 7, 35). Must have same other dimensions as cube. prob_vii_cube (iris.cube.Cube): Radar-derived vertically integrated ice content (VII). Must have same x and y dimensions as cube. Time should be a scalar coordinate. Must have a threshold coordinate with points matching. self.vii_thresholds. Can be <No cube> or None or anything that evaluates to False. Returns: iris.cube.Cube: Output cube containing Nowcast lightning probability. Raises: iris.exceptions.ConstraintMismatchError: If lightning_rate_cube or first_guess_lightning_cube do not contain the expected times. """ new_cube_list = iris.cube.CubeList([]) # Loop over required forecast validity times for cube_slice in cube.slices_over('time'): this_time = iris_time_to_datetime( cube_slice.coord('time').copy())[0] lightning_rate_slice = lightning_rate_cube.extract( iris.Constraint(time=this_time)) err_string = "No matching {} cube for {}" if not isinstance(lightning_rate_slice, iris.cube.Cube): raise ConstraintMismatchError( err_string.format("lightning", this_time)) first_guess_slice = extract_nearest_time_point( first_guess_lightning_cube, this_time, allowed_dt_difference=7201) first_guess_slice = cube_slice.copy(data=first_guess_slice.data) first_guess_slice.coord('forecast_period').convert_units('minutes') fcmins = first_guess_slice.coord('forecast_period').points[0] # Increase prob(lightning) to Risk 2 (pl_dict[2]) when # lightning nearby (lrt_lev2) # (and leave unchanged when condition is not met): first_guess_slice.data = np.where( (lightning_rate_slice.data >= self.lrt_lev2) & (first_guess_slice.data < self.pl_dict[2]), self.pl_dict[2], first_guess_slice.data) # Increase prob(lightning) to Risk 1 (pl_dict[1]) when within # lightning storm (lrt_lev1): # (and leave unchanged when condition is not met): lratethresh = self.lrt_lev1(fcmins) first_guess_slice.data = np.where( (lightning_rate_slice.data >= lratethresh) & (first_guess_slice.data < self.pl_dict[1]), self.pl_dict[1], first_guess_slice.data) new_cube_list.append(first_guess_slice) new_prob_lightning_cube = new_cube_list.merge_cube() new_prob_lightning_cube = check_cube_coordinates( cube, new_prob_lightning_cube) # Apply precipitation adjustments. new_prob_lightning_cube = self.apply_precip(new_prob_lightning_cube, prob_precip_cube) # If we have VII data, increase prob(lightning) accordingly. if prob_vii_cube: new_prob_lightning_cube = self.apply_ice(new_prob_lightning_cube, prob_vii_cube) return new_prob_lightning_cube def apply_precip(self, prob_lightning_cube, prob_precip_cube): """ Modify Nowcast of lightning probability with precipitation rate probabilities at thresholds of 0.5, 7 and 35 mm/h. Args: prob_lightning_cube (iris.cube.Cube): First-guess lightning probability. prob_precip_cube (iris.cube.Cube): Nowcast precipitation probability (threshold > 0.5, 7., 35. mm hr-1) Units of threshold coord modified in-place to mm hr-1 Returns: iris.cube.Cube: Output cube containing updated nowcast lightning probability. This cube will have the same dimensions and meta-data as prob_lightning_cube. Raises: iris.exceptions.ConstraintMismatchError: If prob_precip_cube does not contain the expected thresholds. """ new_cube_list = iris.cube.CubeList([]) # check prob-precip threshold units are as expected precip_threshold_coord = find_threshold_coordinate(prob_precip_cube) precip_threshold_coord.convert_units('mm hr-1') # extract precipitation probabilities at required thresholds for cube_slice in prob_lightning_cube.slices_over('time'): this_time = iris_time_to_datetime( cube_slice.coord('time').copy())[0] this_precip = prob_precip_cube.extract( iris.Constraint(time=this_time) & iris.Constraint(coord_values={ precip_threshold_coord: lambda t: isclose(t.point, 0.5)})) high_precip = prob_precip_cube.extract( iris.Constraint(time=this_time) & iris.Constraint(coord_values={ precip_threshold_coord: lambda t: isclose(t.point, 7.)})) torr_precip = prob_precip_cube.extract( iris.Constraint(time=this_time) & iris.Constraint(coord_values={ precip_threshold_coord: lambda t: isclose(t.point, 35.)})) err_string = "No matching {} cube for {}" if not isinstance(this_precip, iris.cube.Cube): raise ConstraintMismatchError( err_string.format("any precip", this_time)) if not isinstance(high_precip, iris.cube.Cube): raise ConstraintMismatchError( err_string.format("high precip", this_time)) if not isinstance(torr_precip, iris.cube.Cube): raise ConstraintMismatchError( err_string.format("intense precip", this_time)) # Increase prob(lightning) to Risk 2 (pl_dict[2]) when # prob(precip > 7mm/hr) > phighthresh cube_slice.data = np.where( (high_precip.data >= self.phighthresh) & (cube_slice.data < self.pl_dict[2]), self.pl_dict[2], cube_slice.data) # Increase prob(lightning) to Risk 1 (pl_dict[1]) when # prob(precip > 35mm/hr) > ptorrthresh cube_slice.data = np.where( (torr_precip.data >= self.ptorrthresh) & (cube_slice.data < self.pl_dict[1]), self.pl_dict[1], cube_slice.data) # Decrease prob(lightning) where prob(precip > 0.5 mm hr-1) is low. cube_slice.data = apply_double_scaling( this_precip, cube_slice, self.precipthr, self.ltngthr) new_cube_list.append(cube_slice) new_cube = new_cube_list.merge_cube() new_cube = check_cube_coordinates( prob_lightning_cube, new_cube) return new_cube def apply_ice(self, prob_lightning_cube, ice_cube): """ Modify Nowcast of lightning probability with ice data from a radar composite (VII; Vertically Integrated Ice) Args: prob_lightning_cube (iris.cube.Cube): First-guess lightning probability. The forecast_period coord is modified in-place to "minutes". ice_cube (iris.cube.Cube): Analysis of vertically integrated ice (VII) from radar thresholded at self.ice_thresholds. Units of threshold coord modified in-place to kg m^-2 Returns: iris.cube.Cube: Output cube containing updated nowcast lightning probability. This cube will have the same dimensions and meta-data as prob_lightning_cube. The influence of the data in ice_cube reduces linearly to zero as forecast_period increases to 2H30M. Raises: iris.exceptions.ConstraintMismatchError: If ice_cube does not contain the expected thresholds. """ prob_lightning_cube.coord('forecast_period').convert_units('minutes') # check prob-ice threshold units are as expected ice_threshold_coord = find_threshold_coordinate(ice_cube) ice_threshold_coord.convert_units('kg m^-2') new_cube_list = iris.cube.CubeList([]) err_string = "No matching prob(Ice) cube for threshold {}" for cube_slice in prob_lightning_cube.slices_over('time'): fcmins = cube_slice.coord('forecast_period').points[0] for threshold, prob_max in zip(self.ice_thresholds, self.ice_scaling): ice_slice = ice_cube.extract( iris.Constraint(coord_values={ ice_threshold_coord: lambda t: isclose( t.point, threshold)})) if not isinstance(ice_slice, iris.cube.Cube): raise ConstraintMismatchError(err_string.format(threshold)) # Linearly reduce impact of ice as fcmins increases to 2H30M. ice_scaling = [0., (prob_max * (1. - (fcmins / 150.)))] if ice_scaling[1] > 0: cube_slice.data = np.maximum( rescale(ice_slice.data, data_range=(0., 1.), scale_range=ice_scaling, clip=True), cube_slice.data) new_cube_list.append(cube_slice) new_cube = new_cube_list.merge_cube() new_cube = check_cube_coordinates( prob_lightning_cube, new_cube) return new_cube def process(self, cubelist): """ Produce Nowcast of lightning probability. Args: cubelist (iris.cube.CubeList): Where thresholds are listed, only these threshold values will be used. Contains cubes of * First-guess lightning probability * Nowcast precipitation probability (required thresholds: > 0.5, 7., 35. mm hr-1) * Nowcast lightning rate * (optional) Analysis of vertically integrated ice (VII) from radar thresholded into probability slices at self.ice_thresholds. Returns: iris.cube.Cube: Output cube containing Nowcast lightning probability. This cube will have the same dimensions as the input Nowcast precipitation probability after the threshold coord has been removed. Raises: iris.exceptions.ConstraintMismatchError: If cubelist does not contain the expected cubes. """ first_guess_lightning_cube = cubelist.extract( "probability_of_rate_of_lightning_above_threshold", strict=True) lightning_rate_cube = cubelist.extract( "rate_of_lightning", strict=True) lightning_rate_cube.convert_units("min^-1") # Ensure units are correct prob_precip_cube = cubelist.extract( "probability_of_lwe_precipitation_rate_above_threshold", strict=True) # Now find prob_vii_cube. Can't use strict=True here as cube may not be # present, so will use a normal extract and then merge_cube if needed. prob_vii_cube = cubelist.extract( "probability_of_vertical_integral_of_ice_above_threshold") if prob_vii_cube: prob_vii_cube = prob_vii_cube.merge_cube() precip_threshold_coord = find_threshold_coordinate(prob_precip_cube) precip_threshold_coord.convert_units('mm hr-1') precip_slice = prob_precip_cube.extract( iris.Constraint(coord_values={ precip_threshold_coord: lambda t: isclose(t.point, 0.5)})) if not isinstance(precip_slice, iris.cube.Cube): raise ConstraintMismatchError( "Cannot find prob(precip > 0.5 mm hr-1) cube in cubelist.") template_cube = self._update_metadata(precip_slice) new_cube = self._modify_first_guess( template_cube, first_guess_lightning_cube, lightning_rate_cube, prob_precip_cube, prob_vii_cube) # Adjust data so that lightning probability does not decrease too # rapidly with distance. self.neighbourhood.process(new_cube) return new_cube