griffingoodwin04 commited on
Commit
a0c6371
·
1 Parent(s): 9bd18f8

Add ablation study and spatial performance scripts; update pipeline configuration and dataset handling

Browse files
.gitignore CHANGED
@@ -155,4 +155,11 @@ wandb/
155
 
156
  .claude/
157
  Untracked/
158
- foxes.yml
 
 
 
 
 
 
 
 
155
 
156
  .claude/
157
  Untracked/
158
+ foxes.yml
159
+ .git-backup
160
+ /forecasting/training/convert_flux_to_npy.py
161
+ /forecasting/inference/extract_patch_flux.py
162
+ /.merged_train_config.yaml
163
+ /.merged_inference_config.yaml
164
+ /.merged_evaluate_config.yaml
165
+ .npz
__init__.py ADDED
File without changes
analysis/spatial_performance.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flux-Weighted Error Heatmap on Solar Disk
3
+ ==========================================
4
+ For each matched flux map, accumulates:
5
+ mae_sum[i,j] += flux[i,j] * |log10 error|
6
+ bias_sum[i,j] += flux[i,j] * log10 error
7
+ weight[i,j] += flux[i,j]
8
+
9
+ Then normalizes to get flux-weighted mean error per patch.
10
+
11
+ Usage
12
+ -----
13
+ python analysis/spatial_performance.py
14
+
15
+ Outputs
16
+ -------
17
+ analysis/flux_weighted_errors_t0.npz — accumulation cache
18
+ analysis/performance_heatmap_all.png
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ import numpy as np
24
+ import pandas as pd
25
+ import matplotlib.pyplot as plt
26
+ from concurrent.futures import ProcessPoolExecutor, as_completed
27
+ from tqdm import tqdm
28
+ from pathlib import Path
29
+ from cmap import Colormap
30
+
31
+ PROJECT_ROOT = Path(__file__).parent.parent
32
+ sys.path.insert(0, str(PROJECT_ROOT))
33
+ from forecasting.inference.evaluation import setup_barlow_font
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Paths — edit here
37
+ # ---------------------------------------------------------------------------
38
+ FLUX_DIR = "/Volumes/T9/FOXES_Data/flux/"
39
+ PREDICTIONS_CSV = "/Volumes/T9/FOXES_Misc/batch_results/vit/vit_predictions_test.csv"
40
+ OUT_DIR = Path(__file__).parent
41
+ GRID_SIZE = 64 # 512px / 8px patch size
42
+ BIN_SIZE = 1 # downsample factor (1 = full 64×64 resolution)
43
+ CROP_FACTOR = 1.1 # AIA images cropped at 1.1 solar radii
44
+ SOLAR_RADIUS_PATCHES = (GRID_SIZE / 2) / CROP_FACTOR # ≈ 29.1 patches
45
+
46
+ # Only patches above this percentile (per flux map) contribute.
47
+ # 0 = include all non-zero patches.
48
+ FLUX_THRESHOLD_PERCENTILE = 0
49
+
50
+ # Percentile cap for colorbar scaling (applied to non-NaN values).
51
+ # e.g. 99 clips the top 1% of values so detail in the bulk is visible.
52
+ VMAX_PERCENTILE = 99.9
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def normalize_ts(series: pd.Series) -> pd.Series:
59
+ return pd.to_datetime(
60
+ series.astype(str).str.replace("_", ":", regex=False), utc=False,
61
+ ).dt.floor("s")
62
+
63
+
64
+ def _ts_key(fpath: str) -> str:
65
+ raw = os.path.basename(fpath).replace('.npy', '').replace('_', ':')
66
+ return pd.Timestamp(raw).floor('s').isoformat()
67
+
68
+
69
+ def load_predictions(predictions_csv: str) -> pd.DataFrame:
70
+ df = pd.read_csv(predictions_csv)
71
+ df["timestamp"] = normalize_ts(df["timestamp"])
72
+ df["log_pred"] = np.log10(df["predictions"])
73
+ df["log_gt"] = np.log10(df["groundtruth"])
74
+ df["log_error"] = df["log_pred"] - df["log_gt"]
75
+ df["log_abs_error"] = df["log_error"].abs()
76
+ print(f"Loaded {len(df)} predictions")
77
+ return df
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Heatmap accumulation
82
+ # ---------------------------------------------------------------------------
83
+
84
+ # NOTE: module-level for ProcessPoolExecutor (spawn on macOS)
85
+ def _accumulate_flux_map(args):
86
+ fpath, log_abs_error, log_error, threshold_pct = args
87
+ fmap = np.load(fpath).astype(np.float64)
88
+ active = fmap[fmap > 0]
89
+ if active.size == 0:
90
+ return None
91
+ if threshold_pct > 0:
92
+ thresh = np.percentile(active, threshold_pct)
93
+ fmap = np.where(fmap >= thresh, fmap, 0.0)
94
+ else:
95
+ fmap = np.where(fmap > 0, fmap, 0.0)
96
+ total = fmap.sum()
97
+ if total == 0:
98
+ return None
99
+ fmap = fmap / total # normalise: each timestamp contributes equal total weight
100
+ return fmap * log_abs_error, fmap * log_error, fmap
101
+
102
+
103
+ def compute_flux_weighted_errors(flux_dir: str, df: pd.DataFrame, cache_path: Path,
104
+ threshold_pct: int = FLUX_THRESHOLD_PERCENTILE) -> dict:
105
+ cache_path = cache_path.with_stem(f"{cache_path.stem}_t{threshold_pct}")
106
+
107
+ if cache_path.exists():
108
+ print(f"Loading cached flux-weighted error maps from {cache_path}")
109
+ data = np.load(cache_path)
110
+ result = {}
111
+ n = float(data['count'])
112
+ w = data['weight']
113
+ mae = np.where(w > 0, data['mae_sum'] / n, np.nan) if n > 0 else np.full_like(w, np.nan)
114
+ bias = np.where(w > 0, data['bias_sum'] / n, np.nan) if n > 0 else np.full_like(w, np.nan)
115
+ return mae, bias, w
116
+
117
+ lookup = {}
118
+ for _, row in df.iterrows():
119
+ key = pd.Timestamp(row['timestamp']).floor('s').isoformat()
120
+ lookup[key] = (float(row['log_abs_error']), float(row['log_error']))
121
+
122
+ shape = (GRID_SIZE, GRID_SIZE)
123
+ mae_sum = np.zeros(shape)
124
+ bias_sum = np.zeros(shape)
125
+ weight = np.zeros(shape)
126
+ count = 0
127
+
128
+ files = sorted([os.path.join(flux_dir, f)
129
+ for f in os.listdir(flux_dir) if f.endswith('.npy')])
130
+ args_list = []
131
+ for fpath in files:
132
+ try:
133
+ ts_key = _ts_key(fpath)
134
+ except Exception:
135
+ continue
136
+ if ts_key not in lookup:
137
+ continue
138
+ abs_err, err = lookup[ts_key]
139
+ args_list.append((fpath, abs_err, err, threshold_pct))
140
+
141
+ print(f"Matched {len(args_list)} / {len(files)} flux maps")
142
+
143
+ with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
144
+ futures = {executor.submit(_accumulate_flux_map, a): i
145
+ for i, a in enumerate(args_list)}
146
+ for future in tqdm(as_completed(futures), total=len(args_list),
147
+ desc="Accumulating flux-weighted errors"):
148
+ result = future.result()
149
+ if result is None:
150
+ continue
151
+ mae_c, bias_c, flux_c = result
152
+ mae_sum += mae_c
153
+ bias_sum += bias_c
154
+ weight += flux_c
155
+ count += 1
156
+
157
+ np.savez(cache_path, mae_sum=mae_sum, bias_sum=bias_sum,
158
+ weight=weight, count=np.array(count))
159
+ print(f"Saved → {cache_path}")
160
+
161
+ mae = np.where(weight > 0, mae_sum / count, np.nan) if count > 0 else np.full(shape, np.nan)
162
+ bias = np.where(weight > 0, bias_sum / count, np.nan) if count > 0 else np.full(shape, np.nan)
163
+ return mae, bias, weight
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Plot
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def _bin_grid(grid: np.ndarray, bin_size: int) -> np.ndarray:
171
+ if bin_size == 1:
172
+ return grid
173
+ h, w = grid.shape
174
+ bh, bw = h // bin_size, w // bin_size
175
+ cropped = grid[:bh * bin_size, :bw * bin_size]
176
+ return np.nanmean(cropped.reshape(bh, bin_size, bw, bin_size), axis=(1, 3))
177
+
178
+
179
+ def plot_flux_weighted_heatmap(mae_grid: np.ndarray, bias_grid: np.ndarray,
180
+ weight_grid: np.ndarray, out_path: Path,
181
+ subtitle: str = "", bin_size: int = BIN_SIZE,
182
+ vmax_pct: int = VMAX_PERCENTILE):
183
+ setup_barlow_font()
184
+ text_color = "#111111"
185
+ theta = np.linspace(0, 2 * np.pi, 300)
186
+
187
+ mae_b = _bin_grid(mae_grid, bin_size)
188
+ bias_b = _bin_grid(bias_grid, bin_size)
189
+ weight_b = _bin_grid(weight_grid, bin_size)
190
+ n_bins = mae_b.shape[0]
191
+ cy, cx = n_bins / 2, n_bins / 2
192
+
193
+ r_limb = SOLAR_RADIUS_PATCHES / bin_size
194
+
195
+ mae_vmax = np.nanpercentile(mae_b, vmax_pct)
196
+ mae_norm = plt.Normalize(vmin=0, vmax=mae_vmax)
197
+
198
+ bias_cap = np.nanpercentile(np.abs(bias_b), vmax_pct)
199
+ bias_norm = plt.Normalize(vmin=-bias_cap, vmax=bias_cap)
200
+
201
+ panels = [
202
+ (mae_b, r"Flux-Weighted MAE (log$_{10}$ Space)", Colormap('cmocean:thermal').to_mpl(), mae_norm),
203
+ (bias_b, r"Flux-Weighted MBE (log$_{10}$ Space)", Colormap('cmasher:fusion_r').to_mpl(), bias_norm),
204
+ # (np.log10(np.where(weight_b > 0, weight_b, np.nan)),
205
+ # r"log$_{10}$ Accumulated Flux", "viridis", None),
206
+ ]
207
+
208
+ fig, axes = plt.subplots(1, 2, figsize=(10, 5))
209
+ fig.patch.set_facecolor("white")
210
+
211
+ for ax, (grid, title, cmap, norm) in zip(axes, panels):
212
+ im = ax.imshow(grid, origin="lower", cmap=cmap, norm=norm,
213
+ interpolation="bilinear", extent=[0, n_bins, 0, n_bins])
214
+ cbar = fig.colorbar(im, ax=ax, shrink=0.82)
215
+ cbar.ax.tick_params(labelsize=9, colors=text_color)
216
+ def _fmt(x, _):
217
+ m, e = f"{x:.2e}".split("e")
218
+ return f"{m}e{int(e)}"
219
+ cbar.ax.yaxis.set_major_formatter(plt.matplotlib.ticker.FuncFormatter(_fmt))
220
+ for lbl in cbar.ax.get_yticklabels():
221
+ lbl.set_fontfamily("Barlow")
222
+ lbl.set_fontsize(9)
223
+ lbl.set_color(text_color)
224
+ #cbar.set_label(title, fontsize=9, color=text_color, fontfamily="Barlow")
225
+
226
+ ax.plot(cx + r_limb * np.cos(theta), cy + r_limb * np.sin(theta),
227
+ color="#4488FF", linestyle="--", linewidth=1.2, alpha=0.8,
228
+ label=f"Solar Limb")
229
+
230
+ tick_bins = np.linspace(0, n_bins, 7)
231
+ tick_labels = [f"{int((t - n_bins / 2) * bin_size)}" for t in tick_bins]
232
+ ax.set_xticks(tick_bins); ax.set_xticklabels(tick_labels)
233
+ ax.set_yticks(tick_bins); ax.set_yticklabels(tick_labels)
234
+
235
+ ax.set_title(title, fontsize=10, color=text_color, fontfamily="Barlow")
236
+ ax.set_xlabel("Solar X (ViT Patches From Center)", fontsize=9,
237
+ color=text_color, fontfamily="Barlow")
238
+ ax.set_ylabel("Solar Y (ViT Patches From Center)", fontsize=9,
239
+ color=text_color, fontfamily="Barlow")
240
+ ax.tick_params(labelsize=8, colors=text_color)
241
+ ax.legend(fontsize=7, facecolor="white", edgecolor="grey", loc="upper right",)
242
+ for spine in ax.spines.values():
243
+ spine.set_color(text_color)
244
+
245
+ plt.tight_layout()
246
+ plt.savefig(out_path, dpi=400, bbox_inches="tight", facecolor="white")
247
+ plt.show()
248
+ print(f"Saved → {out_path}")
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Main
253
+ # ---------------------------------------------------------------------------
254
+
255
+ if __name__ == "__main__":
256
+ import argparse
257
+ parser = argparse.ArgumentParser()
258
+ parser.add_argument("--flux_dir", default=FLUX_DIR)
259
+ parser.add_argument("--predictions_csv", default=PREDICTIONS_CSV)
260
+ parser.add_argument("--out_dir", default=str(OUT_DIR))
261
+ args = parser.parse_args()
262
+
263
+ out = Path(args.out_dir)
264
+ out.mkdir(parents=True, exist_ok=True)
265
+
266
+ df = load_predictions(args.predictions_csv)
267
+
268
+ mae, bias, weight = compute_flux_weighted_errors(
269
+ args.flux_dir, df, out / "flux_weighted_errors.npz"
270
+ )
271
+ plot_flux_weighted_heatmap(mae, bias, weight,
272
+ out / "performance_heatmap_all.png",
273
+ subtitle="All flares")
forecasting/data_loaders/SDOAIA_dataloader.py CHANGED
@@ -63,7 +63,7 @@ class AIA_GOESDataset(torch.utils.data.Dataset):
63
  Strategy for balancing dataset classes.
