"""
``rainymotion.models``: optical flow models for radar-based
precipitation nowcasting
===============================================================================

"""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import cv2
import numpy as np
import wradlib.ipol as ipol
from scipy.interpolate import LinearNDInterpolator, NearestNDInterpolator
from scipy.ndimage import map_coordinates
import skimage.transform as sktf
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures

from rainymotion.utils import RYScaler, inv_RYScaler

# -- SPARSE GROUP -- #
# ----- helpers ----- #


def _sparse_linear(data_instance,
                   of_params={'st_pars': dict(maxCorners = 200,
                                              qualityLevel = 0.2,
                                              minDistance = 7,
                                              blockSize = 21),
                              'lk_pars': dict(winSize = (20, 20),
                                              maxLevel = 2,
                                              criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))},
                   extrapol_params={"model": LinearRegression(),
                                    "features": "ordinal"},
                   lead_steps=12):

    # find features to track
    old_corners = cv2.goodFeaturesToTrack(data_instance[0], mask=None,
                                          **of_params['st_pars'])

    # Set containers to collect results (time steps in rows, detected corners
    # in columns)

    #   corner x coords
    x = np.full((data_instance.shape[0], len(old_corners)), np.nan)
    #   corner y coords
    y = np.full((data_instance.shape[0], len(old_corners)), np.nan)
    #   Assign persistent corner IDs
    ids = np.arange(len(old_corners))

    # fill in first values
    x[0, :] = old_corners[:, 0, 0]
    y[0, :] = old_corners[:, 0, 1]

    # track corners by optical flow algorithm
    for i in range(1, data_instance.shape[0]):

        new_corners, st, err = cv2.calcOpticalFlowPyrLK(prevImg=data_instance[i-1],
                                                        nextImg=data_instance[i],
                                                        prevPts=old_corners,
                                                        nextPts=None,
                                                        **of_params['lk_pars'])

        # select only good attempts for corner tracking
        success = st.ravel() == 1
        # use only sucessfull ids for filling
        ids = ids[success]
        # fill in results
        x[i, ids] = new_corners[success, 0, 0]
        y[i, ids] = new_corners[success, 0, 1]
        # new corners will be old in the next loop
        old_corners = new_corners[success]

    # consider only full paths
    full_paths_without_nan = [np.sum(np.isnan(x[:, i])) == 0 for i in range(x.shape[1])]
    x = x[:, full_paths_without_nan].copy()
    y = y[:, full_paths_without_nan].copy()

    # containers for corners predictions
    x_new = np.full((lead_steps, x.shape[1]), np.nan)
    y_new = np.full((lead_steps, y.shape[1]), np.nan)

    for i in range(x.shape[1]):

        x_train = x[:, i]
        y_train = y[:, i]

        X = np.arange(x.shape[0] + lead_steps)

        if extrapol_params["features"] == "polynomial":
            polyfeatures = PolynomialFeatures(2)
            X = polyfeatures.fit_transform(X.reshape(-1, 1))
            X_train = X[:x.shape[0], :]
            X_pred = X[x.shape[0]:, :]
        else:
            X = X.reshape(-1, 1)
            X_train = X[:x.shape[0], :]
            X_pred = X[x.shape[0]:, :]

        x_pred = extrapol_params["model"].fit(X_train, x_train).predict(X_pred)
        y_pred = extrapol_params["model"].fit(X_train, y_train).predict(X_pred)

        x_new[:, i] = x_pred
        y_new[:, i] = y_pred

    # define source corners in appropriate format
    pts_source = np.hstack([x[-1, :].reshape(-1, 1), y[-1, :].reshape(-1, 1)])

    # define container for targets in appropriate format
    pts_target_container = [np.hstack([x_new[i, :].reshape(-1, 1),
                                       y_new[i, :].reshape(-1, 1)]) for i in range(x_new.shape[0])]

    return pts_source, pts_target_container


