File size: 11,775 Bytes
fdb9863 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """Reusable, unvalidated pressure-driven wind/structure candidate.
Not a change to released neural weights or pressure fields. The initialization
uses ONLY the exact issue-time USA 1-minute wind and reported quadrant radii.
Future pressure deficits come from each genuine forecast member. An initialized
outer wind envelope is scaled by sqrt(deficit / issue deficit), motivated by
gradient-wind/Holland balance. RMW is explicitly PERSISTENCE, not a measured or
learned future RMW. Missing observations are never invented as reference data.
Nominal targets match USA radius units, thresholds and quadrant-max geometry,
but this candidate has no validated 1-minute/10-m wind calibration. Strict
official wind/radius skill scores MUST remain disabled.
"""
from __future__ import annotations
from datetime import datetime
import math
import numpy as np
VERSION = 'pressure-scaled-issue-structure-experimental-v1'
QUADRANTS = ('NE', 'SE', 'SW', 'NW')
THRESHOLDS = (34, 50, 64)
METRICS = ('wind_estimate_kt', 'rmw_persistence_km') + tuple(
f'r{t}_{q}_estimate_km' for t in THRESHOLDS for q in QUADRANTS)
MAX_RADIUS_KM = 1500.0
REFERENCES = (
'https://noaa-ocs-modeling.github.io/PaHM/html/models.html',
'https://www.ncei.noaa.gov/sites/default/files/2025-09/IBTrACS_v04r01_column_documentation.pdf',
)
DEFINITIONS = dict(
units='km', quantity_kind='radius_not_diameter',
thresholds_kt=list(THRESHOLDS), quadrant_order=list(QUADRANTS),
quadrant_statistic='maximum_extent_within_quadrant',
target_wind_averaging_seconds=60,
validated_wind_averaging_seconds=None,
validated_surface_height_m=None,
rmw='issue-time reported RMW carried forward unchanged; persistence baseline',
official_scoring_allowed=False,
)
def number(row, key, low=0.0, high=10000.0):
try:
value = float(row.get(key, '').strip())
except (TypeError, ValueError, AttributeError):
return None
return value if math.isfinite(value) and low <= value <= high else None
def radius_km(row, key, *, rmw=False):
"""IBTrACS USA radii are nautical-mile radii, not diameters."""
value = number(row, key, 0.001 if rmw else 0.0, 999.0)
return None if value is None else value * 1.852
def ambient_pressure(field, latitude, longitude, center):
"""Use actual coarse-model cells for ambient pressure, not inner-core size.
Median available cells 600..1000 km from the predicted center; require
>=2 physical cells in each quadrant. Record sample count/coverage. Missing
quadrants, out-of-domain centers and nonphysical values fail closed.
"""
p = np.asarray(field, dtype=float)
lat, lon, c = (np.asarray(a, dtype=float) for a in (latitude, longitude, center))
if p.shape != (len(lat), len(lon)) or c.shape != (2,):
raise ValueError('Pressure grid/coordinate shape mismatch')
if not np.isfinite(c).all() or not (0 < c[0] < 60 and 100 < c[1] < 180):
return dict(value_hpa=None, status='outside_model_domain', cells=0)
yy, xx = np.meshgrid(lat, lon, indexing='ij')
a, b = np.deg2rad(yy-c[0]), np.deg2rad(xx-c[1])
h = np.sin(a/2)**2 + np.cos(np.deg2rad(yy))*np.cos(np.deg2rad(c[0]))*np.sin(b/2)**2
distance = 2*6371.0088*np.arcsin(np.sqrt(np.clip(h, 0, 1)))
mask = (600 <= distance) & (distance <= 1000)
physical = np.isfinite(p) & (800 <= p) & (p <= 1100)
qmask = ((yy >= c[0]) & (xx >= c[1]), (yy < c[0]) & (xx >= c[1]),
(yy < c[0]) & (xx < c[1]), (yy >= c[0]) & (xx < c[1]))
counts = [int((mask & physical & q).sum()) for q in qmask]
usable = mask & physical
result = dict(cells=int(usable.sum()), candidate_cells=int(mask.sum()),
quadrant_cells=dict(zip(QUADRANTS, counts)),
annulus_km=[600, 1000], statistic='median_available_physical_cells')
if min(counts) < 2:
return dict(result, value_hpa=None, status='insufficient_ambient_coverage')
return dict(result, value_hpa=float(np.median(p[usable])), status='available')
def initialize(row, issue_time_utc, ambient_hpa):
"""Allowlisted fields from ONE exact issue row; no future labels accepted."""
issue = datetime.fromisoformat(issue_time_utc.replace('Z', '+00:00'))
if issue.utcoffset() is None or issue.utcoffset().total_seconds() != 0:
raise ValueError('Issue time must be UTC')
if row.get('ISO_TIME') != issue.strftime('%Y-%m-%d %H:%M:%S'):
raise ValueError('Initialization is not the exact issue-time report')
# Model pressure is initialized from TOKYO_PRES in the released runner.
# USA wind/radii are a NEW diagnostic input, not silently injected into the
# neural issue_intensity tensor, and not obtained from future observations.
wind = number(row, 'USA_WIND', 0.01, 249.0)
pressure = number(row, 'TOKYO_PRES', 800.0, 1100.0)
rmw = radius_km(row, 'USA_RMW', rmw=True)
pn = float(ambient_hpa) if ambient_hpa is not None else None
if pn is not None and not (math.isfinite(pn) and 800 <= pn <= 1100):
raise ValueError('Invalid issue ambient pressure')
radii = {f'r{t}_{q}': radius_km(row, f'USA_R{t}_{q}')
for t in THRESHOLDS for q in QUADRANTS}
deficit = pn-pressure if pn is not None and pressure is not None else None
b = None
if wind is not None and deficit is not None and deficit > 1:
# Fixed literature-motivated shape range, not selected on these storms.
b = float(np.clip(1.15*math.e*(wind*.514444444444/.8)**2/(deficit*100), 1, 2.5))
return dict(method=VERSION, storm_id=row.get('SID'), issue_time_utc=issue_time_utc,
input_wind_kt=wind, input_model_pressure_hpa=pressure,
ambient_pressure_hpa=pn, pressure_deficit_hpa=deficit,
rmw_persistence_km=rmw, native_quadrant_radii_km=radii,
holland_inspired_tail_b=b, source_agency=row.get('USA_AGENCY', '').strip(),
source_track_type=row.get('TRACK_TYPE', '').strip(),
usa_reference_center=[number(row, 'USA_LAT', -90, 90), number(row, 'USA_LON', -180, 180)],
model_origin=[number(row, 'LAT', -90, 90), number(row, 'LON', -180, 180)],
definitions=DEFINITIONS,
mixed_agency_initialization='USA 1-minute wind/radii; model TOKYO pressure. No conversion between wind periods.',
calibration='none; initialization is issue assimilation, not future-target fitting')
def envelope(initial, quadrant):
"""Strictly decreasing outer envelope; expose inconsistent issue reports."""
vmax, rmw = initial['input_wind_kt'], initial['rmw_persistence_km']
if vmax is None or rmw is None:
return None, ['missing_issue_wind_or_rmw']
points = [(float(rmw), float(vmax))]
candidates, warnings = [], []
for t in reversed(THRESHOLDS):
r = initial['native_quadrant_radii_km'][f'r{t}_{quadrant}']
if r is None:
continue
if r == 0:
# Reported absence is not a blank. A constant-peak quadrant envelope
# cannot honor an absent lower isotach: fail closed for this quadrant.
if t <= vmax:
return None, [f'reported_zero_r{t}_incompatible_with_constant_peak_envelope']
continue
if t >= vmax or r <= rmw:
warnings.append(f'inconsistent_initial_r{t}_{quadrant}_not_assimilated')
continue
candidates.append((r, float(t)))
for radius, value in sorted(candidates):
if radius <= points[-1][0] or value >= points[-1][1]:
warnings.append('nonmonotonic_issue_isotach_not_assimilated')
else:
points.append((radius, value))
return points, warnings
def outer_radius(points, initial_b, initial_threshold):
"""Solve the analytic outer envelope; do not fabricate a grid-edge crossing."""
if initial_threshold <= 0:
return None
if initial_threshold > points[0][1]:
return 0.0 # complete candidate envelope never reaches the threshold
for (r0, v0), (r1, v1) in zip(points[:-1], points[1:]):
if v1 <= initial_threshold <= v0:
fraction = math.log(initial_threshold/v0)/math.log(v1/v0)
return math.exp(math.log(r0) + fraction*math.log(r1/r0))
r, v = points[-1]
# Holland's far-field wind asymptote V~r^(-B/2); this is an explicit
# parametric extrapolation, not a native resolved wind or pressure field.
result = r*(v/initial_threshold)**(2/initial_b)
return float(result) if result <= MAX_RADIUS_KM else None
def diagnose(initial, pressure_hpa, ambient, *, track_valid=True):
result = {key: None for key in METRICS}
result.update(method=VERSION, experimental=True, status='unavailable', warnings=[],
official_scoring_allowed=False, ambient=ambient)
if not track_valid or ambient['status'] == 'outside_model_domain':
result['status'] = 'outside_model_domain_or_invalid_track'
return result
wind, d0 = initial['input_wind_kt'], initial['pressure_deficit_hpa']
if wind is None or d0 is None or d0 <= 1:
result['status'] = 'missing_issue_wind_or_positive_pressure_deficit'
return result
if ambient['value_hpa'] is None or not math.isfinite(pressure_hpa) or not 800 <= pressure_hpa <= 1100:
result['status'] = 'missing_physical_model_pressure'
return result
future_deficit = ambient['value_hpa']-pressure_hpa
if future_deficit <= 0:
result['status'] = 'model_has_no_positive_vortex_pressure_deficit'
return result
scale = math.sqrt(future_deficit/d0)
predicted_wind = wind*scale
if not math.isfinite(predicted_wind) or predicted_wind >= 250:
result['status'] = 'wind_outside_candidate_physical_range'
return result
result.update(wind_estimate_kt=float(predicted_wind), status='experimental_issue_anchored_estimate',
pressure_deficit_hpa=float(future_deficit), pressure_scaling=float(scale),
rmw_persistence_km=initial['rmw_persistence_km'])
for quadrant in QUADRANTS:
points, warnings = envelope(initial, quadrant)
result['warnings'] += warnings
if points is None:
continue # wind remains usable even if structure was not observed
for threshold in THRESHOLDS:
radius = outer_radius(points, initial['holland_inspired_tail_b'], threshold/scale)
result[f'r{threshold}_{quadrant}_estimate_km'] = radius
if radius is None:
result['warnings'].append(f'r{threshold}_{quadrant}_beyond_{MAX_RADIUS_KM:g}_km_candidate_range')
return result
def summarize(members, *, expected_members):
if len(members) != expected_members or expected_members < 1:
raise ValueError('Actual member diagnostics do not match the claimed count')
summary = dict(method=VERSION, members=len(members), definitions=DEFINITIONS,
aggregation='diagnose each physical forecast member then equal-weight mean',
estimates={}, official_scoring_allowed=False)
for key in METRICS:
values = [m[key] for m in members if m[key] is not None and math.isfinite(m[key])]
complete = len(values) == expected_members
summary['estimates'][key] = dict(
mean=float(np.mean(values)) if complete else None,
p10=float(np.quantile(values, .1)) if complete else None,
p90=float(np.quantile(values, .9)) if complete else None,
valid_members=len(values), total_members=expected_members)
return summary
def official_radius_mae(*_args, **_kwargs):
raise ValueError('Strict official scoring blocked: no validated surface-wind averaging-period calibration; RMW is persistence')
|