euler314 commited on
Commit
8b7ca92
·
verified ·
1 Parent(s): 3465ef2

Upload run_v23.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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()