def _sparse_sd(data_instance,
               of_params={'st_pars': dict(maxCorners = 200,
                                          qualityLevel = 0.2,
                                          minDistance = 7,
                                          blockSize = 21),
                          'lk_pars': dict(winSize = (20, 20),
                                          maxLevel = 2,
                                          criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))},
               lead_steps=12):

    # define penult and last frames
    penult_frame = data_instance[-2]
    last_frame = data_instance[-1]

    # find features to track
    old_corners = cv2.goodFeaturesToTrack(data_instance[0], mask=None,
                                          **of_params['st_pars'])

    # track corners by optical flow algorithm
    new_corners, st, err = cv2.calcOpticalFlowPyrLK(prevImg=penult_frame,
                                                    nextImg=last_frame,
                                                    prevPts=old_corners,
                                                    nextPts=None,
                                                    **of_params['lk_pars'])

    # select only good attempts for corner tracking
    success = st.ravel() == 1
    new_corners = new_corners[success].copy()
    old_corners = old_corners[success].copy()

    # calculate Simple Delta
    delta = new_corners.reshape(-1, 2) - old_corners.reshape(-1, 2)

    # simplificate furher transformations
    pts_source = new_corners.reshape(-1, 2)

    # propagate our corners through time
    pts_target_container = []

    for lead_step in range(lead_steps):
        pts_target_container.append(pts_source + delta * (lead_step + 1))

    return pts_source, pts_target_container


class Sparse:
    """
    The basic class for the Sparse model of the rainymotion library.

    To run your nowcasting model you first have to set up a class instance
    as follows:

    `model = Sparse()`

    and then use class attributes to set up model parameters, e.g.:

    `model.extrapolation = "linear"`

    All class attributes have default values, for getting started with
    nowcasting you must specify only `input_data` attribute which holds
    the latest radar data observations. After specifying the input data,
    you can run nowcasting model and produce the corresponding results of
    nowcasting using `.run()` method:

    `nowcasts = model.run()`

    Attributes
    ----------
    input_data: 3D numpy array (frames, dim_x, dim_y) of radar data for
                previous hours. "frames" dimension must be > 2.

    scaler: function, default=rainymotion.utils.RYScaler
        Corner identification and optical flow algorithms require specific data
        type to perform calculations: uint8. That means that you must specify
        the transformation function (i.e. "scaler") to convert the "input_data"
        to the range of integers [0, 255]. By default we are using RYScaler
        which converts precipitation depth (mm, float16) to "brightness"
        values (uint8).

    inverse_scaler: function, default=rainymotion.utils.inv_RYScaler
        Function which does the inverse transformation of "brightness"
        values (uint8) to precipitation values.

    lead_steps: int, default=12
        Number of lead times for which we want to produce nowcasts. Must be > 0

    of_params: dict
        The dictionary of corresponding Shi-Tomasi corner detector parameters
        (key "st_pars"), and Lukas-Kanade optical flow parameters
        (key "lk_pars"). The default dictionary for parameters is:
        {'st_pars' : dict(maxCorners = 200,
                           qualityLevel = 0.2,
                           minDistance = 7,
                           blockSize = 21 ),
         'lk_pars' : dict(winSize  = (20,20),
                           maxLevel = 2,
                           criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))}

    extrapolation: str, default="linear"
        The extrapolation method for precipitation features advection.
        Linear method establishes linear regression for every detected feature
        which then used to advect this feature to the imminent future.

    warper: str, default="affine", options=["affine", "euclidean", "similarity",
                                            "projective"]
            Warping technique used for transformation of the last available
            radar observation in accordance with advected features displacement.

    Methods
    -------
    run(): perform calculation of nowcasts.
        Return 3D numpy array of shape (lead_steps, dim_x, dim_y).

    """
    def __init__(self):

        self.of_params = {'st_pars': dict(maxCorners=200, qualityLevel=0.2,
                                          minDistance=7, blockSize=21),
                          'lk_pars': dict(winSize=(20, 20), maxLevel=2,
                                          criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))}

        self.extrapolation = "linear"

        self.warper = "affine"

        self.input_data = None

        self.scaler = RYScaler

        self.inverse_scaler = inv_RYScaler

        self.lead_steps = 12

    def run(self):
        """
        Run nowcasting calculations.

        Returns
        -------
        nowcasts : 3D numpy array of shape (lead_steps, dim_x, dim_y).

        """

        # define available transformations dictionary
        transformations = {'euclidean': sktf.EuclideanTransform(),
                           'similarity': sktf.SimilarityTransform(),
                           'affine': sktf.AffineTransform(),
                           'projective': sktf.ProjectiveTransform()}

        # scale input data to uint8 [0-255] with self.scaler
        data_scaled, c1, c2 = self.scaler(self.input_data)

        # set up transformer object
        trf = transformations[self.warper]

        # obtain source and target points
        if self.extrapolation == "linear":
            pts_source, pts_target_container = _sparse_linear(data_instance=data_scaled,
                                                              of_params=self.of_params,
                                                              lead_steps=self.lead_steps)
        elif self.extrapolation == "simple_delta":
            pts_source, pts_target_container = _sparse_sd(data_instance=data_scaled,
                                                          of_params=self.of_params,
                                                          lead_steps=self.lead_steps)

        # now we can start to find nowcasted image
        # for every candidate of projected sets of points

        # container for our nowcasts
        last_frame = data_scaled[-1]
        nowcst_frames = []

        for lead_step, pts_target in enumerate(pts_target_container):

            # estimate transformation matrix
            # based on source and traget points
            trf.estimate(pts_source, pts_target)

            # make a nowcast
            nowcst_frame = sktf.warp(last_frame/255, trf.inverse)
            # transformations dealing with strange behaviour
            nowcst_frame = (nowcst_frame*255).astype('uint8')
            # add to the container
            nowcst_frames.append(nowcst_frame)

        nowcst_frames = np.stack(nowcst_frames, axis=0)

        nowcst_frames = self.inverse_scaler(nowcst_frames, c1, c2)

        return nowcst_frames


