Source code for aurora.tracker

"""Copyright (c) Microsoft Corporation. Licensed under the MIT license."""

import logging
from datetime import datetime

import numpy as np
import pandas as pd
from scipy.ndimage import gaussian_filter, minimum_filter

from aurora.batch import Batch

__all__ = ["Tracker"]

logger = logging.getLogger(__file__)


class NoEyeException(Exception):
    """Raised when no eye can be found."""


def get_box(
    variable: np.ndarray,
    lats: np.ndarray,
    lons: np.ndarray,
    lat_min: float,
    lat_max: float,
    lon_min: float,
    lon_max: float,
):
    """Get a square box for a variable."""
    # Make latitude selection.
    lat_mask = (lat_min <= lats) & (lats <= lat_max)
    box = variable[..., lat_mask, :]
    lats = lats[lat_mask]

    # Make longitude selection. Be careful when wrapping around.
    lon_min = lon_min % 360
    lon_max = lon_max % 360
    if lon_min <= lon_max:
        lon_mask = (lon_min <= lons) & (lons <= lon_max)
        box = box[..., lon_mask]
        lons = lons[lon_mask]
    else:
        lon_mask1 = lon_min <= lons
        lon_mask2 = lons <= lon_max
        box = np.concatenate((box[..., lon_mask1], box[..., lon_mask2]), axis=-1)
        lons = np.concatenate((lons[lon_mask1], lons[lon_mask2]))

    return lats, lons, box