64
  """
65
 
66
- def __init__(self, aia_dir, sxr_dir, wavelengths=[94, 131, 171, 193, 211, 304], sxr_transform=None,
67
  target_size=(512, 512), cadence=1, reference_time=None, only_prediction=False, oversample=False,
68
  flare_threshold=1e-5, balance_strategy='upsample_minority'):
69
  self.aia_dir = Path(aia_dir).resolve()
@@ -311,6 +311,130 @@ class AIA_GOESDataset(torch.utils.data.Dataset):
311
  return timestamp
312
 
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  class AIA_GOESDataModule(LightningDataModule):
315
  """
316
  PyTorch Lightning DataModule for managing AIA-GOES datasets.
 
63
  Strategy for balancing dataset classes.
64
  """
65
 
66
+ def __init__(self, aia_dir, sxr_dir, wavelengths=[94, 131, 171, 193, 211, 304, 335], sxr_transform=None,
67
  target_size=(512, 512), cadence=1, reference_time=None, only_prediction=False, oversample=False,
68
  flare_threshold=1e-5, balance_strategy='upsample_minority'):
69
  self.aia_dir = Path(aia_dir).resolve()
 
311
  return timestamp
312
 
313
 
314
+ class NoisyAIA_GOESDataset(AIA_GOESDataset):
315
+ """
316
+ Ablation dataset that applies Gaussian noise to specific AIA wavelength channels.
317
+
318
+ Inherits all behavior from AIA_GOESDataset and injects additive Gaussian noise
319
+ on the specified wavelengths after loading, enabling channel-wise ablation studies.
320
+
321
+ Parameters
322
+ ----------
323
+ noisy_wavelengths : list of int
324
+ Subset of wavelengths to corrupt with noise (e.g. [171, 193]).
325
+ Must be a subset of the `wavelengths` argument.
326
+ noise_std : float or dict
327
+ Standard deviation of the Gaussian noise. If a float, the same std is
328
+ applied to all noisy channels. If a dict mapping wavelength -> std, each
329
+ channel gets its own scale.
330
+ *args, **kwargs
331
+ Forwarded to AIA_GOESDataset.
332
+ """
333
+
334
+ def __init__(self, *args, noisy_wavelengths, noise_std=1.0, **kwargs):
335
+ super().__init__(*args, **kwargs)
336
+ self.noisy_wavelengths = noisy_wavelengths
337
+ # Build a per-channel std lookup
338
+ if isinstance(noise_std, dict):
339
+ self.noise_std = noise_std
340
+ else:
341
+ self.noise_std = {w: noise_std for w in noisy_wavelengths}
342
+
343
+ invalid = set(noisy_wavelengths) - set(self.wavelengths)
344
+ if invalid:
345
+ raise ValueError(f"noisy_wavelengths {invalid} not in dataset wavelengths {self.wavelengths}")
346
+
347
+ def __getitem__(self, idx):
348
+ aia_img, sxr_val = super().__getitem__(idx) # (H, W, C)
349
+
350
+ for wav in self.noisy_wavelengths:
351
+ c = self.wavelengths.index(wav)
352
+ std = self.noise_std[wav]
353
+ noise = torch.randn_like(aia_img[..., c]) * std
354
+ aia_img[..., c] = aia_img[..., c] + noise
355
+
356
+ return aia_img, sxr_val
357
+
358
+
359
+ class NoisyAIA_GOESDataModule(LightningDataModule):
360
+ """
361
+ DataModule for the Gaussian-noise ablation study.
362
+
363
+ Wraps NoisyAIA_GOESDataset for test/val splits while keeping training
364
+ clean (or optionally noisy too). Noise is applied only at evaluation time
365
+ by default so the pre-trained model is evaluated without retraining.
366
+
367
+ Parameters
368
+ ----------
369
+ noisy_wavelengths : list of int
370
+ Wavelengths to corrupt during evaluation.
371
+ noise_std : float or dict
372
+ Noise standard deviation(s); see NoisyAIA_GOESDataset.
373
+ apply_noise_to_train : bool, optional
374
+ If True, also corrupt the training split (default: False).
375
+ All other parameters match AIA_GOESDataModule.
376
+ """
377
+
378
+ def __init__(self, aia_train_dir, aia_val_dir, aia_test_dir,
379
+ sxr_train_dir, sxr_val_dir, sxr_test_dir,
380
+ sxr_norm, noisy_wavelengths, noise_std=1.0,
381
+ apply_noise_to_train=False,
382
+ batch_size=64, num_workers=4,
383
+ wavelengths=[94, 131, 171, 193, 211, 304, 335],
384
+ cadence=1, reference_time=None):
385
+ super().__init__()
386
+ self.aia_train_dir = aia_train_dir
387
+ self.aia_val_dir = aia_val_dir
388
+ self.aia_test_dir = aia_test_dir
389
+ self.sxr_train_dir = sxr_train_dir
390
+ self.sxr_val_dir = sxr_val_dir
391
+ self.sxr_test_dir = sxr_test_dir
392
+ self.sxr_norm = sxr_norm
393
+ self.noisy_wavelengths = noisy_wavelengths
394
+ self.noise_std = noise_std
395
+ self.apply_noise_to_train = apply_noise_to_train
396
+ self.batch_size = batch_size
397
+ self.num_workers = num_workers
398
+ self.wavelengths = wavelengths
399
+ self.cadence = cadence
400
+ self.reference_time = reference_time
401
+
402
+ def _make_dataset(self, aia_dir, sxr_dir, noisy):
403
+ cls = NoisyAIA_GOESDataset if noisy else AIA_GOESDataset
404
+ kwargs = dict(
405
+ aia_dir=aia_dir,
406
+ sxr_dir=sxr_dir,
407
+ sxr_transform=SXRLogNormTransform(self.sxr_norm[0], self.sxr_norm[1]),
408
+ target_size=(512, 512),
409
+ wavelengths=self.wavelengths,
410
+ cadence=self.cadence,
411
+ reference_time=self.reference_time,
412
+ )
413
+ if noisy:
414
+ kwargs["noisy_wavelengths"] = self.noisy_wavelengths
415
+ kwargs["noise_std"] = self.noise_std
416
+ return cls(**kwargs)
417
+
418
+ def setup(self, stage=None):
419
+ self.train_ds = self._make_dataset(
420
+ self.aia_train_dir, self.sxr_train_dir, noisy=self.apply_noise_to_train
421
+ )
422
+ self.val_ds = self._make_dataset(self.aia_val_dir, self.sxr_val_dir, noisy=True)
423
+ self.test_ds = self._make_dataset(self.aia_test_dir, self.sxr_test_dir, noisy=True)
424
+
425
+ def train_dataloader(self):
426
+ return DataLoader(self.train_ds, batch_size=self.batch_size,
427
+ shuffle=True, num_workers=self.num_workers, prefetch_factor=4)
428
+
429
+ def val_dataloader(self):
430
+ return DataLoader(self.val_ds, batch_size=self.batch_size,
431
+ shuffle=False, num_workers=self.num_workers, prefetch_factor=4)
432
+
433
+ def test_dataloader(self):
434
+ return DataLoader(self.test_ds, batch_size=self.batch_size,
435
+ shuffle=False, num_workers=self.num_workers, prefetch_factor=1)
436
+
437
+
438
  class AIA_GOESDataModule(LightningDataModule):
439
  """