class SparseSD:
    """
    The basic class for the SparseSD model of the rainymotion library.

    To run your nowcasting model you first have to set up a class instance
    as follows:

    `model = SparseSD()`

    and then use class attributes to set up model parameters, e.g.:

    `model.warper = "affine"`

    All class attributes have default values, for getting started with
    nowcasting you must specify only `input_data` attribute which holds the
    latest radar data observations. After specifying the input data, you can
    run nowcasting model and produce the corresponding results of nowcasting
    using `.run()` method:

    `nowcasts = model.run()`

    Attributes
    ----------
    input_data: 3D numpy array (frames, dim_x, dim_y) of radar data for
                previous hours. "frames" dimension must be > 2.

    scaler: function, default=rainymotion.utils.RYScaler
        Corner identification and optical flow algorithms require specific data
        type to perform calculations: uint8. That means that you must specify
        the transformation function (i.e. "scaler") to convert the "input_data"
        to the range of integers [0, 255]. By default we are using RYScaler
        which converts precipitation depth (mm, float16) to "brightness"
        values (uint8).

    inverse_scaler: function, default=rainymotion.utils.inv_RYScaler
        Function which does the inverse transformation of "brightness"
        values (uint8) to precipitation values.

    lead_steps: int, default=12
        Number of lead times for which we want to produce nowcasts. Must be > 0

    of_params: dict
        The dictionary of corresponding Shi-Tomasi corner detector parameters
        (key "st_pars"), and Lukas-Kanade optical flow parameters
        (key "lk_pars"). The default dictionary for parameters is:
        {'st_pars' : dict(maxCorners = 200,
                           qualityLevel = 0.2,
                           minDistance = 7,
                           blockSize = 21 ),
         'lk_pars' : dict(winSize  = (20,20),
                           maxLevel = 2,
                           criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))}

    extrapolation: str, default="simple_delta"
        The extrapolation method for precipitation features advection.
        For "simple_delta" method we use an assumption that detected
        displacement of precipitation feature between the two latest radar
        observations will be constant for each lead time.

    warper: str, default="affine", options=["affine", "euclidean", "similarity",
                                            "projective"]
        Warping technique used for transformation of the last available radar
        observation in accordance with advected features displacement.

    Methods
    -------
    run(): perform calculation of nowcasts.
        Return 3D numpy array of shape (lead_steps, dim_x, dim_y).

    """

    def __init__(self):

        self.of_params = {'st_pars': dict(maxCorners=200, qualityLevel=0.2,
                                          minDistance=7, blockSize=21),
                          'lk_pars': dict(winSize=(20, 20), maxLevel=2,
                                          criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0))}

        self.extrapolation = "simple_delta"

        self.warper = "affine"

        self.input_data = None

        self.scaler = RYScaler

        self.inverse_scaler = inv_RYScaler

        self.lead_steps = 12

    def run(self):
        """
        Run nowcasting calculations.

        Returns
        -------
        nowcasts : 3D numpy array of shape (lead_steps, dim_x, dim_y).

        """

        # define available transformations dictionary
        transformations = {'euclidean': sktf.EuclideanTransform(),
                           'similarity': sktf.SimilarityTransform(),
                           'affine': sktf.AffineTransform(),
                           'projective': sktf.ProjectiveTransform()}

        # scale input data to uint8 [0-255] with self.scaler
        data_scaled, c1, c2 = self.scaler(self.input_data)

        # set up transformer object
        trf = transformations[self.warper]

        # obtain source and target points
        if self.extrapolation == "linear":
            pts_source, pts_target_container = _sparse_linear(data_instance=data_scaled,
                                                              of_params=self.of_params,
                                                              lead_steps=self.lead_steps)
        elif self.extrapolation == "simple_delta":
            pts_source, pts_target_container = _sparse_sd(data_instance=data_scaled,
                                                          of_params=self.of_params,
                                                          lead_steps=self.lead_steps)

        # now we can start to find nowcasted image
        # for every candidate of projected sets of points

        # container for our nowcasts
        last_frame = data_scaled[-1]
        nowcst_frames = []

        for lead_step, pts_target in enumerate(pts_target_container):

            # estimate transformation matrix
            # based on source and traget points
            trf.estimate(pts_source, pts_target)

            # make a nowcast
            nowcst_frame = sktf.warp(last_frame/255, trf.inverse)
            # transformations dealing with strange behaviour
            nowcst_frame = (nowcst_frame*255).astype('uint8')
            # add to the container
            nowcst_frames.append(nowcst_frame)

        nowcst_frames = np.stack(nowcst_frames, axis=0)

        nowcst_frames = self.inverse_scaler(nowcst_frames, c1, c2)

        return nowcst_frames