def havdist(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Distance between two latitude-longitude coordinates."""
    lat1, lat2 = np.deg2rad(lat1), np.deg2rad(lat2)
    lon1, lon2 = np.deg2rad(lon1), np.deg2rad(lon2)
    rad_earth_km = 6371
    inner = 1 - np.cos(lat2 - lat1) + np.cos(lat1) * np.cos(lat2) * (1 - np.cos(lon2 - lon1))
    return 2 * rad_earth_km * np.arcsin(np.sqrt(0.5 * inner))


def get_closest_min(
    variable: np.ndarray,
    lats: np.ndarray,
    lons: np.ndarray,
    lat: float,
    lon: float,
    delta_lat: float = 5,
    delta_lon: float = 5,
    minimum_cap_size: int = 8,
) -> tuple[float, float]:
    """Get the minimum in `variable` that is closest to `lat` and `lon`."""
    # Create a box centred around the current latitude and longitude.
    lats, lons, box = get_box(
        variable,
        lats,
        lons,
        lat - delta_lat,
        lat + delta_lat,
        lon - delta_lon,
        lon + delta_lon,
    )

    # Smooth to avoid local minima due to noise.
    box = gaussian_filter(box, sigma=1)

    # Find local minima.
    local_minima = minimum_filter(box, size=(minimum_cap_size, minimum_cap_size)) == box

    # Remove minima at the edges: these occur when the tracker fails.
    local_minima[0, :] = 0
    local_minima[-1, :] = 0
    local_minima[:, 0] = 0
    local_minima[:, -1] = 0

    # If no local minima are left, no eye can be found. Try the next one.
    if local_minima.sum() == 0:
        raise NoEyeException()

    # Return the latitude and longitude of the closest local minimum.
    lat_inds, lon_inds = zip(*np.argwhere(local_minima))
    dists = havdist(lats[list(lat_inds)], lons[list(lon_inds)], lat, lon)
    i = np.argmin(dists)

    return lats[lat_inds[i]], lons[lon_inds[i]]


def extrapolate(lats: list[float], lons: list[float]) -> tuple[float, float]:
    """Guess an initial latitude and longitude by extrapolating `lats` and `lons`."""
    assert len(lats) == len(lons)
    if len(lats) == 0:
        raise ValueError("Cannot extrapolate from empty lists.")
    elif len(lats) == 1:
        return lats[0], lons[0]
    else:
        # Linearly extrapolate using the last eight points.
        lats = lats[-8:]
        lons = lons[-8:]
        n = len(lats)
        fit = np.polyfit(np.arange(n), np.stack((lats, lons), axis=-1), 1)
        return np.polyval(fit, n)


[docs] class Tracker: """Simple tropical cyclone tracker. This algorithm was originally designed and implemented by Anna Allen. This particular implementation is by Wessel Bruinsma and features various improvements over the original design. """ def __init__( self, init_lat: float, init_lon: float, init_time: datetime, ) -> None: self.tracked_times: list[datetime] = [init_time] self.tracked_lats: list[float] = [init_lat] self.tracked_lons: list[float] = [init_lon] self.tracked_msls: list[float] = [np.nan] self.tracked_winds: list[float] = [np.nan] self.fails: int = 0
[docs] def results(self) -> pd.DataFrame: """Assemble the track into a convenient DataFrame.""" return pd.DataFrame( { "time": self.tracked_times, "lat": self.tracked_lats, "lon": self.tracked_lons, "msl": self.tracked_msls, "wind": self.tracked_winds, } )
[docs] def step(self, batch: Batch) -> None: """Track the next step. Args: batch (:class:`aurora.Batch`): Prediction. """ # Check that there is only one prediction. We don't support batched tracking. if len(batch.metadata.time) != 1: raise RuntimeError("Predictions don't have batch size one.") # No need to do tracking on the GPU. It's cheap. batch = batch.to("cpu") # Extract the relevant variables from the prediction. z700_index = list(batch.metadata.atmos_levels).index(700) z700 = batch.atmos_vars["z"][0, 0, z700_index].numpy() msl = batch.surf_vars["msl"][0, 0].numpy() u10 = batch.surf_vars["10u"][0, 0].numpy() v10 = batch.surf_vars["10v"][0, 0].numpy() wind = np.sqrt(u10 * u10 + v10 * v10) lsm = batch.static_vars["lsm"].numpy() lats = batch.metadata.lat.numpy() lons = batch.metadata.lon.numpy() time = batch.metadata.time[0] # Provide an initial guess by extrapolating. lat, lon = extrapolate(self.tracked_lats, self.tracked_lons) lat = max(min(lat, 90), -90) lon = lon % 360 def is_clear(lat: float, lon: float, delta: float) -> bool: """Is a box centred at `lat` and `lon` with "radius" `delta` clear of land?""" _, _, lsm_box = get_box( lsm, lats, lons, lat - delta, lat + delta, lon - delta, lon + delta, ) return lsm_box.max() < 0.5 # Did we "snap" from the guess to a real nearby minimum? snap = False # Try MSL with increasingly small boxes. for delta in [5, 4, 3, 2, 1.5]: try: if is_clear(lat, lon, delta): lat, lon = get_closest_min( msl, lats, lons, lat, lon, delta_lat=delta, delta_lon=delta, ) snap = True break except NoEyeException: pass if not snap: # MSL didn't work. Try Z700. If it works, try to refine with MSL. try: lat, lon = get_closest_min( z700, lats, lons, lat, lon, delta_lat=5, delta_lon=5, ) snap = True for delta in [5, 4, 3, 2, 1.5]: try: if is_clear(lat, lon, delta): lat, lon = get_closest_min( msl, lats, lons, lat, lon, delta_lat=delta, delta_lon=delta, ) break except NoEyeException: pass except NoEyeException: pass if not snap: self.fails += 1 if len(self.tracked_lats) > 1: logger.info(f"Failed at time {time}. Extrapolating in a silly way.") else: raise NoEyeException("Completely failed at the first step.") self.tracked_times.append(time) self.tracked_lats.append(lat) self.tracked_lons.append(lon) # Extract minimum MSL and maximum wind speed from a crop around the TC. _, _, msl_crop = get_box( msl, lats, lons, lat - 1.5, lat + 1.5, lon - 1.5, lon + 1.5, ) _, _, wind_crop = get_box( wind, lats, lons, lat - 1.5, lat + 1.5, lon - 1.5, lon + 1.5, ) self.tracked_msls.append(msl_crop.min()) self.tracked_winds.append(wind_crop.max())