Beispiel #1
0
 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)
Beispiel #2
0
 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)
Beispiel #3
0
 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)
Beispiel #4
0
    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)
Beispiel #5
0
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
Beispiel #6
0
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