# -- DENSE GROUP -- #
# ----- helpers ----- #

# filling holes (zeros) in velocity field


def _fill_holes(of_instance, threshold=0):

    # calculate velocity scalar
    vlcty = np.sqrt(of_instance[::, ::, 0]**2 + of_instance[::, ::, 1]**2)

    # zero mask
    zero_holes = vlcty <= threshold

    # targets
    coord_target_i, coord_target_j = np.meshgrid(range(of_instance.shape[1]),
                                                 range(of_instance.shape[0]))

    # source
    coord_source_i, coord_source_j = coord_target_i[~zero_holes], coord_target_j[~zero_holes]
    delta_x_source = of_instance[::, ::, 0][~zero_holes]
    delta_y_source = of_instance[::, ::, 1][~zero_holes]

    # reshape
    src = np.vstack((coord_source_i.ravel(), coord_source_j.ravel())).T
    trg = np.vstack((coord_target_i.ravel(), coord_target_j.ravel())).T

    # create an object
    interpolator = ipol.Idw(src, trg)

    #
    delta_x_target = interpolator(delta_x_source.ravel())
    delta_y_target = interpolator(delta_y_source.ravel())

    # reshape output
    delta_x_target = delta_x_target.reshape(of_instance.shape[0],
                                            of_instance.shape[1])
    delta_y_target = delta_y_target.reshape(of_instance.shape[0],
                                            of_instance.shape[1])

    return np.stack([delta_x_target, delta_y_target], axis=-1)


