––° ––mph ingest HOLD
Suomi NPP VIIRS satellite image of Hurricane Maria as a Category 4 on 18 September 2017

Maria · Suomi NPP VIIRS · 18 Sep 2017

Home Blog Writing the 24-hour delta

Weather

Writing the 24-hour delta

The RI threshold is one inequality. The code is what keeps it from becoming two definitions.

Sep 11, 2026

The research note treats rapid intensification as a 30\ge 30 kt gain in 2424 hours. That sentence is short on purpose. The failure mode is not the threshold. It is having the threshold in a paper and a different one in a notebook.

I write the inequality first, then I write the function that is allowed to implement it. If they disagree later, the function is wrong.

The inequality

Let vmax(t)v_{\max}(t) be the best-track maximum sustained wind at a 6-hour fix. The 24-hour forward delta is four steps on that cadence:

Δv24(t)=vmax(t+24h)vmax(t)\Delta v_{24}(t) = v_{\max}(t + 24\,\mathrm{h}) - v_{\max}(t)

A raw RI flag is then an indicator that also refuses post-landfall points. Those are a different physical problem, and they inflate a seasonal count if you leave them in.

RI(t)={1if Δv24(t)30kt and t is pre-landfall0otherwise\mathrm{RI}(t) = \begin{cases} 1 & \text{if }\Delta v_{24}(t)\ge 30\,\mathrm{kt}\text{ and }t\text{ is pre-landfall}\\ 0 & \text{otherwise} \end{cases}

A storm that stays above the threshold for 1818 hours is still one event. Consecutive trues collapse to the first fix:

onset(t)=RI(t)(1RI(t6h))\mathrm{onset}(t) = \mathrm{RI}(t)\,(1 - \mathrm{RI}(t-6\,\mathrm{h}))

The seasonal count NyN_y is the number of storms in season SyS_y with at least one onset, not the number of 6-hour flags.

Ny=sSy1{ts:onset(t)=1}N_y = \sum_{s\in S_y}\mathbf{1}\{\exists\,t\in s:\mathrm{onset}(t)=1\}

That last line is the one people skip when they groupby a boolean and call it a frequency.

A series you can see

Six-hour vmaxv_{\max} for a toy storm that jumps through 3030 kt between tt and t+24ht+24\mathrm{h}, then holds:

ttvmaxv_{\max} (kt)Δv24\Delta v_{24}RI\mathrm{RI}onset\mathrm{onset}
00Z00\mathrm{Z}5555+20+200000
06Z06\mathrm{Z}6060+35+351111
12Z12\mathrm{Z}7070+30+301100
18Z18\mathrm{Z}8585+15+150000
00Z+100\mathrm{Z}^{+1}9595
06Z+106\mathrm{Z}^{+1}100100

The useful timestamp is 06Z06\mathrm{Z}, not 12Z12\mathrm{Z}. If you plot every true RI\mathrm{RI} as a pin on a basin map, you draw a smear. Onset is the pin.

The function

The pandas that matches the three equations, and nothing else:

import pandas as pd

RI_KT = 30
STEPS = 4  # 24 h at a 6-hour cadence

def ri_onset(vmax: pd.Series, land: pd.Series) -> pd.Series:
    """True on the first 6-hour fix of a ≥30 kt / 24 h jump."""
    delta = vmax.shift(-STEPS) - vmax
    raw = (delta >= RI_KT) & ~land.astype(bool)
    return raw & ~raw.shift(1, fill_value=False)


def seasonal_count(onset: pd.Series, storm: pd.Series, year: pd.Series) -> pd.Series:
    flagged = onset.groupby([year, storm]).any()
    return flagged.groupby(level=0).sum()

shift(-4) is the t+24ht+24\mathrm{h} in Δv24\Delta v_{24}. The ~raw.shift(1) is the (1RI(t6h))(1-\mathrm{RI}(t-6\mathrm{h})). If you find yourself adding a rolling max or a “sustained for two fixes” clause, you are writing a second definition. Put it in the math first.

The same onset, if the record arrives as a typed fix instead of a frame:

export type IntensityFix = {
  t: string
  vmaxKt: number
  inland: boolean
}

export function onsetIndex(fixes: IntensityFix[]): number[] {
  const hits: number[] = []

  for (let i = 0; i + 4 < fixes.length; i++) {
    if (fixes[i].inland) continue
    const jump = fixes[i + 4].vmaxKt - fixes[i].vmaxKt
    const already = i > 0 && !fixes[i - 1].inland
      && fixes[i + 3].vmaxKt - fixes[i - 1].vmaxKt >= 30
    if (jump >= 30 && !already) hits.push(i)
  }

  return hits
}

I keep both. The frame is what I run on HURDAT2. The typed loop is what I want next to a service that has to emit “this storm just jumped” as an event, not as a column that someone will re-derive at 2 a.m.

What I will not do in the function

I will not smooth vmaxv_{\max} before the subtraction. A 6-hour best track is already an analysis. If a spike is wrong, that is a data problem, and it should fail a holdout, not disappear inside a rolling(3).mean().

I will not change 3030 to 2525 because a plot looks sparse. The Kaplan–DeMaria threshold is a convention. Conventions are allowed to be blunt. They are not allowed to drift per figure.

I will not count tRI(t)\sum_t \mathrm{RI}(t) and call it NyN_y. That quantity is hours spent intensifying, useful, and a different paper.

The earlier note is why the number matters after landfall. This one is only the contract: one inequality, one onset, one function, and a seasonal count that still means storms.