Upload run_v23.py with huggingface_hub
Browse files- run_v23.py +222 -0
run_v23.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run TrackFormer v23 on a storm's track history, in either of two modes:
|
| 2 |
+
|
| 3 |
+
IBTrACS-only (default, no --steering given): the model sees only the storm's own recent
|
| 4 |
+
positions/wind/pressure -- exactly what IBTrACS (or any best-track record) gives you for a past
|
| 5 |
+
storm, nothing else. The steering field and its 12h/24h history are zero-filled with an explicit
|
| 6 |
+
availability flag, the same "unavailable == exact zeros, not fabricated" convention used
|
| 7 |
+
throughout this project whenever a field genuinely isn't there.
|
| 8 |
+
|
| 9 |
+
Full data (--steering given): the model additionally sees a real deep-layer-mean steering-wind
|
| 10 |
+
patch (850/500/200 hPa u/v, weighted 0.269/0.500/0.231) around the storm -- for the current fix
|
| 11 |
+
and, if present, t-12h/t-24h. This is what the project's headline 434.96 km number requires; on
|
| 12 |
+
Typhoon Dolphin (2026) zeroing this field out shifted the 120h forecast position by ~600 km while
|
| 13 |
+
softening the intensity forecast only modestly (99 -> 90 kt) -- see the project's README for the
|
| 14 |
+
full ablation.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python run_v23.py --track my_storm.json --out forecast.json
|
| 18 |
+
python run_v23.py --track my_storm.json --steering my_steering.npz --out forecast.json
|
| 19 |
+
|
| 20 |
+
--track JSON format: a list of fixes, OLDEST to NEWEST, spaced 6 hours apart, ending at the fix to
|
| 21 |
+
forecast from ("now"). Up to 9 fixes are used (fewer is fine -- the model pads with the same
|
| 22 |
+
pre-genesis zero-fill it saw for young storms in training); extra leading fixes beyond 9 are
|
| 23 |
+
ignored.
|
| 24 |
+
[{"time": "2026-07-29T00:00", "lat": 14.1, "lon": 169.1, "vmax_kt": 121.6, "pres_hpa": 941},
|
| 25 |
+
{"time": "2026-07-29T06:00", "lat": 14.5, "lon": 168.4, "vmax_kt": 121.7, "pres_hpa": 941}]
|
| 26 |
+
`pres_hpa` may be null/omitted per-fix if unknown -- it is then treated as unavailable for that fix
|
| 27 |
+
(zero-filled, flagged), not fabricated.
|
| 28 |
+
|
| 29 |
+
--steering NPZ format (optional): float32 arrays of shape [2,17,17] (u,v in m/s, 2.5 deg
|
| 30 |
+
resolution, +-20 deg box centered on the storm), keyed by ISO time strings matching entries in
|
| 31 |
+
--track (e.g. "2026-07-29T06:00"). Only the LAST fix's key is required; keys for the fixes 12h and
|
| 32 |
+
24h before it are used for v23's temporal-history stack if present, and zero-filled (flagged
|
| 33 |
+
unavailable) if not -- so a steering file with only the current fix still runs, just without the
|
| 34 |
+
temporal-history benefit. See _fetch_dolphin_steering.py in the repo root for a working example of
|
| 35 |
+
building this from NOAA/NOMADS GFS analysis fields for a live storm, or from ERA5 for a past one.
|
| 36 |
+
"""
|
| 37 |
+
import argparse
|
| 38 |
+
import json
|
| 39 |
+
import math
|
| 40 |
+
import os
|
| 41 |
+
import sys
|
| 42 |
+
|
| 43 |
+
import numpy as np
|
| 44 |
+
import torch
|
| 45 |
+
|
| 46 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 47 |
+
from trackformer_v23 import build_v23, TMEAN, TSTD, DSC, TARGET_SCALE # noqa: E402
|
| 48 |
+
|
| 49 |
+
R = 111.2 # km per degree latitude
|
| 50 |
+
HIST = 9 # kinematic-history window length the model was trained with
|
| 51 |
+
|
| 52 |
+
_here = os.path.dirname(os.path.abspath(__file__))
|
| 53 |
+
_terrain = np.load(os.path.join(_here, "v23_terrain_wp.npz"))
|
| 54 |
+
_T_LAT, _T_LON, _LSM = _terrain["lat"], _terrain["lon"], _terrain["lsm"]
|
| 55 |
+
_LAND = _LSM > 0.5
|
| 56 |
+
_LAND_LAT = _T_LAT[np.where(_LAND)[0]]
|
| 57 |
+
_LAND_LON = _T_LON[np.where(_LAND)[1]]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def dist2land(lat_i, lon_i):
|
| 61 |
+
if len(_LAND_LAT) == 0:
|
| 62 |
+
return 3000.0
|
| 63 |
+
dlat = _LAND_LAT - lat_i
|
| 64 |
+
dlon = (_LAND_LON - lon_i) * math.cos(math.radians(lat_i))
|
| 65 |
+
return float(np.hypot(dlon * R, dlat * R).min())
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def load_track(path):
|
| 69 |
+
fixes = json.load(open(path))
|
| 70 |
+
fixes = fixes[-HIST:] if len(fixes) > HIST else fixes
|
| 71 |
+
times = [f["time"] for f in fixes]
|
| 72 |
+
lat = np.array([f["lat"] for f in fixes], dtype="float64")
|
| 73 |
+
lon = np.array([f["lon"] for f in fixes], dtype="float64")
|
| 74 |
+
vmax = np.array([f["vmax_kt"] for f in fixes], dtype="float64")
|
| 75 |
+
pres = np.array([f.get("pres_hpa", None) if f.get("pres_hpa", None) is not None else np.nan
|
| 76 |
+
for f in fixes], dtype="float64")
|
| 77 |
+
tns = np.array([np.datetime64(t).astype("datetime64[ns]").astype("int64") for t in times])
|
| 78 |
+
return times, tns, lat, lon, vmax, pres
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_window(times, tns, lat, lon, vmax, pres):
|
| 82 |
+
"""Kinematic/thermodynamic feature window -- same construction as this project's other
|
| 83 |
+
real-storm inference scripts (e.g. _dolphin_v23_v35.py / _noul_v33.py)."""
|
| 84 |
+
n = len(times)
|
| 85 |
+
base = n - 1
|
| 86 |
+
hidx = [max(0, base - HIST + 1 + k) for k in range(HIST)]
|
| 87 |
+
n_padded = max(0, HIST - 1 - base)
|
| 88 |
+
t0 = int(tns[base])
|
| 89 |
+
doy = (np.datetime64(times[base]) - np.datetime64(times[base][:4] + "-01-01")).astype(int) + 1
|
| 90 |
+
phase = 2 * math.pi * doy / 365.25
|
| 91 |
+
seq = np.zeros((HIST, 54), dtype="float32")
|
| 92 |
+
prev, pdir = -1, None
|
| 93 |
+
|
| 94 |
+
def mkm(a, b, c, d):
|
| 95 |
+
dlat = c - a; dlon = ((d - b + 180) % 360) - 180
|
| 96 |
+
return dlon * R * math.cos(math.radians((a + c) / 2)), dlat * R
|
| 97 |
+
|
| 98 |
+
for i, idx in enumerate(hidx):
|
| 99 |
+
e, n_ = mkm(lat[base], lon[base], lat[idx], lon[idx])
|
| 100 |
+
se, sn = (0., 0.) if prev < 0 else mkm(lat[prev], lon[prev], lat[idx], lon[idx])
|
| 101 |
+
f = seq[i]; f[0:4] = [e, n_, se, sn]
|
| 102 |
+
vv = [vmax[idx], pres[idx], np.nan, np.nan]
|
| 103 |
+
for j in range(4):
|
| 104 |
+
f[4 + j] = vv[j] if np.isfinite(vv[j]) else 0.
|
| 105 |
+
f[24:28] = [float(np.isfinite(x)) for x in vv]
|
| 106 |
+
f[21:23] = [math.sin(phase), math.cos(phase)]; f[23] = (t0 - int(tns[idx])) / 3.6e12
|
| 107 |
+
sp = math.hypot(se, sn); hs, hc = (se / sp, sn / sp) if (sp > 1e-3 and prev >= 0) else (0., 0.)
|
| 108 |
+
f[40], f[41], f[42] = hs, hc, sp
|
| 109 |
+
f[43] = (pdir[0] * hc - pdir[1] * hs) if (pdir and (hs or hc) and (pdir[0] or pdir[1])) else 0.
|
| 110 |
+
if prev >= 0:
|
| 111 |
+
dv = np.isfinite(vmax[prev]) and np.isfinite(vmax[idx])
|
| 112 |
+
dp = np.isfinite(pres[prev]) and np.isfinite(pres[idx])
|
| 113 |
+
f[44] = vmax[idx] - vmax[prev] if dv else 0.
|
| 114 |
+
f[45] = pres[idx] - pres[prev] if dp else 0.
|
| 115 |
+
f[46], f[47] = float(dv), float(dp)
|
| 116 |
+
lat_i, lon_i = lat[idx], lon[idx]
|
| 117 |
+
m = np.datetime64(times[idx]).astype("datetime64[M]").astype(int) % 12 + 1
|
| 118 |
+
d2l = dist2land(lat_i, lon_i % 360)
|
| 119 |
+
thermal = 0.5 * 23.44 * math.sin(2 * math.pi * (m - 3) / 12.0)
|
| 120 |
+
f[48] = lat_i; f[49] = abs(lat_i); f[50] = math.sin(math.radians(lon_i)); f[51] = math.cos(math.radians(lon_i))
|
| 121 |
+
f[52] = d2l; f[53] = max(0., min(31., 30. - 0.30 * abs(lat_i - thermal) ** 1.4))
|
| 122 |
+
if hs or hc:
|
| 123 |
+
pdir = (hs, hc)
|
| 124 |
+
prev = idx
|
| 125 |
+
|
| 126 |
+
seq_n = (seq - TMEAN) / TSTD
|
| 127 |
+
vpair = np.concatenate([seq[-1, 2:4], seq[-2, 2:4]]).astype("float32")
|
| 128 |
+
return seq_n, vpair, n_padded
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def load_steering(path, times):
|
| 132 |
+
"""Returns (slp[1,4,17,17], hist[1,8,17,17], have[1,2]) for the LAST fix in `times`. `path` may
|
| 133 |
+
be None -- then everything is zero-filled (the IBTrACS-only ablation)."""
|
| 134 |
+
if path is None:
|
| 135 |
+
return (np.zeros((1, 4, 17, 17), "float32"),
|
| 136 |
+
np.zeros((1, 8, 17, 17), "float32"),
|
| 137 |
+
np.zeros((1, 2), "float32"))
|
| 138 |
+
dlm = np.load(path)
|
| 139 |
+
now_t = np.datetime64(times[-1])
|
| 140 |
+
|
| 141 |
+
def key_at(back_h):
|
| 142 |
+
return str(now_t - np.timedelta64(back_h, "h"))
|
| 143 |
+
|
| 144 |
+
slp = np.zeros((1, 4, 17, 17), "float32")
|
| 145 |
+
now_key = times[-1]
|
| 146 |
+
if now_key in dlm:
|
| 147 |
+
uv = dlm[now_key]
|
| 148 |
+
slp[0, 2:4] = np.clip(uv / DSC[:, None, None], -4.0, 4.0)
|
| 149 |
+
elif key_at(0) in dlm:
|
| 150 |
+
uv = dlm[key_at(0)]
|
| 151 |
+
slp[0, 2:4] = np.clip(uv / DSC[:, None, None], -4.0, 4.0)
|
| 152 |
+
|
| 153 |
+
hist = np.zeros((1, 8, 17, 17), "float32")
|
| 154 |
+
have = np.zeros((1, 2), "float32")
|
| 155 |
+
cur = slp[0]
|
| 156 |
+
for c, back in enumerate((12, 24)):
|
| 157 |
+
k = key_at(back)
|
| 158 |
+
if k in dlm:
|
| 159 |
+
uv = dlm[k]
|
| 160 |
+
hist[0, c * 4 + 2:c * 4 + 4] = np.clip(uv / DSC[:, None, None], -4.0, 4.0)
|
| 161 |
+
have[0, c] = 1.0
|
| 162 |
+
else:
|
| 163 |
+
hist[0, c * 4:(c + 1) * 4] = cur
|
| 164 |
+
return slp, hist, have
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@torch.no_grad()
|
| 168 |
+
def forecast(models, times, tns, lat, lon, vmax, pres, steering_path):
|
| 169 |
+
seq_n, vpair, n_padded = build_window(times, tns, lat, lon, vmax, pres)
|
| 170 |
+
tr = torch.from_numpy(seq_n[None]); vp = torch.from_numpy(vpair[None])
|
| 171 |
+
slp, hist, have = load_steering(steering_path, times)
|
| 172 |
+
args = [tr, vp, torch.from_numpy(slp), torch.from_numpy(hist), torch.from_numpy(have)]
|
| 173 |
+
motion = torch.stack([m(*args)[0] for m in models]).mean(0)[0] * TARGET_SCALE
|
| 174 |
+
motion = motion.float().numpy() # [20, 17]
|
| 175 |
+
la, lo = float(lat[-1]), float(lon[-1])
|
| 176 |
+
lats, lons, vmaxs, presses = [], [], [], []
|
| 177 |
+
for L in range(20):
|
| 178 |
+
e, n_ = motion[L, 0], motion[L, 1]
|
| 179 |
+
la = la + n_ / R; lo = lo + e / (R * math.cos(math.radians(la)))
|
| 180 |
+
lats.append(la); lons.append(lo)
|
| 181 |
+
vmaxs.append(float(motion[L, 2])); presses.append(float(motion[L, 3]))
|
| 182 |
+
return lats, lons, vmaxs, presses, n_padded
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def main():
|
| 186 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 187 |
+
ap.add_argument("--track", required=True, help="track-history JSON, see module docstring")
|
| 188 |
+
ap.add_argument("--steering", default=None, help="steering NPZ (omit for IBTrACS-only mode)")
|
| 189 |
+
ap.add_argument("--seeds", default=os.path.join(_here, "v23_seed*.pt"), help="checkpoint glob")
|
| 190 |
+
ap.add_argument("--out", default=None, help="write forecast JSON here (default: stdout)")
|
| 191 |
+
args = ap.parse_args()
|
| 192 |
+
|
| 193 |
+
import glob
|
| 194 |
+
ckpts = sorted(glob.glob(args.seeds))
|
| 195 |
+
if not ckpts:
|
| 196 |
+
sys.exit(f"no checkpoints matched {args.seeds!r}")
|
| 197 |
+
models = []
|
| 198 |
+
for c in ckpts:
|
| 199 |
+
m = build_v23().eval()
|
| 200 |
+
m.load_state_dict(torch.load(c, map_location="cpu", weights_only=False)["model"])
|
| 201 |
+
models.append(m)
|
| 202 |
+
mode = "IBTrACS-only (steering zeroed)" if args.steering is None else f"full data ({args.steering})"
|
| 203 |
+
print(f"loaded {len(models)} v23 seeds, mode: {mode}", file=sys.stderr)
|
| 204 |
+
|
| 205 |
+
times, tns, lat, lon, vmax, pres = load_track(args.track)
|
| 206 |
+
lats, lons, vmaxs, presses, n_padded = forecast(models, times, tns, lat, lon, vmax, pres, args.steering)
|
| 207 |
+
|
| 208 |
+
out = {"issue_time": times[-1], "mode": mode, "base_lat": float(lat[-1]), "base_lon": float(lon[-1]),
|
| 209 |
+
"lead_hours": list(range(6, 121, 6)),
|
| 210 |
+
"lats": [round(float(x), 3) for x in lats], "lons": [round(float(x), 3) for x in lons],
|
| 211 |
+
"vmax_kt": [round(float(x), 1) for x in vmaxs], "pres_hpa": [round(float(x), 1) for x in presses],
|
| 212 |
+
"n_padded_history": n_padded}
|
| 213 |
+
text = json.dumps(out, indent=2)
|
| 214 |
+
if args.out:
|
| 215 |
+
open(args.out, "w").write(text)
|
| 216 |
+
print(f"wrote {args.out}", file=sys.stderr)
|
| 217 |
+
else:
|
| 218 |
+
print(text)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
main()
|