# calculate optical flow
def _calculate_of(data_instance,
                  method="DIS",
                  direction="forward"):

    # define frames order
    if direction == "forward":
        prev_frame = data_instance[-2]
        next_frame = data_instance[-1]
        coef = 1.0
    elif direction == "backward":
        prev_frame = data_instance[-1]
        next_frame = data_instance[-2]
        coef = -1.0

    # calculate dense flow
    if method == "Farneback":
        of_instance = cv2.optflow.createOptFlow_Farneback()
    elif method == "DIS":
        of_instance = cv2.optflow.createOptFlow_DIS()
    elif method == "DeepFlow":
        of_instance = cv2.optflow.createOptFlow_DeepFlow()
    elif method == "PCAFlow":
        of_instance = cv2.optflow.createOptFlow_PCAFlow()
    elif method == "SimpleFlow":
        of_instance = cv2.optflow.createOptFlow_SimpleFlow()
    elif method == "SparseToDense":
        of_instance = cv2.optflow.createOptFlow_SparseToDense()

    delta = of_instance.calc(prev_frame, next_frame, None) * coef

    if method in ["Farneback", "SimpleFlow"]:
        # variational refinement
        delta = cv2.optflow.createVariationalFlowRefinement().calc(prev_frame, next_frame, delta)
        delta = np.nan_to_num(delta)
        delta = _fill_holes(delta)

    return delta


# constant-vector advection
def _advection_constant_vector(of_instance, lead_steps=12):

    delta_x = of_instance[::, ::, 0]
    delta_y = of_instance[::, ::, 1]

    # make a source meshgrid
    coord_source_i, coord_source_j = np.meshgrid(range(of_instance.shape[1]),
                                                 range(of_instance.shape[0]))

    # calculate new coordinates of radar pixels
    coord_targets = []
    for lead_step in range(lead_steps):
        coord_target_i = coord_source_i + delta_x * (lead_step + 1)
        coord_target_j = coord_source_j + delta_y * (lead_step + 1)
        coord_targets.append([coord_target_i, coord_target_j])

    coord_source = [coord_source_i, coord_source_j]

    return coord_source, coord_targets


# semi-Lagrangian advection
def _advection_semi_lagrangian(of_instance, lead_steps=12):

    delta_x = of_instance[::, ::, 0]
    delta_y = of_instance[::, ::, 1]

    # make a source meshgrid
    coord_source_i, coord_source_j = np.meshgrid(range(of_instance.shape[1]),
                                                 range(of_instance.shape[0]))

    # create dynamic delta holders
    delta_xi = delta_x.copy()
    delta_yi = delta_y.copy()

    # Block for calculation displacement
    # init placeholders
    coord_targets = []
    for lead_step in range(lead_steps):

        # calculate corresponding targets
        coord_target_i = coord_source_i + delta_xi
        coord_target_j = coord_source_j + delta_yi

        coord_targets.append([coord_target_i, coord_target_j])

        # now update source coordinates
        coord_source_i = coord_target_i
        coord_source_j = coord_target_j
        coord_source = [coord_source_j.ravel(), coord_source_i.ravel()]

        # update deltas
        delta_xi = map_coordinates(delta_x, coord_source).reshape(of_instance.shape[0], of_instance.shape[1])
        delta_yi = map_coordinates(delta_y, coord_source).reshape(of_instance.shape[0], of_instance.shape[1])

    # reinitialization of coordinates source
    coord_source_i, coord_source_j = np.meshgrid(range(of_instance.shape[1]),
                                                 range(of_instance.shape[0]))
    coord_source = [coord_source_i, coord_source_j]

    return coord_source, coord_targets


# interpolation routine
def _interpolator(points, coord_source, coord_target, method="idw"):

    coord_source_i, coord_source_j = coord_source
    coord_target_i, coord_target_j = coord_target

    # reshape
    trg = np.vstack((coord_source_i.ravel(), coord_source_j.ravel())).T
    src = np.vstack((coord_target_i.ravel(), coord_target_j.ravel())).T

    if method == "nearest":
        interpolator = NearestNDInterpolator(src, points.ravel(),
                                             tree_options={"balanced_tree": False})
        points_interpolated = interpolator(trg)
    elif method == "linear":
        interpolator = LinearNDInterpolator(src, points.ravel(), fill_value=0)
        points_interpolated = interpolator(trg)
    elif method == "idw":
        interpolator = ipol.Idw(src, trg)
        points_interpolated = interpolator(points.ravel())

    # reshape output
    points_interpolated = points_interpolated.reshape(points.shape)

    return points_interpolated.astype(points.dtype)