440
  PyTorch Lightning DataModule for managing AIA-GOES datasets.
forecasting/inference/ablation_inference.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ablation Inference Script — Gaussian Noise Channel Masking
3
+ ==========================================================
4
+
5
+ Runs inference with the pretrained model while applying Gaussian noise to
6
+ specific AIA wavelength channels, one condition at a time. This lets you
7
+ measure how much each channel (or combination of channels) contributes to
8
+ forecast skill.
9
+
10
+ Each ablation condition produces its own output CSV so results can be
11
+ compared directly against the clean baseline.
12
+ """
13
+
14
+ import argparse
15
+ import re
16
+ import sys
17
+ import gc
18
+ import pandas as pd
19
+ import torch
20
+ import numpy as np
21
+ from pathlib import Path
22
+ import yaml
23
+ from tqdm import tqdm
24
+
25
+ PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute()
26
+ sys.path.insert(0, str(PROJECT_ROOT))
27
+
28
+ from forecasting.data_loaders.SDOAIA_dataloader import AIA_GOESDataset, NoisyAIA_GOESDataset
29
+ import forecasting.models as models
30
+ # Reuse inference helpers from the main inference script
31
+ from forecasting.inference.inference import load_model_from_config, evaluate_model_on_dataset
32
+
33
+
34
+ def build_dataset(config_data, noisy_wavelengths, noise_std):
35
+ """
36
+ Build an AIA_GOESDataset (or NoisyAIA_GOESDataset if wavelengths are given).
37
+
38
+ Parameters
39
+ ----------
40
+ config_data : dict
41
+ noisy_wavelengths : list of int
42
+ Empty list → clean baseline (plain AIA_GOESDataset).
43
+ noise_std : float or dict
44
+
45
+ Returns
46
+ -------
47
+ dataset : torch.utils.data.Dataset
48
+ """
49
+ aia_dir = config_data['data']['aia_dir']
50
+ sxr_dir = config_data['data']['sxr_dir']
51
+ wavelengths = config_data['wavelengths']
52
+ prediction_only = config_data.get('prediction_only', 'false').lower() == 'true'
53
+
54
+ common_kwargs = dict(
55
+ aia_dir=aia_dir,
56
+ sxr_dir=sxr_dir,
57
+ wavelengths=wavelengths,
58
+ only_prediction=prediction_only,
59
+ )
60
+
61
+ if noisy_wavelengths:
62
+ return NoisyAIA_GOESDataset(
63
+ **common_kwargs,
64
+ noisy_wavelengths=noisy_wavelengths,
65
+ noise_std=noise_std,
66
+ )
67
+ else:
68
+ return AIA_GOESDataset(**common_kwargs)
69
+
70
+
71
+ def run_condition(model, dataset, label, config_data, model_params, output_dir):
72
+ """
73
+ Run inference for a single ablation condition and save results to CSV.
74
+
75
+ Parameters
76
+ ----------
77
+ model : torch.nn.Module
78
+ dataset : torch.utils.data.Dataset
79
+ label : str
80
+ Human-readable name for this condition (used as filename stem).
81
+ config_data : dict
82
+ model_params : dict
83
+ output_dir : Path
84
+ """
85
+ times = dataset.samples
86
+ total_samples = len(times)
87
+ batch_size = model_params.get('batch_size', 10)
88
+ input_size = model_params.get('input_size', 512)
89
+ patch_size = model_params.get('patch_size', 16)
90
+ save_weights = not model_params.get('no_weights', True)
91
+ save_flux = not model_params.get('no_flux', True)
92
+ prediction_only = config_data.get('prediction_only', 'false').lower() == 'true'
93
+
94
+ print(f"\n{'='*60}")
95
+ print(f" Condition: {label} ({total_samples} samples)")
96
+ print(f"{'='*60}")
97
+
98
+ timestamps, predictions, ground_truths = [], [], []
99
+
100
+ pbar = tqdm(
101
+ evaluate_model_on_dataset(
102
+ model, dataset, batch_size, times, config_data,
103
+ save_weights, input_size, patch_size, save_flux,
104
+ ),
105
+ total=total_samples,
106
+ desc=label,
107
+ unit="sample",
108
+ ncols=100,
109
+ )
110
+
111
+ for prediction, sxr, _weight, _flux, idx in pbar:
112
+ predictions.append(float(prediction.item() if hasattr(prediction, 'item') else prediction))
113
+ ground_truths.append(0.0 if prediction_only else
114
+ float(sxr.item() if hasattr(sxr, 'item') else sxr))
115
+ timestamps.append(str(times[idx]))
116
+
117
+ output_path = output_dir / f"{label}.csv"
118
+ pd.DataFrame({
119
+ 'timestamp': timestamps,
120
+ 'predictions': predictions,
121
+ 'groundtruth': ground_truths,
122
+ }).to_csv(output_path, index=False)
123
+
124
+ print(f" Saved → {output_path}")
125
+
126
+ # Free dataset memory between conditions
127
+ gc.collect()
128
+ if torch.cuda.is_available():
129
+ torch.cuda.empty_cache()
130
+
131
+
132
+ def resolve_config_variables(config_dict):
133
+ """Recursively resolve ${variable} references within config."""
134
+ variables = {k: v for k, v in config_dict.items()
135
+ if isinstance(v, str) and not v.startswith('${')}
136
+
137
+ def substitute(obj):
138
+ if isinstance(obj, dict):
139
+ return {k: substitute(v) for k, v in obj.items()}
140
+ elif isinstance(obj, list):
141
+ return [substitute(item) for item in obj]
142
+ elif isinstance(obj, str):
143
+ for match in re.finditer(r'\$\{([^}]+)\}', obj):
144
+ var = match.group(1)
145
+ if var in variables:
146
+ obj = obj.replace(f'${{{var}}}', variables[var])
147
+ return obj
148
+ return obj
149
+
150
+ return substitute(config_dict)
151
+
152
+
153
+ def main():
154
+ parser = argparse.ArgumentParser(description="Ablation inference with Gaussian noise masking.")
155
+ parser.add_argument('-config', type=str, required=True,
156
+ help='Path to the ablation inference YAML config.')
157
+ args = parser.parse_args()
158
+
159
+ with open(args.config, 'r') as f:
160
+ config_data = yaml.load(f, Loader=yaml.SafeLoader)
161
+ config_data = resolve_config_variables(config_data)
162
+ sys.modules['models'] = models
163
+
164
+ model_params = config_data.get('model_params', {})
165
+ ablation_cfg = config_data.get('ablation', {})
166
+ noise_std = ablation_cfg.get('noise_std', 1.0)
167
+ conditions = ablation_cfg.get('conditions', [])
168
+
169
+ if not conditions:
170
+ raise ValueError("No ablation conditions defined in config under 'ablation.conditions'.")
171
+
172
+ output_dir = Path(config_data['output_dir'])
173
+ output_dir.mkdir(parents=True, exist_ok=True)
174
+
175
+ print(f"Loading model...")
176
+ model = load_model_from_config(config_data)
177
+
178
+ for condition in conditions:
179
+ label = condition.get('label', 'unnamed')
180
+ noisy_wavelengths = condition.get('wavelengths', [])
181
+
182
+ dataset = build_dataset(config_data, noisy_wavelengths, noise_std)
183
+ run_condition(model, dataset, label, config_data, model_params, output_dir)
184
+
185
+ print(f"\nAll conditions complete. Results saved to: {output_dir}")
186
+
187
+
188
+ if __name__ == '__main__':
189
+ main()
forecasting/inference/ablation_inference_config.yaml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # FOXES Ablation Inference Configuration
3
+ # =============================================================================
4
+ # Used by ablation_inference.py to run Gaussian noise channel-masking ablation.
5
+ #
6
+ # Usage:
7
+ # python ablation_inference.py -config ablation_inference_config.yaml
8
+ #
9
+ # Variables
10
+ # ---------
11
+ # Define top-level string keys and reference them anywhere with ${key}.
12
+
13
+ base_dir: "/Volumes/T9/FOXES_Data"
14
+ checkpoint: "/Users/griffingoodwin/Downloads/FOXES_Model_Checkpoint.ckpt"
15
+
16
+ model: "ViTLocal"
17
+ wavelengths: [94, 131, 171, 193, 211, 304, 335]
18
+ prediction_only: "false"
19
+
20
+ data:
21
+ aia_dir: "${base_dir}/AIA_processed/val"
22
+ sxr_dir: "${base_dir}/SXR_processed/val"
23
+ sxr_norm_path: "${base_dir}/SXR_processed/normalized_sxr.npy"
24
+ checkpoint_path: "${checkpoint}"
25
+
26
+ # Output directory — each condition saves <label>.csv here
27
+ output_dir: "${base_dir}/inference/ablation"
28
+
29
+ model_params:
30
+ input_size: 512
31
+ patch_size: 8
32
+ batch_size: 10
33
+ no_weights: true # skip attention saving for speed
34
+ no_flux: true
35
+
36
+ # -----------------------------------------------------------------------------
37
+ # Ablation conditions
38
+ # -----------------------------------------------------------------------------
39
+ # noise_std: standard deviation of Gaussian noise applied in normalized tensor space.
40
+ # Each condition names a wavelength subset to corrupt; empty list = clean baseline.
41
+ # You can also pass a per-wavelength dict, e.g. {171: 0.5, 193: 2.0}
42
+ ablation:
43
+ noise_std: 1.0
44
+ conditions:
45
+ - label: baseline
46
+ wavelengths: []
47
+ - label: ablate_94
48
+ wavelengths: [94]
49
+ - label: ablate_131
50
+ wavelengths: [131]
51
+ - label: ablate_171
52
+ wavelengths: [171]
53
+ - label: ablate_193
54
+ wavelengths: [193]
55
+ - label: ablate_211
56
+ wavelengths: [211]
57
+ - label: ablate_304
58
+ wavelengths: [304]
59
+ - label: ablate_335
60
+ wavelengths: [335]
61
+ - label: ablate_all
62
+ wavelengths: [94, 131, 171, 193, 211, 304, 335]
pipeline_config.yaml CHANGED
@@ -7,6 +7,14 @@
7
  # python run_pipeline.py --config pipeline_config.yaml --steps all
8
  # python run_pipeline.py --config pipeline_config.yaml --steps train,inference,flare_analysis
9
  # python run_pipeline.py --list
 
 
 
 
 
 
 
 
10
 
11
  # -----------------------------------------------------------------------------
12
  # HuggingFace download (step: hf_download)
@@ -14,7 +22,7 @@
14
  # Use this instead of download_aia + download_sxr + preprocess + split.
15
  # -----------------------------------------------------------------------------
16
  hf_download:
17
- config: "download/hf_download_config.yaml" # points to hf_download_config.yaml
18
 
19
  # -----------------------------------------------------------------------------
20
  # Shared date range (used by download_aia and download_sxr)
@@ -26,36 +34,36 @@ end_date: "2014-07-08 00:00:00"
26
  # AIA download (step: download_aia)
27
  # -----------------------------------------------------------------------------
28
  aia:
29
- download_dir: "/Volumes/T9/Data_FOXES/AIA_raw"
30
  email: "" # Must be registered at http://jsoc.stanford.edu
31
- cadence: 1 # Minutes between frames
32
 
33
  # -----------------------------------------------------------------------------
34
  # SXR download (step: download_sxr)
35
  # -----------------------------------------------------------------------------
36
  sxr:
37
- save_dir: "/Volumes/T9/Data_FOXES/SXR_raw"
38
 
39
  # -----------------------------------------------------------------------------
40
  # Preprocessing (step: preprocess)
41
  # -----------------------------------------------------------------------------
42
  preprocess:
43
- config: "data/pipeline_config.yaml" # PipelineConfig for process_data_pipeline.py
44
 
45
  # -----------------------------------------------------------------------------
46
  # SXR normalization (step: normalize)
47
  # -----------------------------------------------------------------------------
48
  normalize:
49
- sxr_dir: "/Volumes/T9/Data_FOXES/SXR_processed/train"
50
- output_path: "/Volumes/T9/Data_FOXES/SXR_processed/normalized_sxr.npy"
51
 
52
  # -----------------------------------------------------------------------------
53
  # Train/val/test split (step: split)
54
  # Runs split_data.py once for AIA and once for SXR.
55
  # -----------------------------------------------------------------------------
56
  split:
57
- aia_input_dir: "/Volumes/T9/Data_FOXES/AIA_processed" # splits into AIA_processed/train|val|test
58
- sxr_input_dir: "/Volumes/T9/Data_FOXES/SXR_processed" # splits into SXR_processed/train|val|test
59
  train_start: "2014-07-01"
60
  train_end: "2014-07-05"
61
  val_start: "2014-07-06"
@@ -68,14 +76,14 @@ split:
68
  # -----------------------------------------------------------------------------
69
  train:
70
  config: "forecasting/training/train_config.yaml"
71
- overrides: # Any key from train_config.yaml can go here
72
- base_data_dir: "/Volumes/T9/Data_FOXES"
73
- base_checkpoint_dir: "/Volumes/T9/Data_FOXES"
74
  epochs: 150
75
  batch_size: 6
76
  wandb:
77
  run_name: "pipeline-run"
78
- entity: jayantbiradar619-university-of-arizona # Use your exact W&B username
79
  project: Paper
80
  job_type: training
81
  tags:
@@ -89,32 +97,53 @@ train:
89
  # -----------------------------------------------------------------------------
90
  inference:
91
  config: "forecasting/inference/local_config.yaml"
92
- overrides: # Any key from local_config.yaml can go here
93
  data:
94
- aia_dir: "/Volumes/T9/Data_FOXES/AIA_processed"
95
- sxr_dir: "/Volumes/T9/Data_FOXES/SXR_processed"
96
- sxr_norm_path: "/Volumes/T9/Data_FOXES/SXR_processed/normalized_sxr.npy"
97
- checkpoint_path: "/Users/griffingoodwin/Downloads/FOXES_Model_Checkpoint.ckpt" # update to actual checkpoint
98
- output_path: "/Volumes/T9/Data_FOXES/inference/predictions.csv"
99
  prediction_only: "false"
100
  paths:
101
- data_dir: "/Volumes/T9/Data_FOXES"
102
- predictions_csv: "/Volumes/T9/Data_FOXES/inference/predictions.csv"
103
- aia_path: "/Volumes/T9/Data_FOXES/AIA_processed/test"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  # -----------------------------------------------------------------------------
106
  # Evaluation (step: evaluate)
107
  # -----------------------------------------------------------------------------
108
  evaluate:
109
  config: "forecasting/inference/evaluation_config.yaml"
110
- overrides: # Any key from evaluation_config.yaml can go here
111
  model_predictions:
112
- main_model_csv: "/Volumes/T9/Data_FOXES/inference/predictions.csv"
113
  data:
114
- aia_dir: "/Volumes/T9/Data_FOXES/AIA_processed/val"
115
- weight_path: "/Volumes/T9/Data_FOXES/inference/weights"
116
  evaluation:
117
- output_dir: "/Volumes/T9/Data_FOXES/inference/evaluation"
118
  time_range:
119
  start_time: "2023-01-01T00:00:00"
120
  end_time: "2023-12-31T23:59:59"
 
7
  # python run_pipeline.py --config pipeline_config.yaml --steps all
8
  # python run_pipeline.py --config pipeline_config.yaml --steps train,inference,flare_analysis
9
  # python run_pipeline.py --list
10
+ #
11
+ # Variables
12
+ # ---------
13
+ # Define top-level string keys and reference them anywhere with ${key}.
14
+ # Change base_dir or checkpoint once and every path updates automatically.
15
+
16
+ base_dir: "/Volumes/T9/FOXES_Data"
17
+ checkpoint: "/Users/griffingoodwin/Downloads/FOXES_Model_Checkpoint.ckpt"
18
 
19
  # -----------------------------------------------------------------------------
20
  # HuggingFace download (step: hf_download)
 
22
  # Use this instead of download_aia + download_sxr + preprocess + split.
23
  # -----------------------------------------------------------------------------
24
  hf_download:
25
+ config: "download/hf_download_config.yaml"
26
 
27
  # -----------------------------------------------------------------------------
28
  # Shared date range (used by download_aia and download_sxr)
 
34
  # AIA download (step: download_aia)
35
  # -----------------------------------------------------------------------------
36
  aia:
37
+ download_dir: "${base_dir}/AIA_raw"
38
  email: "" # Must be registered at http://jsoc.stanford.edu
39
+ cadence: 1 # Minutes between frames
40
 
41
  # -----------------------------------------------------------------------------
42
  # SXR download (step: download_sxr)
43
  # -----------------------------------------------------------------------------
44
  sxr:
45
+ save_dir: "${base_dir}/SXR_raw"
46
 
47
  # -----------------------------------------------------------------------------
48
  # Preprocessing (step: preprocess)
49
  # -----------------------------------------------------------------------------
50
  preprocess:
51
+ config: "data/pipeline_config.yaml"
52
 
53
  # -----------------------------------------------------------------------------
54
  # SXR normalization (step: normalize)
55
  # -----------------------------------------------------------------------------
56
  normalize:
57
+ sxr_dir: "${base_dir}/SXR_processed/train"
58
+ output_path: "${base_dir}/SXR_processed/normalized_sxr.npy"
59
 
60
  # -----------------------------------------------------------------------------
61
  # Train/val/test split (step: split)
62
  # Runs split_data.py once for AIA and once for SXR.
63
  # -----------------------------------------------------------------------------
64
  split:
65
+ aia_input_dir: "${base_dir}/AIA_processed"
66
+ sxr_input_dir: "${base_dir}/SXR_processed"
67
  train_start: "2014-07-01"
68
  train_end: "2014-07-05"
69
  val_start: "2014-07-06"
 
76
  # -----------------------------------------------------------------------------
77
  train:
78
  config: "forecasting/training/train_config.yaml"
79
+ overrides:
80
+ base_data_dir: "${base_dir}"
81
+ base_checkpoint_dir: "${base_dir}"
82
  epochs: 150
83
  batch_size: 6
84
  wandb:
85
  run_name: "pipeline-run"
86
+ entity: jayantbiradar619-university-of-arizona
87
  project: Paper
88
  job_type: training
89
  tags:
 
97
  # -----------------------------------------------------------------------------
98
  inference:
99
  config: "forecasting/inference/local_config.yaml"
100
+ overrides:
101
  data:
102
+ aia_dir: "${base_dir}/AIA_processed"
103
+ sxr_dir: "${base_dir}/SXR_processed"
104
+ sxr_norm_path: "${base_dir}/SXR_processed/normalized_sxr.npy"
105
+ checkpoint_path: "${checkpoint}"
106
+ output_path: "${base_dir}/inference/predictions.csv"
107
  prediction_only: "false"
108
  paths:
109
+ data_dir: "${base_dir}"
110
+ predictions_csv: "${base_dir}/inference/predictions.csv"
111
+ aia_path: "${base_dir}/AIA_processed/test"
112
+
113
+ # -----------------------------------------------------------------------------
114
+ # Ablation study (step: ablation)
115
+ # -----------------------------------------------------------------------------
116
+ ablation:
117
+ config: "forecasting/inference/ablation_inference_config.yaml"
118
+ overrides:
119
+ data:
120
+ aia_dir: "${base_dir}/AIA_processed/test"
121
+ sxr_dir: "${base_dir}/SXR_processed/test"
122
+ sxr_norm_path: "${base_dir}/SXR_processed/normalized_sxr.npy"
123
+ checkpoint_path: "${checkpoint}"
124
+ output_dir: "${base_dir}/inference/ablation"
125
+
126
+ # -----------------------------------------------------------------------------
127
+ # Spatial performance (step: spatial_performance)
128
+ # -----------------------------------------------------------------------------
129
+ spatial_performance:
130
+ flux_dir: "${base_dir}/flux"
131
+ predictions_csv: "${base_dir}/inference/predictions.csv"
132
+ out_dir: "${base_dir}/inference/spatial_performance"
133
 
134
  # -----------------------------------------------------------------------------
135
  # Evaluation (step: evaluate)
136
  # -----------------------------------------------------------------------------
137
  evaluate:
138
  config: "forecasting/inference/evaluation_config.yaml"
139
+ overrides:
140
  model_predictions:
141
+ main_model_csv: "${base_dir}/inference/predictions.csv"
142
  data:
143
+ aia_dir: "${base_dir}/AIA_processed/val"
144
+ weight_path: "${base_dir}/inference/weights"
145
  evaluation:
146
+ output_dir: "${base_dir}/inference/evaluation"
147
  time_range:
148
  start_time: "2023-01-01T00:00:00"
149
  end_time: "2023-12-31T23:59:59"
run_pipeline.py CHANGED
@@ -23,6 +23,7 @@ Usage:
23
 
24
  import argparse
25
  import logging
 
26
  import subprocess
27
  import sys
28
  import time
@@ -47,6 +48,33 @@ log = logging.getLogger(__name__)
47
  # Config helpers
48
  # ---------------------------------------------------------------------------
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def deep_merge(base: dict, overrides: dict) -> dict:
51
  """Recursively merge overrides into base, modifying base in-place."""
52
  for key, val in overrides.items():
@@ -88,6 +116,8 @@ STEP_ORDER = [
88
  "inference",
89
  "evaluate",
90
  "flare_analysis",
 
 
91
  ]
92
 
93
  STEP_INFO = {
@@ -135,6 +165,14 @@ STEP_INFO = {
135
  "description": "Detect, track, and match flares; generate plots/movies",
136
  "script": ROOT / "forecasting" / "inference" / "flare_analysis.py",
137
  },
 
 
 
 
 
 
 
 
138
  }
139
 
140
 
@@ -268,6 +306,26 @@ def build_commands(step: str, cfg: dict, force: bool) -> list[list[str]] | None:
268
  config_path = str(write_merged_config(config_path, inf["overrides"], "inference_config"))
269
  return [base + ["--config", config_path]]
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  return [base]
272
 
273
 
@@ -338,6 +396,7 @@ def main():
338
 
339
  with open(args.config, "r") as f:
340
  cfg = yaml.safe_load(f)
 
341
 
342
  # Resolve step list
343
  if args.steps.strip().lower() == "all":
 
23
 
24
  import argparse
25
  import logging
26
+ import re
27
  import subprocess
28
  import sys
29
  import time
 
48
  # Config helpers
49
  # ---------------------------------------------------------------------------
50
 
51
+ def resolve_variables(cfg: dict) -> dict:
52
+ """
53
+ Resolve ${variable} placeholders in a config dict.
54
+
55
+ Top-level string keys whose values don't themselves contain placeholders
56
+ are treated as variables. Substitution is applied recursively to all
57
+ string values in the config.
58
+ """
59
+ variables = {k: v for k, v in cfg.items()
60
+ if isinstance(v, str) and '${' not in v}
61
+
62
+ def substitute(obj):
63
+ if isinstance(obj, dict):
64
+ return {k: substitute(v) for k, v in obj.items()}
65
+ elif isinstance(obj, list):
66
+ return [substitute(item) for item in obj]
67
+ elif isinstance(obj, str):
68
+ for match in re.finditer(r'\$\{([^}]+)\}', obj):
69
+ var = match.group(1)
70
+ if var in variables:
71
+ obj = obj.replace(f'${{{var}}}', variables[var])
72
+ return obj
73
+ return obj
74
+
75
+ return substitute(cfg)
76
+
77
+
78
  def deep_merge(base: dict, overrides: dict) -> dict:
79
  """Recursively merge overrides into base, modifying base in-place."""
80
  for key, val in overrides.items():
 
116
  "inference",
117
  "evaluate",
118
  "flare_analysis",
119
+ "ablation",
120
+ "spatial_performance",
121
  ]
122
 
123
  STEP_INFO = {
 
165
  "description": "Detect, track, and match flares; generate plots/movies",
166
  "script": ROOT / "forecasting" / "inference" / "flare_analysis.py",
167
  },
168
+ "ablation": {
169
+ "description": "Run Gaussian noise channel-masking ablation study on pretrained model",
170
+ "script": ROOT / "forecasting" / "inference" / "ablation_inference.py",
171
+ },
172
+ "spatial_performance": {
173
+ "description": "Generate flux-weighted spatial error heatmap on the solar disk",
174
+ "script": ROOT / "analysis" / "spatial_performance.py",
175
+ },
176
  }
177
 
178
 
 
306
  config_path = str(write_merged_config(config_path, inf["overrides"], "inference_config"))
307
  return [base + ["--config", config_path]]
308
 
309
+ if step == "ablation":
310
+ if not require(["config"], "ablation"):
311
+ return None
312
+ abl = cfg["ablation"]
313
+ config_path = abl["config"]
314
+ if abl.get("overrides"):
315
+ config_path = str(write_merged_config(config_path, abl["overrides"], "ablation_config"))
316
+ return [base + ["-config", config_path]]
317
+
318
+ if step == "spatial_performance":
319
+ sp = cfg.get("spatial_performance", {})
320
+ cmd = base[:]
321
+ if sp.get("flux_dir"):
322
+ cmd += ["--flux_dir", sp["flux_dir"]]
323
+ if sp.get("predictions_csv"):
324
+ cmd += ["--predictions_csv", sp["predictions_csv"]]
325
+ if sp.get("out_dir"):
326
+ cmd += ["--out_dir", sp["out_dir"]]
327
+ return [cmd]
328
+
329
  return [base]
330
 
331
 
 
396
 
397
  with open(args.config, "r") as f:
398
  cfg = yaml.safe_load(f)
399
+ cfg = resolve_variables(cfg)
400
 
401
  # Resolve step list
402
  if args.steps.strip().lower() == "all":