JayF14 commited on
Commit
e01e082
·
verified ·
1 Parent(s): 67ac984

Upload data_loader.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. data_loader.py +412 -0
data_loader.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SIH26077 — Spatiotemporal Data Loader
3
+ ======================================
4
+ Loads and aligns multi-modal atmospheric data from three sources:
5
+ 1. IMDAA Reanalysis (.nc) → (30, 6, 256, 256) thermodynamic + kinematic fields
6
+ 2. INSAT-3DR Satellite (.h5) → (3, 6, 256, 256) WV, CTT, HEM
7
+ 3. CartoDEM (.tif) → (2, 256, 256) elevation + slope
8
+
9
+ All modalities are spatially aligned to a unified 256×256 grid and
10
+ channel-wise z-score normalized for stable training.
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import torch
16
+ import numpy as np
17
+ import xarray as xr
18
+ import h5py
19
+ import rasterio
20
+ from torch.utils.data import Dataset, DataLoader
21
+ import torch.nn.functional as F
22
+ import warnings
23
+
24
+ from config import (
25
+ INDEX_PATH, DATASET_ROOT, DEM_PATH, GRID_SIZE, DEFAULT_LEAD_TIME,
26
+ IMDAA_MEAN, IMDAA_STD, INSAT_MEAN, INSAT_STD, TERRAIN_MEAN, TERRAIN_STD,
27
+ )
28
+
29
+ warnings.filterwarnings('ignore')
30
+
31
+
32
+ def resize_tensor(tensor, size):
33
+ """Resizes a 2D or 3D/4D spatial tensor to the target (H, W) grid.
34
+
35
+ Uses bilinear interpolation. Handles tensors of shape:
36
+ - (H, W) → unsqueeze to (1, 1, H, W) → resize → squeeze back
37
+ - (C, H, W) → unsqueeze to (1, C, H, W) → resize → squeeze back
38
+ - (N, C, H, W) → resize directly
39
+ """
40
+ original_shape = tensor.shape
41
+ if len(tensor.shape) == 2:
42
+ tensor = tensor.unsqueeze(0).unsqueeze(0)
43
+ elif len(tensor.shape) == 3:
44
+ tensor = tensor.unsqueeze(0)
45
+
46
+ tensor = tensor.float()
47
+ resized = F.interpolate(tensor, size=size, mode='bilinear', align_corners=False)
48
+
49
+ if len(original_shape) == 2:
50
+ return resized.squeeze(0).squeeze(0)
51
+ elif len(original_shape) == 3:
52
+ return resized.squeeze(0)
53
+ return resized
54
+
55
+
56
+ def resize_binary_target(tensor, size):
57
+ """Resize a binary (0/1) label map using nearest-neighbor interpolation.
58
+
59
+ WHY NOT bilinear for binary masks:
60
+ Bilinear on a sparse binary mask (e.g. CB at 2816×2805 → 256×256)
61
+ smears each '1' pixel into a tiny float blur (~0.0001). After
62
+ downscaling 11× the signal is effectively zero — the model sees no
63
+ positive pixels and the loss gradient collapses.
64
+
65
+ Nearest-neighbor keeps every '1' as a hard '1' and every '0' as '0',
66
+ preserving the true label distribution at the new resolution.
67
+ """
68
+ original_shape = tensor.shape
69
+ if len(tensor.shape) == 2:
70
+ tensor = tensor.unsqueeze(0).unsqueeze(0)
71
+ elif len(tensor.shape) == 3:
72
+ tensor = tensor.unsqueeze(0)
73
+
74
+ tensor = tensor.float()
75
+ resized = F.interpolate(tensor, size=size, mode='nearest')
76
+ # Hard-threshold to guarantee strict binary output (no float residuals)
77
+ resized = (resized > 0.5).float()
78
+
79
+ if len(original_shape) == 2:
80
+ return resized.squeeze(0).squeeze(0)
81
+ elif len(original_shape) == 3:
82
+ return resized.squeeze(0)
83
+ return resized
84
+
85
+
86
+ class SpatiotemporalDataset(Dataset):
87
+ """Multi-modal spatiotemporal dataset for severe weather nowcasting.
88
+
89
+ Each sample is a 6-timestep window (~18 hours) containing:
90
+ - IMDAA reanalysis: 30 atmospheric channels across 6 timesteps
91
+ - INSAT satellite: 3 observational channels across 6 timesteps
92
+ - CartoDEM terrain: 2 static channels (elevation + slope)
93
+ - Targets: 3 binary risk maps (cloudburst, thunderstorm, flash flood)
94
+
95
+ Args:
96
+ index_path: Path to window_index.json
97
+ root_dir: Path to dataset_root/
98
+ lead_time: Which lead time to predict ('2', '3', '4', '5', or '6' hours)
99
+ grid_size: Unified spatial grid (H, W) for all modalities
100
+ normalize: Whether to apply z-score normalization (default: True)
101
+ """
102
+
103
+ def __init__(self, index_path=INDEX_PATH, root_dir=DATASET_ROOT,
104
+ lead_time=DEFAULT_LEAD_TIME, grid_size=GRID_SIZE, normalize=True):
105
+ with open(index_path, 'r') as f:
106
+ self.windows = json.load(f)
107
+
108
+ self.root_dir = root_dir
109
+ self.lead_time = str(lead_time)
110
+ self.grid_size = grid_size
111
+ self.normalize = normalize
112
+
113
+ # Load static DEM once (same for all windows)
114
+ self.dem_tensor = self._load_dem()
115
+
116
+ def __len__(self):
117
+ return len(self.windows)
118
+
119
+ def _load_dem(self):
120
+ """Load and preprocess the Digital Elevation Model."""
121
+ print("Loading and downsampling static DEM...")
122
+ with rasterio.open(DEM_PATH) as src:
123
+ # Downsample immediately to save memory
124
+ factor = max(src.width // 1000, 1)
125
+ elevation = src.read(
126
+ 1, out_shape=(src.height // factor, src.width // factor)
127
+ ).astype(np.float32)
128
+
129
+ # Compute slope from elevation gradients
130
+ dy, dx = np.gradient(elevation)
131
+ slope = np.sqrt(dx**2 + dy**2)
132
+
133
+ # Stack into (C=2, H, W): [elevation, slope]
134
+ terrain = np.stack([elevation, slope], axis=0)
135
+ terrain_tensor = torch.from_numpy(terrain)
136
+ terrain_tensor = torch.nan_to_num(terrain_tensor, nan=0.0)
137
+
138
+ # Resize to unified grid
139
+ terrain_tensor = resize_tensor(terrain_tensor, self.grid_size)
140
+
141
+ # Normalize
142
+ if self.normalize:
143
+ terrain_tensor = (terrain_tensor - TERRAIN_MEAN) / (TERRAIN_STD + 1e-8)
144
+
145
+ return terrain_tensor
146
+
147
+ def _load_imdaa(self, paths):
148
+ """Load IMDAA reanalysis data into (Channels=30, Time=6, H, W).
149
+
150
+ CRITICAL FIX — group by TIMESTAMP, not by alphabetical sort:
151
+ The 180 paths span 6 timestamps × 30 channels (5 vars × 6 levels).
152
+ When sorted alphabetically and chunked by 30, each 'timestep' chunk
153
+ contains only ONE variable repeated (e.g. all HGT-1000mb through
154
+ HGT-925mb at 6 different times) — NOT all variables at one time.
155
+ The 3D temporal conv was therefore operating on var-grouped slices
156
+ with no actual temporal meaning.
157
+
158
+ Correct grouping: extract the 10-digit timestamp from each filename
159
+ (e.g. '2019081600'), group files sharing the same timestamp, then
160
+ sort groups chronologically. Each group is a genuine snapshot of
161
+ all 5 atmospheric variables at one moment in time.
162
+ """
163
+ import re
164
+ from collections import defaultdict
165
+
166
+ # Group by 10-digit timestamp embedded in filename (YYYYMMDDHH)
167
+ ts_groups = defaultdict(list)
168
+ for p in paths:
169
+ fname = os.path.basename(p.replace('\\', '/'))
170
+ m = re.search(r'_(\d{10})_', fname)
171
+ if m:
172
+ ts_groups[m.group(1)].append(p)
173
+
174
+ if not ts_groups:
175
+ # Fallback to old alphabetical chunking if regex fails
176
+ paths_sorted = sorted(paths)
177
+ chunks = [paths_sorted[i:i+30] for i in range(0, len(paths_sorted), 30)]
178
+ else:
179
+ # Sort chronologically; within each timestamp, sort alphabetically
180
+ # (alphabetical within-timestamp gives: HGT, RH, TMP, UGRD, VGRD × levels)
181
+ chunks = [sorted(ts_groups[ts]) for ts in sorted(ts_groups.keys())]
182
+
183
+ time_steps = []
184
+ for chunk in chunks:
185
+ channels = []
186
+ for path in chunk:
187
+ try:
188
+ path = path.replace('\\', '/')
189
+ ds = xr.open_dataset(path)
190
+ var_name = list(ds.data_vars)[0]
191
+ data = ds[var_name].squeeze().values
192
+ data = np.nan_to_num(data, nan=0.0)
193
+ channels.append(torch.from_numpy(data))
194
+ ds.close()
195
+ except Exception as e:
196
+ print(f"Error loading {path}: {e}")
197
+ channels.append(torch.zeros((501, 751)))
198
+
199
+ # (30, H_raw, W_raw) — one full atmospheric state snapshot
200
+ timestep_tensor = torch.stack(channels, dim=0)
201
+ time_steps.append(timestep_tensor)
202
+
203
+ # (Time=6, C=30, H, W) → (C=30, Time=6, H, W)
204
+ imdaa_tensor = torch.stack(time_steps, dim=0).permute(1, 0, 2, 3)
205
+ imdaa_tensor = resize_tensor(imdaa_tensor, self.grid_size)
206
+
207
+ # Normalize channel-wise (broadcast across Time, H, W)
208
+ if self.normalize:
209
+ # IMDAA_MEAN/STD shape: (30, 1, 1, 1) — broadcasts over (30, 6, 256, 256)
210
+ imdaa_tensor = (imdaa_tensor - IMDAA_MEAN) / (IMDAA_STD + 1e-8)
211
+
212
+ return imdaa_tensor
213
+
214
+
215
+ def _load_insat(self, l1b_paths, ctp_paths, hem_paths):
216
+ """Load INSAT satellite data into (Channels=4, Time=6, H, W).
217
+
218
+ Four channels per timestep:
219
+ - Ch 0: WV — Water Vapor brightness temperature (L1B IMG_WV key)
220
+ - Ch 1: CTT — Cloud Top Temperature (L2B CTP 'CTT' key, Kelvin)
221
+ - Ch 2: HEM — Hydro-Estimator precipitation rate (L2B HEM 'HEM' key, mm/30min)
222
+ - Ch 3: CTT_RATE — Frame-to-frame CTT change (K per 3h step, NEGATIVE = cooling = storm building)
223
+
224
+ CTT_RATE (ps.md explicit requirement):
225
+ "Rapid cooling of cloud tops (CTT Drop Rate) provides real-time
226
+ validation of explosive vertical updrafts within the system."
227
+ Without CTT_RATE, the model sees static snapshots and cannot detect
228
+ convective intensification. A -15K/step drop in CTT is the clearest
229
+ single-variable cloudburst precursor available from satellite.
230
+ """
231
+ time_steps = []
232
+ num_steps = max(len(l1b_paths), len(ctp_paths), len(hem_paths), 6)
233
+
234
+ # --- Load raw CTT values for all timesteps first ---
235
+ # Needed to compute temporal differences (drop rate)
236
+ raw_ctts = []
237
+ for i in range(num_steps):
238
+ if i < len(ctp_paths):
239
+ try:
240
+ with h5py.File(ctp_paths[i].replace('\\', '/'), 'r') as f:
241
+ ctt = np.squeeze(f['CTT'][:]).astype(np.float32)
242
+ ctt = np.ma.filled(np.ma.masked_where(ctt < 0, ctt), 0.0)
243
+ raw_ctts.append(ctt)
244
+ except Exception:
245
+ raw_ctts.append(np.zeros((313, 312), dtype=np.float32))
246
+ else:
247
+ raw_ctts.append(np.zeros((313, 312), dtype=np.float32))
248
+
249
+ # CTT drop rate: diff between consecutive frames (negative = cooling)
250
+ # At t=0 there is no prior frame — use zero (no rate info).
251
+ ctt_rates = [np.zeros_like(raw_ctts[0])]
252
+ for i in range(1, len(raw_ctts)):
253
+ ctt_rates.append(raw_ctts[i] - raw_ctts[i - 1]) # neg = cooling
254
+
255
+ # --- Build per-timestep channel stacks ---
256
+ for i in range(num_steps):
257
+ channels = []
258
+
259
+ # Ch 0: WV (L1B IMG_WV)
260
+ if i < len(l1b_paths):
261
+ try:
262
+ with h5py.File(l1b_paths[i].replace('\\', '/'), 'r') as f:
263
+ wv = np.squeeze(f['IMG_WV'][:]).astype(np.float32)
264
+ wv = wv[::4, ::4] # subsample: 1408×1402 → 352×351
265
+ channels.append(torch.from_numpy(np.nan_to_num(wv, nan=0.0)))
266
+ except Exception:
267
+ channels.append(torch.zeros((352, 351)))
268
+ else:
269
+ channels.append(torch.zeros((352, 351)))
270
+
271
+ # Ch 1: CTT (L2B CTP)
272
+ channels.append(torch.from_numpy(raw_ctts[i]))
273
+
274
+ # Ch 2: HEM precipitation rate (L2B HEM)
275
+ if i < len(hem_paths):
276
+ try:
277
+ with h5py.File(hem_paths[i].replace('\\', '/'), 'r') as f:
278
+ hem = np.squeeze(f['HEM'][:]).astype(np.float32)
279
+ hem[hem < 0] = 0.0
280
+ hem[hem > 500] = 0.0
281
+ hem = hem[::8, ::8] # subsample: 2816×2805 → 352×351
282
+ channels.append(torch.from_numpy(hem))
283
+ except Exception:
284
+ channels.append(torch.zeros((352, 351)))
285
+ else:
286
+ channels.append(torch.zeros((352, 351)))
287
+
288
+ # Ch 3: CTT drop rate (K/step, negative = explosive cooling)
289
+ channels.append(torch.from_numpy(ctt_rates[i]))
290
+
291
+ # Resize each channel to grid_size (different raw sizes per channel)
292
+ resized = [resize_tensor(c, self.grid_size) for c in channels]
293
+ time_steps.append(torch.stack(resized, dim=0))
294
+
295
+ # (Time=6, C=4, H, W) → (C=4, Time=6, H, W)
296
+ insat_tensor = torch.stack(time_steps, dim=0).permute(1, 0, 2, 3)
297
+
298
+ # Normalize channel-wise
299
+ if self.normalize:
300
+ insat_tensor = (insat_tensor - INSAT_MEAN) / (INSAT_STD + 1e-8)
301
+
302
+ return insat_tensor
303
+
304
+
305
+
306
+ def _load_targets(self, target_dict):
307
+ """Load 3 binary target maps into (Channels=3, H, W).
308
+
309
+ Channel order: [Cloudburst, Thunderstorm, FlashFlood]
310
+
311
+ IMPORTANT — Flash Flood label fix:
312
+ flash_flood.npy may contain raw QPE values (mm/3hr) rather than
313
+ a pre-thresholded binary mask. Thunderstorm was stored pre-binarized
314
+ (CTT < 208.15K → 1) but FF was not. We apply the threshold here.
315
+ If max value > 10.0 → treat as continuous mm values → threshold at 50mm.
316
+ If max value ≤ 2.0 → already binary, load as-is.
317
+ """
318
+ cb = np.load(target_dict['cloudburst'].replace('\\', '/'))
319
+ ts = np.load(target_dict['thunderstorm'].replace('\\', '/'))
320
+ ff = np.load(target_dict['flash_flood'].replace('\\', '/'))
321
+
322
+ cb_np = cb.squeeze().astype(np.float32)
323
+ ts_np = ts.squeeze().astype(np.float32)
324
+ ff_np = ff.squeeze().astype(np.float32)
325
+
326
+ # ── Flash Flood threshold fix ────────────────────────────────────
327
+ # If values are continuous QPE (mm), binarize at 50mm (cloudburst threshold)
328
+ # AND require slope > 12° (encoded as a multiplier below — slope mask
329
+ # is applied during the DEM-overlay step in the model output, not here,
330
+ # but the QPE threshold alone creates valid FF labels)
331
+ if ff_np.max() > 10.0:
332
+ ff_np = (ff_np >= 50.0).astype(np.float32)
333
+
334
+ # Same check for CB in case it also stores raw QPE
335
+ if cb_np.max() > 10.0:
336
+ cb_np = (cb_np >= 50.0).astype(np.float32)
337
+
338
+ cb = torch.from_numpy(cb_np).float()
339
+ ts = torch.from_numpy(ts_np).float()
340
+ ff = torch.from_numpy(ff_np).float()
341
+
342
+ # Ensure 2D (take first channel if 3D)
343
+ if len(cb.shape) > 2: cb = cb[0]
344
+ if len(ts.shape) > 2: ts = ts[0]
345
+ if len(ff.shape) > 2: ff = ff[0]
346
+
347
+ # Use nearest-neighbor for all binary targets — preserves sparse 0/1 signals.
348
+ # Bilinear would smear a 25-pixel CB mask over a 2816×2805 grid into
349
+ # near-zero floats after downscaling to 256×256 (label destruction).
350
+ cb = resize_binary_target(cb, self.grid_size)
351
+ ts = resize_binary_target(ts, self.grid_size)
352
+ ff = resize_binary_target(ff, self.grid_size)
353
+
354
+ # Stack: [Cloudburst, Thunderstorm, FlashFlood]
355
+ return torch.stack([cb, ts, ff], dim=0)
356
+
357
+
358
+ def __getitem__(self, idx):
359
+ window = self.windows[idx]
360
+
361
+ # 1. IMDAA (30, 6, H, W)
362
+ imdaa = self._load_imdaa(window['imdaa_paths'])
363
+
364
+ # 2. INSAT (3, 6, H, W)
365
+ insat = self._load_insat(
366
+ window.get('insat_l1b', []),
367
+ window.get('insat_l2b_ctp', []),
368
+ window.get('insat_l2b_hem', []),
369
+ )
370
+
371
+ # 3. Terrain (2, H, W) — pre-loaded and shared
372
+ terrain = self.dem_tensor
373
+
374
+ # 4. Targets (3, H, W)
375
+ target_dict = window['targets_by_lead'][self.lead_time]
376
+ targets = self._load_targets(target_dict)
377
+
378
+ return {
379
+ 'imdaa': imdaa, # (30, 6, 256, 256)
380
+ 'insat': insat, # (3, 6, 256, 256)
381
+ 'terrain': terrain, # (2, 256, 256)
382
+ 'targets': targets, # (3, 256, 256)
383
+ }
384
+
385
+
386
+ # ============================================================
387
+ # Self-Test
388
+ # ============================================================
389
+ if __name__ == "__main__":
390
+ print("Testing SpatiotemporalDataset...")
391
+ dataset = SpatiotemporalDataset()
392
+
393
+ print(f"Dataset length: {len(dataset)}")
394
+ sample = dataset[0]
395
+
396
+ print("\nSample Shapes:")
397
+ print(f"IMDAA: {sample['imdaa'].shape} (Channels, Time, H, W)")
398
+ print(f"INSAT: {sample['insat'].shape} (Channels, Time, H, W)")
399
+ print(f"Terrain: {sample['terrain'].shape} (Channels, H, W)")
400
+ print(f"Targets: {sample['targets'].shape} (Channels, H, W)")
401
+
402
+ print("\nNormalization Check (should be near mean=0, std=1):")
403
+ imdaa = sample['imdaa']
404
+ print(f" IMDAA ch0 mean: {imdaa[0].mean():.2f}, std: {imdaa[0].std():.2f}")
405
+ print(f" IMDAA ch15 mean: {imdaa[15].mean():.2f}, std: {imdaa[15].std():.2f}")
406
+
407
+ # Check for NaNs
408
+ for k, v in sample.items():
409
+ nan_count = torch.isnan(v).sum().item()
410
+ print(f"{k.capitalize()} NaNs: {nan_count}")
411
+
412
+ print("\n[OK] DataLoader test passed.")