class Dense:
    """
    The basic class for the Dense model of the rainymotion library.

    To run your nowcasting model you first have to set up a class instance
    as follows:

    `model = Dense()`

    and then use class attributes to set up model parameters, e.g.:

    `model.of_method = "DIS"`

    All class attributes have default values, for getting started with
    nowcasting you must specify only `input_data` attribute which holds the
    latest radar data observations. After specifying the input data, you can
    run nowcasting model and produce the corresponding results of nowcasting
    using `.run()` method:

    `nowcasts = model.run()`

    Attributes
    ----------
    input_data: 3D numpy array (frames, dim_x, dim_y) of radar data for
    previous hours. "frames" dimension must be > 2.

    scaler: function, default=rainymotion.utils.RYScaler
        Corner identification and optical flow algorithms require specific data
        type to perform calculations: uint8. That means that you must specify
        the transformation function (i.e. "scaler") to convert the "input_data"
        to the range of integers [0, 255]. By default we are using RYScaler
        which converts precipitation depth (mm, float16) to "brightness"
        values (uint8).

    lead_steps: int, default=12
        Number of lead times for which we want to produce nowcasts. Must be > 0

    of_method: str, default="DIS", options=["DIS", "PCAFlow", "DeepFlow",
                                            "Farneback"]
        The optical flow method to obtain the dense representation (in every
        image pixel) of motion field. By default we use the Dense Inverse
        Search algorithm (DIS). PCAFlow, DeepFlow, and Farneback algoritms
        are also available to obtain motion field.

    advection: str, default="constant-vector"
        The advection scheme we use for extrapolation of every image pixel
        into the imminent future.

    direction: str, default="backward", options=["forward", "backward"]
        The direction option of the advection scheme.

    interpolation: str, default="idw", options=["idw", "nearest", "linear"]
        The interpolation method we use to interpolate advected pixel values
        to the original grid of the radar image. By default we use inverse
        distance weightning interpolation (Idw) as proposed in library wradlib
        (wradlib.ipol.Idw), but interpolation techniques from scipy.interpolate
        (e.g., "nearest" or "linear") could also be used.

    Methods
    -------
    run(): perform calculation of nowcasts.
        Return 3D numpy array of shape (lead_steps, dim_x, dim_y).

    """

    def __init__(self):

        self.input_data = None

        self.scaler = RYScaler

        self.lead_steps = 12

        self.of_method = "DIS"

        self.direction = "backward"

        self.advection = "constant-vector"

        self.interpolation = "idw"

    def run(self):
        """
        Run nowcasting calculations.

        Returns
        -------
        nowcasts : 3D numpy array of shape (lead_steps, dim_x, dim_y).

        """

        # scale input data to uint8 [0-255] with self.scaler
        scaled_data, c1, c2 = self.scaler(self.input_data)

        # calculate optical flow
        of = _calculate_of(scaled_data, method=self.of_method,
                           direction=self.direction)

        # advect pixels accordingly
        if self.advection == "constant-vector":
            coord_source, coord_targets = _advection_constant_vector(of, lead_steps=self.lead_steps)
        elif self.advection == "semi-lagrangian":
            coord_source, coord_targets = _advection_semi_lagrangian(of, lead_steps=self.lead_steps)

        # nowcasts placeholder
        nowcasts = []

        # interpolation
        for lead_step in range(self.lead_steps):
            nowcasts.append(_interpolator(self.input_data[-1], coord_source,
                                          coord_targets[lead_step],
                                          method=self.interpolation))

        # reshaping
        nowcasts = np.moveaxis(np.dstack(nowcasts), -1, 0)

        return nowcasts


class DenseRotation:
    """
    The basic class for the Dense model of the rainymotion library.

    To run your nowcasting model you first have to set up a class instance
    as follows:

    `model = Dense()`

    and then use class attributes to set up model parameters, e.g.:

    `model.of_method = "DIS"`

    All class attributes have default values, for getting started with
    nowcasting you must specify only `input_data` attribute which holds the
    latest radar data observations. After specifying the input data, you can
    run nowcasting model and produce the corresponding results of nowcasting
    using `.run()` method:

    `nowcasts = model.run()`

    Attributes
    ----------
    input_data: 3D numpy array (frames, dim_x, dim_y) of radar data for
                previous hours. "frames" dimension must be > 2.

    scaler: function, default=rainymotion.utils.RYScaler
        Corner identification and optical flow algorithms require specific data
        type to perform calculations: uint8. That means that you must specify
        the transformation function (i.e. "scaler") to convert the "input_data"
        to the range of integers [0, 255]. By default we are using RYScaler
        which converts precipitation depth (mm, float16) to "brightness"
        values (uint8).

    lead_steps: int, default=12
        Number of lead times for which we want to produce nowcasts. Must be > 0

    of_method: str, default="DIS", options=["DIS", "PCAFlow", "DeepFlow",
                                            "Farneback"]
        The optical flow method to obtain the dense representation (in every
        image pixel) of motion field. By default we use the Dense Inverse
        Search algorithm (DIS). PCAFlow, DeepFlow, and Farneback algoritms
        are also available to obtain motion field.

    advection: str, default="semi-lagrangian"
        The advection scheme we use for extrapolation of every image pixel
        into the imminent future.

    direction: str, default="backward", options=["forward", "backward"]
        The direction option of the advection scheme.

    interpolation: str, default="idw", options=["idw", "nearest", "linear"]
        The interpolation method we use to interpolate advected pixel values
        to the original grid of the radar image. By default we use inverse
        distance weightning interpolation (idw) as proposed in wradlib.ipol.Idw
        but interpolation techniques from scipy.interpolate (e.g., "nearest"
        or "linear") could also be used.

    Methods
    -------
    run(): perform calculation of nowcasts.
        Return 3D numpy array of shape (lead_steps, dim_x, dim_y).

    """

    def __init__(self):

        self.input_data = None

        self.scaler = RYScaler

        self.lead_steps = 12

        self.of_method = "DIS"

        self.direction = "backward"

        self.advection = "semi-lagrangian"

        self.interpolation = "idw"

    def run(self):
        """
        Run nowcasting calculations.

        Returns
        -------
        nowcasts : 3D numpy array of shape (lead_steps, dim_x, dim_y).

        """

        # scale input data to uint8 [0-255] with self.scaler
        scaled_data, c1, c2 = self.scaler(self.input_data)

        # calculate optical flow
        of = _calculate_of(scaled_data, method=self.of_method,
                           direction=self.direction)

        # advect pixels accordingly
        if self.advection == "constant-vector":
            coord_source, coord_targets = _advection_constant_vector(of, lead_steps=self.lead_steps)
        elif self.advection == "semi-lagrangian":
            coord_source, coord_targets = _advection_semi_lagrangian(of, lead_steps=self.lead_steps)

        # nowcasts placeholder
        nowcasts = []

        # interpolation
        for lead_step in range(self.lead_steps):
            nowcasts.append(_interpolator(self.input_data[-1], coord_source,
                            coord_targets[lead_step],
                            method=self.interpolation))

        # reshaping
        nowcasts = np.moveaxis(np.dstack(nowcasts), -1, 0)

        return nowcasts


class Persistence:

    """
    The basic class of the Eulerian Persistence model (Persistence)
    of the rainymotion library.

    To run your nowcasting model you first have to set up a class instance
    as follows:

    `model = Persistence()`

    and then use class attributes to set up model parameters, e.g.:

    `model.lead_steps = 12`

    For getting started with nowcasting you must specify only `input_data`
    attribute which holds the latest radar data observations.
    After specifying the input data, you can run nowcasting model and
    produce the corresponding results of nowcasting using `.run()` method:

    `nowcasts = model.run()`

    Attributes
    ----------

    input_data: 3D numpy array (frames, dim_x, dim_y) of radar data for
                previous hours. "frames" dimension must be > 2.

    lead_steps: int, default=12
        Number of lead times for which we want to produce nowcasts. Must be > 0

    Methods
    -------
    run(): perform calculation of nowcasts.
        Return 3D numpy array of shape (lead_steps, dim_x, dim_y).

    """

    def __init__(self):

        self.input_data = None

        self.lead_steps = 12

    def run(self):
        """
        Run nowcasting calculations.

        Returns
        -------
        nowcasts : 3D numpy array of shape (lead_steps, dim_x, dim_y).

        """

        last_frame = self.input_data[-1, :, :]

        forecast = np.dstack([last_frame for i in range(self.lead_steps)])

        return np.moveaxis(forecast, -1, 0).copy()