griffingoodwin04 commited on
Commit
dc04e07
·
1 Parent(s): a1e0076

refactiring rest of code base and adding checkpoints

Browse files
.gitignore CHANGED
@@ -172,3 +172,4 @@ analysis/performance_heatmap_all.png
172
  download/download_flares.py
173
  download/test_download_sdo.py
174
  forecasting/models/test_vit_mask.py
 
 
172
  download/download_flares.py
173
  download/test_download_sdo.py
174
  forecasting/models/test_vit_mask.py
175
+ forecasting/griffin_config.yaml
README.md CHANGED
@@ -46,20 +46,18 @@ The solar soft X-ray (SXR) irradiance is a long-standing proxy of solar activity
46
 
47
  ## Repository Structure
48
 
49
- This repository is intentionally scoped to **getting data, running inference,
50
- and evaluating** a trained FOXES model — it does not include the training
51
- code used to produce the released checkpoint.
52
 
53
  ```text
54
  FOXES
55
  ├── download
56
- │ ├── hugging_face_data_download.py # Recommended: stream FOXES data from HF Hub straight to .npy
57
- │ ├── parquet_to_npy.py # Convert already-downloaded HF parquet files to .npy
58
  │ ├── hf_download_config.yaml # Config for hugging_face_data_download.py
59
  │ ├── download_sdo.py # Advanced: raw AIA download from JSOC (needs data/build_dataset.py after)
60
  │ ├── sdo_download_config.yaml # Config for download_sdo.py
61
- │ ├── sxr_downloader.py # Advanced: raw GOES SXR download via SunPy Fido (needs data/build_dataset.py after)
62
- │ └── sxr_download_config.yaml # Config for sxr_downloader.py
63
  ├── data
64
  │ ├── build_dataset.py # Runs the full raw -> processed pipeline below in one command
65
  │ ├── build_dataset_config.yaml # Config for build_dataset.py
@@ -67,14 +65,19 @@ FOXES
67
  │ ├── convert_aia.py # Raw AIA FITS -> paired 512x512 .npy stacks (itipy)
68
  │ ├── combine_sxr.py # Combine raw multi-satellite GOES files into per-satellite CSVs
69
  │ ├── align_aia_sxr.py # Match AIA timestamps to GOES CSVs -> per-timestamp SXR .npy
 
70
  │ └── sxr_normalization.py # Compute log-space mean/std over SXR .npy files for training
71
  ├── forecasting
72
- │ ├── dataset.py # AIA_GOESDataset: loads paired AIA + SXR .npy files
73
  │ ├── model.py # ViTLocal: Vision Transformer with patch flux heads
74
  │ ├── inference.py # Run a checkpoint over a folder of data; writes predictions.csv
75
  │ ├── inference_config.yaml # Config for inference.py
76
  │ ├── evaluation.py # Compute metrics and generate evaluation plots
77
  │ └── evaluation_config.yaml # Config for evaluation.py
 
 
 
 
78
  └── requirements.txt # Python dependencies
79
  ```
80
 
@@ -107,9 +110,10 @@ conda activate foxes
107
 
108
  ## Running the Model
109
 
110
- FOXES is run in three steps: **get data**, **inference** (run a checkpoint over
111
- your data), and **evaluation** (score the predictions and generate plots). All
112
- three are driven by a YAML config — edit the config, then run the script.
 
113
 
114
  ### 0) Get data
115
 
@@ -124,7 +128,9 @@ Edit `download/hf_download_config.yaml` first to set `aia_dir`/`sxr_dir`, which
124
  splits to pull, and whether to subsample.
125
 
126
  If you've already downloaded the HF parquet files locally instead of
127
- streaming, convert them the same way with `download/parquet_to_npy.py`.
 
 
128
 
129
  **Advanced:** to acquire and process raw data yourself instead, edit and run
130
  the two download configs, then the one dataset-build config, in order:
@@ -134,18 +140,22 @@ the two download configs, then the one dataset-build config, in order:
134
  python download/download_sdo.py --config download/sdo_download_config.yaml
135
 
136
  # 2) Raw GOES XRS data via SunPy Fido
137
- python download/sxr_downloader.py --config download/sxr_download_config.yaml
138
 
139
  # 3) Clean + convert AIA, combine + align SXR -> paired .npy (see data/build_dataset_config.yaml)
140
- python data/build_dataset.py -config data/build_dataset_config.yaml
141
  ```
142
 
143
  `data/build_dataset.py` runs the full raw-to-processed pipeline in one command
144
  (clean AIA → convert AIA → combine GOES → align AIA/SXR); each step can be
145
- skipped via the `steps:` block in its config if you've already run it. It can
146
- also optionally compute SXR normalization stats for trainingset
147
- `sxr_normalization.compute: true` in the config (not needed for inference
148
- against a released checkpoint).
 
 
 
 
149
 
150
  ### 1) Data format
151
 
@@ -186,7 +196,7 @@ output_path: "/path/to/predictions.csv"
186
  Then run:
187
 
188
  ```bash
189
- python forecasting/inference.py -config forecasting/inference_config.yaml
190
  ```
191
 
192
  This writes `output_path` (a CSV of timestamp/prediction/groundtruth). Per-patch
@@ -201,11 +211,46 @@ Edit `forecasting/evaluation_config.yaml` to point at the predictions
201
  CSV and data directories, then run:
202
 
203
  ```bash
204
- python forecasting/evaluation.py -config forecasting/evaluation_config.yaml
205
  ```
206
 
207
  This computes metrics (MSE, MAE, R²) and generates plots under `evaluation.output_dir`.
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  ---
210
 
211
  ## Citation
 
46
 
47
  ## Repository Structure
48
 
49
+ This repository covers the full loop: getting data, training, running
50
+ inference, and evaluating a FOXES model.
 
51
 
52
  ```text
53
  FOXES
54
  ├── download
55
+ │ ├── hugging_face_data_download.py # Recommended: HF Hub -> .npy (streamed, or from local parquet)
 
56
  │ ├── hf_download_config.yaml # Config for hugging_face_data_download.py
57
  │ ├── download_sdo.py # Advanced: raw AIA download from JSOC (needs data/build_dataset.py after)
58
  │ ├── sdo_download_config.yaml # Config for download_sdo.py
59
+ │ ├── download_sxr.py # Advanced: raw GOES SXR download via SunPy Fido (needs data/build_dataset.py after)
60
+ │ └── sxr_download_config.yaml # Config for download_sxr.py
61
  ├── data
62
  │ ├── build_dataset.py # Runs the full raw -> processed pipeline below in one command
63
  │ ├── build_dataset_config.yaml # Config for build_dataset.py
 
65
  │ ├── convert_aia.py # Raw AIA FITS -> paired 512x512 .npy stacks (itipy)
66
  │ ├── combine_sxr.py # Combine raw multi-satellite GOES files into per-satellite CSVs
67
  │ ├── align_aia_sxr.py # Match AIA timestamps to GOES CSVs -> per-timestamp SXR .npy
68
+ │ ├── split_train_val_test.py # Split processed AIA/SXR into train/val/test (training only)
69
  │ └── sxr_normalization.py # Compute log-space mean/std over SXR .npy files for training
70
  ├── forecasting
71
+ │ ├── dataset.py # AIAGOESDataset / AIAGOESDataModule: loads paired AIA + SXR .npy files
72
  │ ├── model.py # ViTLocal: Vision Transformer with patch flux heads
73
  │ ├── inference.py # Run a checkpoint over a folder of data; writes predictions.csv
74
  │ ├── inference_config.yaml # Config for inference.py
75
  │ ├── evaluation.py # Compute metrics and generate evaluation plots
76
  │ └── evaluation_config.yaml # Config for evaluation.py
77
+ ├── training
78
+ │ ├── train.py # Train ViTLocal with PyTorch Lightning + Weights & Biases logging
79
+ │ ├── train_config.yaml # Config for train.py
80
+ │ └── callbacks.py # W&B callbacks: SXR pred-vs-true plots, attention map visualization
81
  └── requirements.txt # Python dependencies
82
  ```
83
 
 
110
 
111
  ## Running the Model
112
 
113
+ FOXES is run in four steps: **get data**, **inference** (run a checkpoint over
114
+ your data), **evaluation** (score the predictions and generate plots), and
115
+ optionally **training** your own model. All are driven by a YAML config — edit
116
+ the config, then run the script.
117
 
118
  ### 0) Get data
119
 
 
128
  splits to pull, and whether to subsample.
129
 
130
  If you've already downloaded the HF parquet files locally instead of
131
+ streaming, set `local_parquet_dir` in that same config to the root folder
132
+ containing your per-split subdirs (`train/`, `validation/` or `val/`, `test/`)
133
+ — streaming and the HF Hub login are skipped entirely in that case.
134
 
135
  **Advanced:** to acquire and process raw data yourself instead, edit and run
136
  the two download configs, then the one dataset-build config, in order:
 
140
  python download/download_sdo.py --config download/sdo_download_config.yaml
141
 
142
  # 2) Raw GOES XRS data via SunPy Fido
143
+ python download/download_sxr.py --config download/sxr_download_config.yaml
144
 
145
  # 3) Clean + convert AIA, combine + align SXR -> paired .npy (see data/build_dataset_config.yaml)
146
+ python data/build_dataset.py --config data/build_dataset_config.yaml
147
  ```
148
 
149
  `data/build_dataset.py` runs the full raw-to-processed pipeline in one command
150
  (clean AIA → convert AIA → combine GOES → align AIA/SXR); each step can be
151
+ skipped via the `steps:` block in its config if you've already run it.
152
+ Inference/evaluation just need the flat output of thattraining needs two
153
+ more things, both off by default and only relevant if you're training:
154
+ - `steps.split: true` — splits `aia.processed_dir`/`output.sxr_dir` into
155
+ `train/`/`val/`/`test/` subfolders (date ranges or a month-based default;
156
+ see the `split:` block in the config).
157
+ - `sxr_normalization.compute: true` — computes SXR normalization stats from
158
+ the train split (requires `steps.split` to have run first).
159
 
160
  ### 1) Data format
161
 
 
196
  Then run:
197
 
198
  ```bash
199
+ python forecasting/inference.py --config forecasting/inference_config.yaml
200
  ```
201
 
202
  This writes `output_path` (a CSV of timestamp/prediction/groundtruth). Per-patch
 
211
  CSV and data directories, then run:
212
 
213
  ```bash
214
+ python forecasting/evaluation.py --config forecasting/evaluation_config.yaml
215
  ```
216
 
217
  This computes metrics (MSE, MAE, R²) and generates plots under `evaluation.output_dir`.
218
 
219
+ ### 4) Train your own model (optional)
220
+
221
+ Unlike inference, training expects `aia_dir`/`sxr_dir` each to have `train/`,
222
+ `val/`, and `test/` subfolders of paired `.npy` files — exactly what
223
+ `data/build_dataset.py` and the Hugging Face download path produce.
224
+
225
+ Edit `training/train_config.yaml`:
226
+
227
+ ```yaml
228
+ base_data_dir: "/path/to/processed_data" # holds AIA_processed/ and SXR_processed/
229
+ base_checkpoint_dir: "/path/to/checkpoints"
230
+
231
+ gpu_ids: -1 # -1 = CPU, 0 = GPU 0, [0,1] = specific GPUs, "all" = every GPU
232
+ batch_size: 6
233
+ epochs: 150
234
+
235
+ vit_architecture:
236
+ mask_mode: inverted # inverted (released model) | local | none (full/global attention)
237
+ local_window: 9
238
+
239
+ wandb:
240
+ entity: "" # your W&B username or team name
241
+ ```
242
+
243
+ Then run:
244
+
245
+ ```bash
246
+ python training/train.py --config training/train_config.yaml
247
+ ```
248
+
249
+ Training logs to Weights & Biases (predicted-vs-true SXR plots and attention
250
+ map visualizations each validation epoch — see `training/callbacks.py`) and
251
+ saves the top 10 checkpoints by validation loss to `data.checkpoints_dir`,
252
+ ready to point `forecasting/inference_config.yaml` at.
253
+
254
  ---
255
 
256
  ## Citation
data/align_aia_sxr.py CHANGED
@@ -171,7 +171,10 @@ def align_aia_sxr(goes_data_dir, aia_processed_dir, output_sxr_dir, aia_missing_
171
 
172
  # Get target timestamps from AIA files
173
  print(f"\nFinding target timestamps from AIA files in: {aia_processed_dir}")
174
- aia_files = sorted(glob.glob(f"{aia_processed_dir}/*.npy", recursive=True))
 
 
 
175
  aia_files_split = [file.split('/')[-1].split('.')[0] for file in aia_files]
176
  common_timestamps = [
177
  datetime.fromisoformat(date_str).strftime('%Y-%m-%dT%H:%M:%S')
 
171
 
172
  # Get target timestamps from AIA files
173
  print(f"\nFinding target timestamps from AIA files in: {aia_processed_dir}")
174
+ # Skip macOS AppleDouble sidecar files (e.g. "._2023-08-01T00:00:00.npy")
175
+ # that external/non-native filesystems litter alongside real files.
176
+ aia_files = sorted(f for f in glob.glob(f"{aia_processed_dir}/*.npy", recursive=True)
177
+ if not os.path.basename(f).startswith('._'))
178
  aia_files_split = [file.split('/')[-1].split('.')[0] for file in aia_files]
179
  common_timestamps = [
180
  datetime.fromisoformat(date_str).strftime('%Y-%m-%dT%H:%M:%S')
data/build_dataset.py CHANGED
@@ -1,15 +1,16 @@
1
  """
2
  End-to-end raw -> processed dataset builder for FOXES.
3
 
4
- Runs, in order: clean_aia -> convert_aia -> combine_sxr -> align -> (optional)
5
- sxr_normalization. Each step can be skipped via the `steps` section of the
6
- config if you already ran it. Everything is driven by one YAML config — see
7
- build_dataset_config.yaml for the fields.
8
 
9
  Usage:
10
- python data/build_dataset.py -config data/build_dataset_config.yaml
11
  """
12
  import argparse
 
13
 
14
  import numpy as np
15
  import yaml
@@ -18,6 +19,7 @@ from clean_aia import clean_aia_data
18
  from convert_aia import process_aia_to_npy
19
  from combine_sxr import SXRDataProcessor
20
  from align_aia_sxr import align_aia_sxr
 
21
  from sxr_normalization import compute_sxr_norm
22
 
23
 
@@ -28,7 +30,7 @@ def load_config(config_path):
28
 
29
  def main():
30
  parser = argparse.ArgumentParser(description='Build a FOXES-ready dataset from raw AIA/GOES data.')
31
- parser.add_argument('-config', type=str, default='data/build_dataset_config.yaml',
32
  help='Path to build_dataset config YAML file')
33
  args = parser.parse_args()
34
 
@@ -40,30 +42,31 @@ def main():
40
  output = config['output']
41
  processing = config.get('processing', {})
42
  steps = config.get('steps', {})
 
43
  norm = config.get('sxr_normalization', {})
44
 
45
  print("=== FOXES Dataset Build ===")
46
 
47
  if steps.get('clean_aia', True):
48
- print("\n--- Step 1/4: Clean AIA FITS files ---")
49
  clean_aia_data(aia['raw_dir'], aia['bad_files_dir'], wavelengths)
50
  else:
51
- print("\n--- Step 1/4: Clean AIA FITS files (skipped) ---")
52
 
53
  if steps.get('convert_aia', True):
54
- print("\n--- Step 2/4: Convert AIA FITS -> .npy ---")
55
  process_aia_to_npy(aia['raw_dir'], aia['processed_dir'], wavelengths)
56
  else:
57
- print("\n--- Step 2/4: Convert AIA FITS -> .npy (skipped) ---")
58
 
59
  if steps.get('combine_sxr', True):
60
- print("\n--- Step 3/4: Combine raw GOES satellite files ---")
61
  SXRDataProcessor(data_dir=sxr['raw_dir'], output_dir=sxr['combined_dir']).combine_goes_data()
62
  else:
63
- print("\n--- Step 3/4: Combine raw GOES satellite files (skipped) ---")
64
 
65
  if steps.get('align', True):
66
- print("\n--- Step 4/4: Align AIA timestamps with GOES SXR data ---")
67
  align_aia_sxr(
68
  goes_data_dir=sxr['combined_dir'],
69
  aia_processed_dir=aia['processed_dir'],
@@ -74,11 +77,22 @@ def main():
74
  min_batch_size=processing.get('min_batch_size', 1),
75
  )
76
  else:
77
- print("\n--- Step 4/4: Align AIA timestamps with GOES SXR data (skipped) ---")
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  if norm.get('compute', False):
80
- print("\n--- Optional: Compute SXR normalization stats ---")
81
- sxr_norm = compute_sxr_norm(output['sxr_dir'])
82
  np.save(norm['output_path'], sxr_norm)
83
  print(f"Saved SXR normalization to {norm['output_path']}")
84
 
 
1
  """
2
  End-to-end raw -> processed dataset builder for FOXES.
3
 
4
+ Runs, in order: clean_aia -> convert_aia -> combine_sxr -> align -> split ->
5
+ (optional) sxr_normalization. Each step can be skipped via the `steps` section
6
+ of the config if you already ran it. Everything is driven by one YAML config
7
+ — see build_dataset_config.yaml for the fields.
8
 
9
  Usage:
10
+ python data/build_dataset.py --config data/build_dataset_config.yaml
11
  """
12
  import argparse
13
+ import os
14
 
15
  import numpy as np
16
  import yaml
 
19
  from convert_aia import process_aia_to_npy
20
  from combine_sxr import SXRDataProcessor
21
  from align_aia_sxr import align_aia_sxr
22
+ from split_train_val_test import split_train_val_test
23
  from sxr_normalization import compute_sxr_norm
24
 
25
 
 
30
 
31
  def main():
32
  parser = argparse.ArgumentParser(description='Build a FOXES-ready dataset from raw AIA/GOES data.')
33
+ parser.add_argument('--config', type=str, default='data/build_dataset_config.yaml',
34
  help='Path to build_dataset config YAML file')
35
  args = parser.parse_args()
36
 
 
42
  output = config['output']
43
  processing = config.get('processing', {})
44
  steps = config.get('steps', {})
45
+ split = config.get('split', {})
46
  norm = config.get('sxr_normalization', {})
47
 
48
  print("=== FOXES Dataset Build ===")
49
 
50
  if steps.get('clean_aia', True):
51
+ print("\n--- Step 1/5: Clean AIA FITS files ---")
52
  clean_aia_data(aia['raw_dir'], aia['bad_files_dir'], wavelengths)
53
  else:
54
+ print("\n--- Step 1/5: Clean AIA FITS files (skipped) ---")
55
 
56
  if steps.get('convert_aia', True):
57
+ print("\n--- Step 2/5: Convert AIA FITS -> .npy ---")
58
  process_aia_to_npy(aia['raw_dir'], aia['processed_dir'], wavelengths)
59
  else:
60
+ print("\n--- Step 2/5: Convert AIA FITS -> .npy (skipped) ---")
61
 
62
  if steps.get('combine_sxr', True):
63
+ print("\n--- Step 3/5: Combine raw GOES satellite files ---")
64
  SXRDataProcessor(data_dir=sxr['raw_dir'], output_dir=sxr['combined_dir']).combine_goes_data()
65
  else:
66
+ print("\n--- Step 3/5: Combine raw GOES satellite files (skipped) ---")
67
 
68
  if steps.get('align', True):
69
+ print("\n--- Step 4/5: Align AIA timestamps with GOES SXR data ---")
70
  align_aia_sxr(
71
  goes_data_dir=sxr['combined_dir'],
72
  aia_processed_dir=aia['processed_dir'],
 
77
  min_batch_size=processing.get('min_batch_size', 1),
78
  )
79
  else:
80
+ print("\n--- Step 4/5: Align AIA timestamps with GOES SXR data (skipped) ---")
81
+
82
+ # Off by default: only needed if you're training (inference/evaluation
83
+ # work fine against the flat processed_dir/sxr_dir from the align step).
84
+ if steps.get('split', False):
85
+ print("\n--- Step 5/5: Split AIA/SXR into train/val/test ---")
86
+ split_kwargs = dict(train_range=split.get('train_range'), val_range=split.get('val_range'),
87
+ test_range=split.get('test_range'), copy_files=split.get('copy_files', False))
88
+ split_train_val_test(aia['processed_dir'], aia['processed_dir'], **split_kwargs)
89
+ split_train_val_test(output['sxr_dir'], output['sxr_dir'], **split_kwargs)
90
+ else:
91
+ print("\n--- Step 5/5: Split AIA/SXR into train/val/test (skipped) ---")
92
 
93
  if norm.get('compute', False):
94
+ print("\n--- Optional: Compute SXR normalization stats (from the train split) ---")
95
+ sxr_norm = compute_sxr_norm(os.path.join(output['sxr_dir'], 'train'))
96
  np.save(norm['output_path'], sxr_norm)
97
  print(f"Saved SXR normalization to {norm['output_path']}")
98
 
data/build_dataset_config.yaml CHANGED
@@ -4,23 +4,23 @@
4
  # Turns raw AIA FITS + raw GOES XRS data into the paired .npy layout that
5
  # forecasting/dataset.py expects.
6
  #
7
- # Usage: python data/build_dataset.py -config data/build_dataset_config.yaml
8
  # =============================================================================
9
 
10
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
11
 
12
  aia:
13
- raw_dir: "/path/to/AIA_raw" # raw FITS from download/download_sdo.py
14
- bad_files_dir: "/path/to/AIA_bad" # bad-timestamp FITS get moved here
15
- processed_dir: "/path/to/AIA_processed" # paired 512x512 .npy stacks
16
 
17
  sxr:
18
- raw_dir: "/path/to/GOES_raw" # raw netCDF from download/sxr_downloader.py
19
- combined_dir: "/path/to/GOES_combined" # one interpolated CSV per satellite
20
 
21
  output:
22
- sxr_dir: "/path/to/SXR_processed" # per-timestamp xrsb_flux .npy, aligned to AIA
23
- aia_missing_dir: "/path/to/AIA_missing" # AIA files with no matching SXR value
24
 
25
  processing:
26
  max_processes: null # cap worker processes; null = use all CPUs
@@ -30,13 +30,26 @@ processing:
30
  # Which build steps to run, in order. Set any to false to skip it (e.g. if
31
  # you already ran that step and are re-running after fixing a later one).
32
  steps:
33
- clean_aia: true # drop AIA FITS files with a bad DATE-OBS timestamp
34
- convert_aia: true # raw AIA FITS -> paired .npy stacks
35
- combine_sxr: true # combine raw multi-satellite GOES files into per-satellite CSVs
36
- align: true # match AIA timestamps to GOES CSVs -> per-timestamp SXR .npy
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  # Optional — only needed if you're training, not for running inference
39
- # against a released checkpoint.
 
40
  sxr_normalization:
41
- compute: false
42
- output_path: "/path/to/normalized_sxr.npy"
 
4
  # Turns raw AIA FITS + raw GOES XRS data into the paired .npy layout that
5
  # forecasting/dataset.py expects.
6
  #
7
+ # Usage: python data/build_dataset.py --config data/build_dataset_config.yaml
8
  # =============================================================================
9
 
10
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
11
 
12
  aia:
13
+ raw_dir: "/Volumes/T9/testing_foxes/AIA_raw" # raw FITS from download/download_sdo.py
14
+ bad_files_dir: "/Volumes/T9/testing_foxes/SDO/AIA_bad" # bad-timestamp FITS get moved here
15
+ processed_dir: "/Volumes/T9/testing_foxes/AIA_processed" # paired 512x512 .npy stacks
16
 
17
  sxr:
18
+ raw_dir: "/Volumes/T9/testing_foxes/GOES_raw" # raw netCDF from download/download_sxr.py
19
+ combined_dir: "/Volumes/T9/testing_foxes/GOES_combined" # one interpolated CSV per satellite
20
 
21
  output:
22
+ sxr_dir: "/Volumes/T9/testing_foxes/SXR_processed" # per-timestamp xrsb_flux .npy, aligned to AIA
23
+ aia_missing_dir: "/Volumes/T9/testing_foxes/AIA_missing" # AIA files with no matching SXR value
24
 
25
  processing:
26
  max_processes: null # cap worker processes; null = use all CPUs
 
30
  # Which build steps to run, in order. Set any to false to skip it (e.g. if
31
  # you already ran that step and are re-running after fixing a later one).
32
  steps:
33
+ clean_aia: true # drop AIA FITS files with a bad DATE-OBS timestamp
34
+ convert_aia: true # raw AIA FITS -> paired .npy stacks
35
+ combine_sxr: true # combine raw multi-satellite GOES files into per-satellite CSVs
36
+ align: true # match AIA timestamps to GOES CSVs -> per-timestamp SXR .npy
37
+ split: true # split aia.processed_dir / output.sxr_dir into train/val/test
38
+
39
+ # Only needed if you're training (training/train_config.yaml expects
40
+ # aia_dir/sxr_dir to each have train/val/test subfolders) — not needed for
41
+ # inference or evaluation against a released checkpoint. Splits both
42
+ # aia.processed_dir and output.sxr_dir in place, using the same date ranges
43
+ # so a given timestamp lands in the same split for both.
44
+ split:
45
+ train_range: ["2023-08-01","2023-08-15"] # e.g. ["2012-01-01", "2022-12-31"]; null with the others -> month-based default
46
+ val_range: ["2023-08-16","2023-08-22"] # e.g. ["2023-01-01", "2023-06-30"]
47
+ test_range: ["2023-08-23","2023-08-31"] # e.g. ["2023-08-01", "2023-08-31"]
48
+ copy_files: false # copy instead of move
49
 
50
  # Optional — only needed if you're training, not for running inference
51
+ # against a released checkpoint. Requires steps.split to have run (computes
52
+ # stats from the train split only).
53
  sxr_normalization:
54
+ compute: true
55
+ output_path: "/Volumes/T9/testing_foxes/normalized_sxr.npy"
data/clean_aia.py CHANGED
@@ -1,6 +1,11 @@
1
  """
2
- Remove AIA FITS files whose filename timestamp doesn't match their DATE-OBS
3
- header (more than 60s apart), which indicates a mis-tagged or corrupt download.
 
 
 
 
 
4
 
5
  Called by build_dataset.py — not meant to be run standalone.
6
  """
@@ -9,36 +14,51 @@ import os
9
  import shutil
10
  from multiprocessing import Pool
11
 
 
12
  import pandas as pd
13
  from tqdm import tqdm
14
 
15
  # itipy needs these aliases done manually on newer Python versions.
16
- collections.Iterable = collections.abc.Iterable
17
- collections.Mapping = collections.abc.Mapping
18
- collections.MutableSet = collections.abc.MutableSet
19
- collections.MutableMapping = collections.abc.MutableMapping
20
  from itipy.data.dataset import get_intersecting_files
21
  from astropy.io import fits
22
 
23
 
 
 
 
 
 
 
 
 
 
 
24
  def process_fits_file(file_path):
25
  try:
26
  with fits.open(file_path) as hdu:
27
- header = hdu[1].header
28
  date_obs = pd.to_datetime(header['DATE-OBS'])
29
  # Ensure timezone-naive datetime
30
  if date_obs.tz is not None:
31
  date_obs = date_obs.tz_localize(None)
32
  wavelength = header['WAVELNTH']
33
  filename = pd.to_datetime(os.path.basename(file_path).split('.')[0])
34
- return {'DATE-OBS': date_obs, 'WAVELNTH': wavelength, 'FILENAME': filename}
 
 
 
 
35
  except Exception as e:
36
  print(f"Error processing {file_path}: {e}")
37
  return None
38
 
39
 
40
  def clean_aia_data(input_folder, bad_files_dir, wavelengths):
41
- """Move AIA FITS files with bad DATE-OBS timestamps out of input_folder."""
42
  aia_files = get_intersecting_files(input_folder, wavelengths)
43
  file_list = aia_files[0] # List of FITS file paths
44
 
@@ -48,6 +68,10 @@ def clean_aia_data(input_folder, bad_files_dir, wavelengths):
48
  # Filter out None results (in case of failed files)
49
  results = [r for r in results if r is not None]
50
 
 
 
 
 
51
  # Convert to DataFrame
52
  aia_header = pd.DataFrame(results)
53
  aia_header['DATE-OBS'] = pd.to_datetime(aia_header['DATE-OBS'])
@@ -57,9 +81,13 @@ def clean_aia_data(input_folder, bad_files_dir, wavelengths):
57
  pd.to_datetime(aia_header['FILENAME']) - pd.to_datetime(aia_header['DATE-OBS'])
58
  ).dt.total_seconds()
59
 
60
- # Remove rows where DATE_DIFF is greater than ±60 seconds
61
- files_to_remove = aia_header[(aia_header['DATE_DIFF'] <= -60) | (aia_header['DATE_DIFF'] >= 60)]
62
- print(f"{len(files_to_remove)} bad files found")
 
 
 
 
63
 
64
  for wavelength in wavelengths:
65
  print(f"\nProcessing wavelength: {wavelength}")
 
1
  """
2
+ Remove AIA FITS files that are unusable: filename timestamp doesn't match the
3
+ DATE-OBS header (more than 60s apart, indicating a mis-tagged/corrupt download),
4
+ the image itself is blank (all-zero/constant/NaN), or QUALITY is nonzero (AIA's
5
+ own bitmask flagging eclipse/calibration/off-point/etc. frames). download_sdo.py
6
+ only warns and falls back to the closest available frame when no quality-0
7
+ frame exists nearby, so a bad-quality frame can still make it to disk — this
8
+ is the backstop that catches it.
9
 
10
  Called by build_dataset.py — not meant to be run standalone.
11
  """
 
14
  import shutil
15
  from multiprocessing import Pool
16
 
17
+ import numpy as np
18
  import pandas as pd
19
  from tqdm import tqdm
20
 
21
  # itipy needs these aliases done manually on newer Python versions.
22
+ collections.Iterable = collections.abc.Iterable # type: ignore[attr-defined]
23
+ collections.Mapping = collections.abc.Mapping # type: ignore[attr-defined]
24
+ collections.MutableSet = collections.abc.MutableSet # type: ignore[attr-defined]
25
+ collections.MutableMapping = collections.abc.MutableMapping # type: ignore[attr-defined]
26
  from itipy.data.dataset import get_intersecting_files
27
  from astropy.io import fits
28
 
29
 
30
+ def _is_blank(data):
31
+ """True if the image has no usable signal (all-zero, constant, or all-NaN)."""
32
+ if data is None:
33
+ return True
34
+ finite = data[np.isfinite(data)]
35
+ if finite.size == 0:
36
+ return True
37
+ return bool(np.nanmax(finite) == np.nanmin(finite))
38
+
39
+
40
  def process_fits_file(file_path):
41
  try:
42
  with fits.open(file_path) as hdu:
43
+ header = hdu[1].header # type: ignore[union-attr]
44
  date_obs = pd.to_datetime(header['DATE-OBS'])
45
  # Ensure timezone-naive datetime
46
  if date_obs.tz is not None:
47
  date_obs = date_obs.tz_localize(None)
48
  wavelength = header['WAVELNTH']
49
  filename = pd.to_datetime(os.path.basename(file_path).split('.')[0])
50
+ blank = _is_blank(hdu[1].data) # type: ignore[union-attr]
51
+ quality = pd.to_numeric(pd.Series([header.get('QUALITY')]), errors='coerce').iloc[0]
52
+ bad_quality = bool(pd.notna(quality) and quality != 0)
53
+ return {'DATE-OBS': date_obs, 'WAVELNTH': wavelength, 'FILENAME': filename,
54
+ 'BLANK': blank, 'BAD_QUALITY': bad_quality}
55
  except Exception as e:
56
  print(f"Error processing {file_path}: {e}")
57
  return None
58
 
59
 
60
  def clean_aia_data(input_folder, bad_files_dir, wavelengths):
61
+ """Move AIA FITS files with bad DATE-OBS timestamps or blank images out of input_folder."""
62
  aia_files = get_intersecting_files(input_folder, wavelengths)
63
  file_list = aia_files[0] # List of FITS file paths
64
 
 
68
  # Filter out None results (in case of failed files)
69
  results = [r for r in results if r is not None]
70
 
71
+ if not results:
72
+ print("No readable AIA files found — nothing to clean.")
73
+ return
74
+
75
  # Convert to DataFrame
76
  aia_header = pd.DataFrame(results)
77
  aia_header['DATE-OBS'] = pd.to_datetime(aia_header['DATE-OBS'])
 
81
  pd.to_datetime(aia_header['FILENAME']) - pd.to_datetime(aia_header['DATE-OBS'])
82
  ).dt.total_seconds()
83
 
84
+ # Remove rows where DATE_DIFF is greater than ±60 seconds, the image is
85
+ # blank, or QUALITY flags a known instrumental issue
86
+ bad_timing = (aia_header['DATE_DIFF'] <= -60) | (aia_header['DATE_DIFF'] >= 60)
87
+ files_to_remove = aia_header[bad_timing | aia_header['BLANK'] | aia_header['BAD_QUALITY']]
88
+ print(f"{len(files_to_remove)} bad files found "
89
+ f"({bad_timing.sum()} bad timing, {aia_header['BLANK'].sum()} blank, "
90
+ f"{aia_header['BAD_QUALITY'].sum()} bad quality)")
91
 
92
  for wavelength in wavelengths:
93
  print(f"\nProcessing wavelength: {wavelength}")
data/combine_sxr.py CHANGED
@@ -29,7 +29,7 @@ class SXRDataProcessor:
29
  def __init__(self, data_dir: str, output_dir: str):
30
  self.data_dir = Path(data_dir)
31
  self.output_dir = Path(output_dir)
32
- self.output_dir.mkdir(exist_ok=True)
33
  self.used_g13_files = []
34
  self.used_g14_files = []
35
  self.used_g15_files = []
@@ -42,14 +42,19 @@ class SXRDataProcessor:
42
  Combine GOES-16 and GOES-18 files and track source files used.
43
  Parameters
44
  """
45
- print("🔍 Scanning for GOES data files...")
46
-
47
- g13_files = sorted(self.data_dir.glob("*g13*.nc"))
48
- g14_files = sorted(self.data_dir.glob("*g14*.nc"))
49
- g15_files = sorted(self.data_dir.glob("*g15*.nc"))
50
- g16_files = sorted(self.data_dir.glob("*g16*.nc"))
51
- g17_files = sorted(self.data_dir.glob("*g17*.nc"))
52
- g18_files = sorted(self.data_dir.glob("*g18*.nc"))
 
 
 
 
 
53
 
54
  total_files = len(g13_files) + len(g14_files) + len(g15_files) + len(g16_files) + len(g17_files) + len(g18_files)
55
  logging.info(
@@ -62,11 +67,10 @@ class SXRDataProcessor:
62
 
63
  def process_files(files, satellite_name, output_file, used_file_list):
64
  datasets = []
65
- combined_meta = {}
66
  successful_files = 0
67
  failed_files = 0
68
 
69
- print(f"🛰️ Processing {satellite_name} ({len(files)} files)...")
70
 
71
  # Progress bar for file loading
72
  with tqdm(files, desc=f"Loading {satellite_name}", unit="file",
@@ -85,7 +89,7 @@ class SXRDataProcessor:
85
  continue
86
  finally:
87
  if 'ds' in locals():
88
- ds.close()
89
 
90
  if not datasets:
91
  print(f"No valid datasets for {satellite_name}")
 
29
  def __init__(self, data_dir: str, output_dir: str):
30
  self.data_dir = Path(data_dir)
31
  self.output_dir = Path(output_dir)
32
+ self.output_dir.mkdir(parents=True, exist_ok=True)
33
  self.used_g13_files = []
34
  self.used_g14_files = []
35
  self.used_g15_files = []
 
42
  Combine GOES-16 and GOES-18 files and track source files used.
43
  Parameters
44
  """
45
+ print("Scanning for GOES data files...")
46
+
47
+ def _glob(pattern):
48
+ # Skip macOS AppleDouble sidecar files (e.g. "._sci_xrsf...nc")
49
+ # that external/non-native filesystems litter alongside real files.
50
+ return sorted(p for p in self.data_dir.glob(pattern) if not p.name.startswith('._'))
51
+
52
+ g13_files = _glob("*g13*.nc")
53
+ g14_files = _glob("*g14*.nc")
54
+ g15_files = _glob("*g15*.nc")
55
+ g16_files = _glob("*g16*.nc")
56
+ g17_files = _glob("*g17*.nc")
57
+ g18_files = _glob("*g18*.nc")
58
 
59
  total_files = len(g13_files) + len(g14_files) + len(g15_files) + len(g16_files) + len(g17_files) + len(g18_files)
60
  logging.info(
 
67
 
68
  def process_files(files, satellite_name, output_file, used_file_list):
69
  datasets = []
 
70
  successful_files = 0
71
  failed_files = 0
72
 
73
+ print(f"Processing {satellite_name} ({len(files)} files)...")
74
 
75
  # Progress bar for file loading
76
  with tqdm(files, desc=f"Loading {satellite_name}", unit="file",
 
89
  continue
90
  finally:
91
  if 'ds' in locals():
92
+ ds.close() # type: ignore[attr-defined]
93
 
94
  if not datasets:
95
  print(f"No valid datasets for {satellite_name}")
data/convert_aia.py CHANGED
@@ -8,10 +8,10 @@ import collections.abc
8
  import os
9
  from multiprocessing import Pool
10
 
11
- collections.Iterable = collections.abc.Iterable
12
- collections.Mapping = collections.abc.Mapping
13
- collections.MutableSet = collections.abc.MutableSet
14
- collections.MutableMapping = collections.abc.MutableMapping
15
  import numpy as np
16
  from itipy.data.dataset import StackDataset, get_intersecting_files, AIADataset
17
  from itipy.data.editor import BrightestPixelPatchEditor
@@ -40,7 +40,7 @@ class AIAStackDataset(StackDataset):
40
  ds_mapping = {94: AIADataset, 131: AIADataset, 171: AIADataset, 193: AIADataset, 211: AIADataset,
41
  304: AIADataset, 335: AIADataset, 1600: AIADataset, 1700: AIADataset, 4500: AIADataset, 6173: AIADataset}
42
  data_sets = [ds_mapping[wl_id](files, wavelength=wl_id, resolution=resolution, ext=ext, allow_errors=allow_errors)
43
- for wl_id, files in zip(wavelengths, paths)]
44
  super().__init__(data_sets, **kwargs)
45
  if patch_shape is not None:
46
  self.addEditor(BrightestPixelPatchEditor(patch_shape))
@@ -58,11 +58,11 @@ def _init_worker(dataset, out_folder):
58
 
59
  def save_sample(i):
60
  try:
61
- data = _aia_dataset[i]
62
- file_path = os.path.join(_output_folder, _aia_dataset.getId(i)) + '.npy'
63
  np.save(file_path, data)
64
  except Exception as e:
65
- print(f"Warning: Could not process sample {i} (ID: {_aia_dataset.getId(i)}): {e}")
66
 
67
 
68
  def check_existing_files(base_input_folder, wavelengths, output_folder):
 
8
  import os
9
  from multiprocessing import Pool
10
 
11
+ collections.Iterable = collections.abc.Iterable # type: ignore[attr-defined]
12
+ collections.Mapping = collections.abc.Mapping # type: ignore[attr-defined]
13
+ collections.MutableSet = collections.abc.MutableSet # type: ignore[attr-defined]
14
+ collections.MutableMapping = collections.abc.MutableMapping # type: ignore[attr-defined]
15
  import numpy as np
16
  from itipy.data.dataset import StackDataset, get_intersecting_files, AIADataset
17
  from itipy.data.editor import BrightestPixelPatchEditor
 
40
  ds_mapping = {94: AIADataset, 131: AIADataset, 171: AIADataset, 193: AIADataset, 211: AIADataset,
41
  304: AIADataset, 335: AIADataset, 1600: AIADataset, 1700: AIADataset, 4500: AIADataset, 6173: AIADataset}
42
  data_sets = [ds_mapping[wl_id](files, wavelength=wl_id, resolution=resolution, ext=ext, allow_errors=allow_errors)
43
+ for wl_id, files in zip(wavelengths, paths)] # type: ignore[attr-defined]
44
  super().__init__(data_sets, **kwargs)
45
  if patch_shape is not None:
46
  self.addEditor(BrightestPixelPatchEditor(patch_shape))
 
58
 
59
  def save_sample(i):
60
  try:
61
+ data = _aia_dataset[i] # type: ignore[attr-defined]
62
+ file_path = os.path.join(_output_folder, _aia_dataset.getId(i)) + '.npy' # type: ignore[attr-defined]
63
  np.save(file_path, data)
64
  except Exception as e:
65
+ print(f"Warning: Could not process sample {i} (ID: {_aia_dataset.getId(i)}): {e}") # type: ignore[attr-defined]
66
 
67
 
68
  def check_existing_files(base_input_folder, wavelengths, output_folder):
data/split_train_val_test.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Split a flat folder of timestamp-named .npy files (AIA or SXR) into
3
+ train/val/test subfolders, so forecasting/training's AIAGOESDataModule can
4
+ find them.
5
+
6
+ Called by build_dataset.py — not meant to be run standalone.
7
+ """
8
+ import os
9
+ import shutil
10
+
11
+ import pandas as pd
12
+
13
+
14
+ def _normalize_timestamp(ts: str) -> str:
15
+ """Normalize timestamp strings with underscores instead of colons (cross-platform filenames)."""
16
+ if 'T' in ts:
17
+ date_part, time_part = ts.split('T', 1)
18
+ return f"{date_part}T{time_part.replace('_', ':')}"
19
+ return ts
20
+
21
+
22
+ def _assign_split(file_time, train_range, val_range, test_range):
23
+ """Return 'train'/'val'/'test' for this timestamp, or None if no range matches."""
24
+ ranges = {'train': train_range, 'val': val_range, 'test': test_range}
25
+ if any(ranges.values()):
26
+ for split_name, rng in ranges.items():
27
+ if rng is None:
28
+ continue
29
+ start = pd.to_datetime(rng[0])
30
+ end = pd.to_datetime(rng[1]).replace(hour=23, minute=59, second=59, microsecond=999999)
31
+ if start <= file_time <= end:
32
+ return split_name
33
+ return None
34
+
35
+ # Default: month-based split (August held out for test, Jan-Mar for val)
36
+ month = file_time.month
37
+ if month == 8:
38
+ return 'test'
39
+ if month in (1, 2, 3):
40
+ return 'val'
41
+ return 'train'
42
+
43
+
44
+ def split_train_val_test(input_dir, output_dir, train_range=None, val_range=None, test_range=None,
45
+ copy_files=False):
46
+ """
47
+ Split .npy files in input_dir into train/val/test subfolders under output_dir,
48
+ based on each file's timestamp filename.
49
+
50
+ Parameters
51
+ ----------
52
+ input_dir : str
53
+ Flat folder of .npy files named by timestamp (e.g. from align_aia_sxr.py
54
+ or convert_aia.py). Can be the same path as output_dir to split in place.
55
+ output_dir : str
56
+ Destination folder; train/val/test subfolders are created under it.
57
+ train_range, val_range, test_range : [start, end] date strings, optional
58
+ Inclusive date ranges ("YYYY-MM-DD") for each split. If none are given,
59
+ falls back to a month-based default: August -> test, Jan-Mar -> val,
60
+ everything else -> train.
61
+ copy_files : bool
62
+ Copy instead of move (default: move).
63
+ """
64
+ if not os.path.isdir(input_dir):
65
+ raise ValueError(f"Input folder does not exist: {input_dir}")
66
+
67
+ for split_name in ("train", "val", "test"):
68
+ os.makedirs(os.path.join(output_dir, split_name), exist_ok=True)
69
+
70
+ files = sorted(f for f in os.listdir(input_dir) if f.endswith(".npy"))
71
+ print(f"Splitting {len(files)} files from {input_dir}")
72
+
73
+ moved = skipped = 0
74
+ for filename in files:
75
+ try:
76
+ file_time = pd.to_datetime(_normalize_timestamp(filename[:-len(".npy")]))
77
+ except ValueError:
78
+ print(f"Skipping {filename}: invalid timestamp")
79
+ skipped += 1
80
+ continue
81
+
82
+ split_name = _assign_split(file_time, train_range, val_range, test_range)
83
+ if split_name is None:
84
+ print(f"Skipping {filename}: no matching date range ({file_time.date()})")
85
+ skipped += 1
86
+ continue
87
+
88
+ src = os.path.join(input_dir, filename)
89
+ dst = os.path.join(output_dir, split_name, filename)
90
+ if os.path.exists(dst):
91
+ skipped += 1
92
+ continue
93
+
94
+ (shutil.copy2 if copy_files else shutil.move)(src, dst)
95
+ moved += 1
96
+
97
+ action = "copied" if copy_files else "moved"
98
+ print(f"Done: {moved} files {action}, {skipped} skipped")
99
+ for split_name in ("train", "val", "test"):
100
+ n = len(os.listdir(os.path.join(output_dir, split_name)))
101
+ print(f" {split_name}: {n} files")
download/download_sdo.py CHANGED
@@ -91,12 +91,20 @@ class SDODownloader:
91
  obs = pd.to_datetime(date_str, errors='coerce', utc=True).dt.tz_localize(None)
92
  header['_diff'] = (obs - date).abs()
93
 
 
 
 
 
 
 
 
94
  rows = []
95
  for wl in wavelengths:
96
- sub = header[header['WAVELNTH'] == int(wl)]
 
97
  if len(sub) == 0:
98
  continue
99
- good = sub[(sub['QUALITY'] == 0) | sub['QUALITY'].isna()]
100
  if len(good) > 0:
101
  sub = good
102
  else:
@@ -173,13 +181,27 @@ class SDODownloader:
173
 
174
  header['DATE_OBS'] = header['DATE__OBS']
175
  header = header_to_fits(MetaDict(header))
176
- with fits.open(tmp_path, 'update') as f:
177
- hdr = f[1].header
178
- for k, v in header.items():
179
- if pd.isna(v):
180
- continue
181
- hdr[k] = v
182
- f.verify('silentfix')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
  os.replace(tmp_path, map_path)
185
  return map_path
 
91
  obs = pd.to_datetime(date_str, errors='coerce', utc=True).dt.tz_localize(None)
92
  header['_diff'] = (obs - date).abs()
93
 
94
+ # QUALITY comes back from JSOC as a numeric keyword, but drms occasionally
95
+ # yields it as object/string dtype — compare on the coerced numeric value
96
+ # so a dtype quirk can't silently make every frame look "not quality-0"
97
+ # (which would fall through to accepting whatever frame is closest,
98
+ # calibration/dark frames included).
99
+ quality_numeric = pd.to_numeric(header['QUALITY'], errors='coerce')
100
+
101
  rows = []
102
  for wl in wavelengths:
103
+ mask = header['WAVELNTH'] == int(wl)
104
+ sub = header[mask]
105
  if len(sub) == 0:
106
  continue
107
+ good = sub[(quality_numeric[mask] == 0) | quality_numeric[mask].isna()]
108
  if len(good) > 0:
109
  sub = good
110
  else:
 
181
 
182
  header['DATE_OBS'] = header['DATE__OBS']
183
  header = header_to_fits(MetaDict(header))
184
+
185
+ # JSOC serves this segment as a tile-compressed CompImageHDU whose
186
+ # correct physical values depend on internal per-tile scaling
187
+ # (ZSCALE/ZZERO), not just the outer BSCALE/BZERO placeholder.
188
+ # Opening it in 'update' mode and writing header cards in place
189
+ # forces astropy to re-derive that scaling from the (unrelated)
190
+ # placeholder values, silently corrupting the pixel data — most
191
+ # visible on 94A, whose narrow true dynamic range gets clipped to
192
+ # a flat value after calibration. Instead: read the correctly
193
+ # decompressed data once, then write a fresh plain (uncompressed)
194
+ # FITS file with the merged header — never re-touch the original
195
+ # compressed HDU.
196
+ with fits.open(tmp_path) as f:
197
+ data = f[1].data
198
+ hdr = f[1].header.copy()
199
+ for k, v in header.items():
200
+ if pd.isna(v):
201
+ continue
202
+ hdr[k] = v
203
+ fits.HDUList([fits.PrimaryHDU(), fits.ImageHDU(data=data, header=hdr)]).writeto(
204
+ tmp_path, overwrite=True)
205
 
206
  os.replace(tmp_path, map_path)
207
  return map_path
download/download_sxr.py CHANGED
@@ -12,7 +12,7 @@ class SXRDownloader:
12
 
13
  def __init__(self, save_dir: str):
14
  self.save_dir = Path(save_dir)
15
- self.save_dir.mkdir(exist_ok=True)
16
  self.used_g13_files = []
17
  self.used_g14_files = []
18
  self.used_g15_files = []
 
12
 
13
  def __init__(self, save_dir: str):
14
  self.save_dir = Path(save_dir)
15
+ self.save_dir.mkdir(parents=True, exist_ok=True)
16
  self.used_g13_files = []
17
  self.used_g14_files = []
18
  self.used_g15_files = []
download/hf_download_config.yaml CHANGED
@@ -46,6 +46,14 @@ subsample_frac: 0.1 # used only when subsample_n is null (0.0–1.0)
46
  # Only used when subsample: true.
47
  shuffle_buffer_size: 10
48
 
 
 
 
 
 
 
 
 
49
  # -----------------------------------------------------------------------------
50
  # Misc
51
  # -----------------------------------------------------------------------------
 
46
  # Only used when subsample: true.
47
  shuffle_buffer_size: 10
48
 
49
+ # -----------------------------------------------------------------------------
50
+ # Already downloaded parquet files locally instead of streaming from HF Hub?
51
+ # Point this at the root dir containing per-split subdirs (train/, validation/
52
+ # or val/, test/) and streaming + HF Hub login are skipped entirely.
53
+ # -----------------------------------------------------------------------------
54
+ local_parquet_dir: null
55
+ delete_parquet_after: false # delete each parquet file once its rows are written
56
+
57
  # -----------------------------------------------------------------------------
58
  # Misc
59
  # -----------------------------------------------------------------------------
download/hugging_face_data_download.py CHANGED
@@ -1,17 +1,18 @@
1
  """
2
- Download FOXES dataset from HuggingFace Hub
3
- ============================================
4
- Reconstructs the local AIA / SXR directory layout expected by the pipeline:
5
  {aia_dir}/{split}/{filename} — shape (7, 512, 512) float32 .npy
6
  {sxr_dir}/{split}/{filename} — shape (N,) float32 .npy
7
 
8
- Uses streaming so parquet shards are fetched on-the-fly without loading the
9
- full dataset into RAM. A ThreadPoolExecutor overlaps disk writes with network
10
- fetches so the stream never stalls waiting for np.save to finish.
 
 
11
 
12
  Usage:
13
- python download/hugging_face_data_download.py \\
14
- --config download/hf_download_config.yaml
15
  """
16
 
17
  import argparse
@@ -19,8 +20,10 @@ import logging
19
  import os
20
  import time
21
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
22
 
23
  import numpy as np
 
24
  import yaml
25
  from datasets import load_dataset
26
  from huggingface_hub import login
@@ -28,6 +31,7 @@ from huggingface_hub import login
28
 
29
  # HuggingFace uses "validation"; local pipeline directories use "val"
30
  HF_TO_LOCAL = {"validation": "val"}
 
31
 
32
 
33
  def load_config(path: str) -> dict:
@@ -35,6 +39,22 @@ def load_config(path: str) -> dict:
35
  return yaml.safe_load(f)
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def resolve_n(repo_id: str, hf_split: str, cfg: dict) -> int | None:
39
  """
40
  Resolve how many rows to download. Returns an exact count, or None for all rows.
@@ -75,20 +95,6 @@ def build_dataset(repo_id: str, hf_split: str, cfg: dict, n: int | None):
75
  return ds
76
 
77
 
78
- def _write_arrays(filename: str, aia_arr: np.ndarray, sxr_arr: np.ndarray,
79
- aia_split_dir: str, sxr_split_dir: str) -> bool:
80
- """Save pre-materialized arrays to disk. Returns True if written, False if already exists."""
81
- aia_path = os.path.join(aia_split_dir, filename)
82
- sxr_path = os.path.join(sxr_split_dir, filename)
83
-
84
- if os.path.exists(aia_path) and os.path.exists(sxr_path):
85
- return False
86
-
87
- np.save(aia_path, aia_arr)
88
- np.save(sxr_path, sxr_arr)
89
- return True
90
-
91
-
92
  def download_split(hf_split: str, cfg: dict):
93
  local_split = HF_TO_LOCAL.get(hf_split, hf_split)
94
  aia_split_dir = os.path.join(cfg["aia_dir"], local_split)
@@ -156,20 +162,156 @@ def download_split(hf_split: str, cfg: dict):
156
  print(f"[{hf_split}] Done — {saved} saved, {skipped} skipped | {elapsed/60:.1f} min")
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def main():
160
- parser = argparse.ArgumentParser(description="Download FOXES data from HuggingFace Hub")
161
  parser.add_argument("--config", default="download/hf_download_config.yaml")
162
  args = parser.parse_args()
163
-
164
  cfg = load_config(args.config)
165
 
166
- login()
167
-
168
  splits = cfg.get("splits", ["train", "validation", "test"])
169
- for split in splits:
170
- download_split(split, cfg)
171
-
172
- print("\nAll splits downloaded successfully.")
 
 
 
 
 
 
 
 
 
 
 
 
173
  # Silence HF/fsspec connection pool teardown — it throws [Errno 9] Bad file
174
  # descriptor during GC which is harmless but noisy.
175
  for name in ("huggingface_hub", "urllib3", "fsspec", "datasets"):
 
1
  """
2
+ Get FOXES data from HuggingFace Hub: either stream it directly, or convert
3
+ parquet files you've already downloaded locally. Reconstructs the local AIA /
4
+ SXR directory layout expected by the pipeline:
5
  {aia_dir}/{split}/{filename} — shape (7, 512, 512) float32 .npy
6
  {sxr_dir}/{split}/{filename} — shape (N,) float32 .npy
7
 
8
+ Streaming mode fetches parquet shards on-the-fly without loading the full
9
+ dataset into RAM; a ThreadPoolExecutor overlaps disk writes with network
10
+ fetches so the stream never stalls waiting for np.save to finish. Local mode
11
+ reads parquet files you already have on disk, using bulk/vectorized column
12
+ conversion instead of a per-row Python loop.
13
 
14
  Usage:
15
+ python download/hugging_face_data_download.py --config download/hf_download_config.yaml
 
16
  """
17
 
18
  import argparse
 
20
  import os
21
  import time
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from pathlib import Path
24
 
25
  import numpy as np
26
+ import pyarrow.parquet as pq
27
  import yaml
28
  from datasets import load_dataset
29
  from huggingface_hub import login
 
31
 
32
  # HuggingFace uses "validation"; local pipeline directories use "val"
33
  HF_TO_LOCAL = {"validation": "val"}
34
+ LOCAL_TO_HF = {v: k for k, v in HF_TO_LOCAL.items()}
35
 
36
 
37
  def load_config(path: str) -> dict:
 
39
  return yaml.safe_load(f)
40
 
41
 
42
+ def _write_arrays(filename: str, aia_arr: np.ndarray, sxr_arr: np.ndarray,
43
+ aia_split_dir: str, sxr_split_dir: str) -> bool:
44
+ """Save pre-materialized arrays to disk. Returns True if written, False if already exists."""
45
+ aia_path = os.path.join(aia_split_dir, filename)
46
+ sxr_path = os.path.join(sxr_split_dir, filename)
47
+
48
+ if os.path.exists(aia_path) and os.path.exists(sxr_path):
49
+ return False
50
+
51
+ np.save(aia_path, aia_arr)
52
+ np.save(sxr_path, sxr_arr)
53
+ return True
54
+
55
+
56
+ # --------------------------------------------------------------------------- streaming from HF Hub
57
+
58
  def resolve_n(repo_id: str, hf_split: str, cfg: dict) -> int | None:
59
  """
60
  Resolve how many rows to download. Returns an exact count, or None for all rows.
 
95
  return ds
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def download_split(hf_split: str, cfg: dict):
99
  local_split = HF_TO_LOCAL.get(hf_split, hf_split)
100
  aia_split_dir = os.path.join(cfg["aia_dir"], local_split)
 
162
  print(f"[{hf_split}] Done — {saved} saved, {skipped} skipped | {elapsed/60:.1f} min")
163
 
164
 
165
+ # --------------------------------------------------------------------------- already-downloaded local parquet
166
+
167
+ def _resolve_local_split_dir(local_parquet_dir: str, hf_split: str):
168
+ """Find the on-disk subdir for this split, trying both the HF name and its local alias."""
169
+ for candidate in (hf_split, HF_TO_LOCAL.get(hf_split), LOCAL_TO_HF.get(hf_split)):
170
+ if candidate is None:
171
+ continue
172
+ split_dir = os.path.join(local_parquet_dir, candidate)
173
+ if os.path.isdir(split_dir):
174
+ return split_dir
175
+ return None
176
+
177
+
178
+ def _to_numpy_bulk(col, n):
179
+ """
180
+ Flatten a nested fixed-size list column directly to numpy, then reshape to
181
+ (n, *element_shape). Avoids per-row Python overhead (table.slice / .as_py()
182
+ on large nested arrays).
183
+ """
184
+ chunk = col.combine_chunks()
185
+ # Walk the array levels to recover per-element shape. FixedSizeList exposes
186
+ # list_size directly; regular List (which HF uses even for fixed-size
187
+ # arrays) stores a uniform stride in its offsets buffer — both give the
188
+ # same answer.
189
+ shape = []
190
+ vals = chunk
191
+ while hasattr(vals, "values"):
192
+ if hasattr(vals, "list_size"): # FixedSizeList
193
+ shape.append(vals.list_size)
194
+ elif hasattr(vals, "offsets"): # List — stride from offsets
195
+ shape.append(vals.offsets[1].as_py())
196
+ vals = vals.values
197
+ arr = vals.to_numpy(zero_copy_only=False).astype(np.float32)
198
+ return arr.reshape(n, *shape) if shape else arr.reshape(n, arr.size // n)
199
+
200
+
201
+ def convert_local_parquet_split(parquet_dir: str, hf_split: str, cfg: dict):
202
+ local_split = HF_TO_LOCAL.get(hf_split, hf_split)
203
+ aia_split_dir = os.path.join(cfg["aia_dir"], local_split)
204
+ sxr_split_dir = os.path.join(cfg["sxr_dir"], local_split)
205
+ os.makedirs(aia_split_dir, exist_ok=True)
206
+ os.makedirs(sxr_split_dir, exist_ok=True)
207
+
208
+ parquet_files = sorted(Path(parquet_dir).glob("*.parquet"))
209
+ if not parquet_files:
210
+ print(f"[warn] No parquet files found in {parquet_dir}")
211
+ return
212
+
213
+ print(f"\n{'='*50}")
214
+ print(f"Converting split: {hf_split} -> local dir: {local_split}")
215
+ print(f"{'='*50}")
216
+ print(f" Parquet dir: {parquet_dir} ({len(parquet_files)} files)")
217
+ print(f" AIA -> {aia_split_dir}")
218
+ print(f" SXR -> {sxr_split_dir}")
219
+
220
+ num_workers = cfg.get("num_workers", 8)
221
+ print_every = cfg.get("print_every", 500)
222
+ delete_after = cfg.get("delete_parquet_after", False)
223
+ saved = skipped = submitted = 0
224
+ start = time.time()
225
+
226
+ with ThreadPoolExecutor(max_workers=num_workers) as pool:
227
+ futures = {}
228
+
229
+ for pq_file in parquet_files:
230
+ table = pq.read_table(pq_file, columns=["filename", "aia_stack", "sxr_value"])
231
+ n_rows = len(table)
232
+ filenames = table.column("filename").to_pylist()
233
+
234
+ try:
235
+ aia_bulk = _to_numpy_bulk(table.column("aia_stack"), n_rows)
236
+ use_bulk_aia = True
237
+ except Exception:
238
+ aia_pylist = table.column("aia_stack").to_pylist()
239
+ use_bulk_aia = False
240
+
241
+ try:
242
+ sxr_bulk = _to_numpy_bulk(table.column("sxr_value"), n_rows)
243
+ use_bulk_sxr = True
244
+ except Exception:
245
+ sxr_pylist = table.column("sxr_value").to_pylist()
246
+ use_bulk_sxr = False
247
+
248
+ file_futures = []
249
+ for i, filename in enumerate(filenames):
250
+ aia_arr = aia_bulk[i] if use_bulk_aia else np.array(aia_pylist[i], dtype=np.float32)
251
+ sxr_arr = sxr_bulk[i] if use_bulk_sxr else np.array(sxr_pylist[i], dtype=np.float32)
252
+
253
+ fut = pool.submit(_write_arrays, filename, aia_arr, sxr_arr, aia_split_dir, sxr_split_dir)
254
+ file_futures.append(fut)
255
+ futures[fut] = submitted
256
+ submitted += 1
257
+
258
+ if submitted % print_every == 0:
259
+ done = [f for f in futures if f.done()]
260
+ for f in done:
261
+ if f.result():
262
+ saved += 1
263
+ else:
264
+ skipped += 1
265
+ del futures[f]
266
+
267
+ elapsed = time.time() - start
268
+ rate = submitted / elapsed if elapsed > 0 else 0
269
+ print(
270
+ f"[{hf_split}] submitted={submitted} | saved={saved} skipped={skipped} | "
271
+ f"{rate:.1f} rows/sec",
272
+ flush=True,
273
+ )
274
+
275
+ # Wait for all writes from this file to finish before deleting it
276
+ if delete_after:
277
+ for fut in as_completed(file_futures):
278
+ pass
279
+ pq_file.unlink()
280
+ print(f" Deleted {pq_file.name}", flush=True)
281
+
282
+ for fut in as_completed(futures):
283
+ if fut.result():
284
+ saved += 1
285
+ else:
286
+ skipped += 1
287
+
288
+ elapsed = time.time() - start
289
+ print(f"[{hf_split}] Done — {saved} saved, {skipped} skipped | {elapsed/60:.1f} min")
290
+
291
+
292
  def main():
293
+ parser = argparse.ArgumentParser(description="Get FOXES data from HuggingFace Hub (stream or local parquet)")
294
  parser.add_argument("--config", default="download/hf_download_config.yaml")
295
  args = parser.parse_args()
 
296
  cfg = load_config(args.config)
297
 
 
 
298
  splits = cfg.get("splits", ["train", "validation", "test"])
299
+ local_parquet_dir = cfg.get("local_parquet_dir")
300
+
301
+ if local_parquet_dir:
302
+ print(f"Converting local parquet files from {local_parquet_dir} (no HF Hub access needed)")
303
+ for split in splits:
304
+ split_dir = _resolve_local_split_dir(local_parquet_dir, split)
305
+ if split_dir is None:
306
+ print(f"[warn] Split dir not found under {local_parquet_dir}, skipping: {split}")
307
+ continue
308
+ convert_local_parquet_split(split_dir, split, cfg)
309
+ else:
310
+ login()
311
+ for split in splits:
312
+ download_split(split, cfg)
313
+
314
+ print("\nAll splits done.")
315
  # Silence HF/fsspec connection pool teardown — it throws [Errno 9] Bad file
316
  # descriptor during GC which is harmless but noisy.
317
  for name in ("huggingface_hub", "urllib3", "fsspec", "datasets"):
download/sdo_download_config.yaml CHANGED
@@ -10,11 +10,11 @@
10
  # python download/download_sdo.py --config download/sdo_download_config.yaml
11
  # =============================================================================
12
 
13
- download_dir: "/path/to/AIA_raw"
14
- email: "your.email@example.com" # must be registered with JSOC: http://jsoc.stanford.edu/ajax/register_email.html
15
 
16
  start_date: "2023-08-01 00:00:00"
17
- end_date: "2023-08-02 00:00:00"
18
- cadence: 60 # minutes between samples
19
 
20
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
 
10
  # python download/download_sdo.py --config download/sdo_download_config.yaml
11
  # =============================================================================
12
 
13
+ download_dir: "/Volumes/T9/testing_foxes/AIA_raw"
14
+ email: "ggoodwin5@gsu.edu" # must be registered with JSOC: http://jsoc.stanford.edu/ajax/register_email.html
15
 
16
  start_date: "2023-08-01 00:00:00"
17
+ end_date: "2023-12-01 00:00:00"
18
+ cadence: 7200 # minutes between samples
19
 
20
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
download/sxr_download_config.yaml CHANGED
@@ -1,19 +1,19 @@
1
  # =============================================================================
2
  # Raw GOES/SXR Download Configuration
3
  # =============================================================================
4
- # Used by sxr_downloader.py to pull raw GOES XRS netCDF files via SunPy Fido.
5
  # This is the advanced/raw path — most users should use
6
  # hugging_face_data_download.py instead (see the README). Data downloaded
7
  # here needs the data/ scripts to become the paired .npy format the model
8
  # expects.
9
  #
10
  # Usage:
11
- # python download/sxr_downloader.py --config download/sxr_download_config.yaml
12
  # =============================================================================
13
 
14
- save_dir: "/path/to/GOES_raw"
15
 
16
  start_date: "2023-08-01"
17
- end_date: "2023-08-02"
18
 
19
  max_workers: 4
 
1
  # =============================================================================
2
  # Raw GOES/SXR Download Configuration
3
  # =============================================================================
4
+ # Used by download_sxr.py to pull raw GOES XRS netCDF files via SunPy Fido.
5
  # This is the advanced/raw path — most users should use
6
  # hugging_face_data_download.py instead (see the README). Data downloaded
7
  # here needs the data/ scripts to become the paired .npy format the model
8
  # expects.
9
  #
10
  # Usage:
11
+ # python download/download_sxr.py --config download/sxr_download_config.yaml
12
  # =============================================================================
13
 
14
+ save_dir: "/Volumes/T9/testing_foxes/GOES_raw"
15
 
16
  start_date: "2023-08-01"
17
+ end_date: "2023-08-31"
18
 
19
  max_workers: 4
forecasting/dataset.py CHANGED
@@ -4,6 +4,8 @@ import pandas as pd
4
  import torch
5
  import numpy as np
6
  from pathlib import Path
 
 
7
  import glob
8
 
9
 
@@ -170,3 +172,56 @@ class AIAGOESDataset(torch.utils.data.Dataset):
170
  return timestamp
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import torch
5
  import numpy as np
6
  from pathlib import Path
7
+ from pytorch_lightning import LightningDataModule
8
+ from torch.utils.data import DataLoader
9
  import glob
10
 
11
 
 
172
  return timestamp
173
 
174
 
175
+ class AIAGOESDataModule(LightningDataModule):
176
+ """
177
+ PyTorch Lightning DataModule wiring up train/val/test AIAGOESDataset splits.
178
+ Used by train.py.
179
+
180
+ Parameters
181
+ ----------
182
+ aia_train_dir, aia_val_dir, aia_test_dir : str
183
+ Directories of AIA .npy files for each split.
184
+ sxr_train_dir, sxr_val_dir, sxr_test_dir : str
185
+ Directories of SXR .npy files for each split.
186
+ sxr_norm : np.ndarray
187
+ (mean, std) used to log-normalize SXR targets.
188
+ batch_size, num_workers : int
189
+ wavelengths : list of int
190
+ """
191
+
192
+ def __init__(self, aia_train_dir, aia_val_dir, aia_test_dir, sxr_train_dir, sxr_val_dir, sxr_test_dir,
193
+ sxr_norm, batch_size=64, num_workers=4, wavelengths=[94, 131, 171, 193, 211, 304, 335]):
194
+ super().__init__()
195
+ self.aia_train_dir = aia_train_dir
196
+ self.aia_val_dir = aia_val_dir
197
+ self.aia_test_dir = aia_test_dir
198
+ self.sxr_train_dir = sxr_train_dir
199
+ self.sxr_val_dir = sxr_val_dir
200
+ self.sxr_test_dir = sxr_test_dir
201
+ self.sxr_norm = sxr_norm
202
+ self.batch_size = batch_size
203
+ self.num_workers = num_workers
204
+ self.wavelengths = wavelengths
205
+
206
+ def setup(self, stage=None):
207
+ transform = SXRLogNormTransform(self.sxr_norm[0], self.sxr_norm[1])
208
+ self.train_ds = AIAGOESDataset(aia_dir=self.aia_train_dir, sxr_dir=self.sxr_train_dir,
209
+ sxr_transform=transform, wavelengths=self.wavelengths)
210
+ self.val_ds = AIAGOESDataset(aia_dir=self.aia_val_dir, sxr_dir=self.sxr_val_dir,
211
+ sxr_transform=transform, wavelengths=self.wavelengths)
212
+ self.test_ds = AIAGOESDataset(aia_dir=self.aia_test_dir, sxr_dir=self.sxr_test_dir,
213
+ sxr_transform=transform, wavelengths=self.wavelengths)
214
+
215
+ def train_dataloader(self):
216
+ return DataLoader(self.train_ds, batch_size=self.batch_size, shuffle=True,
217
+ num_workers=self.num_workers, prefetch_factor=4 if self.num_workers else None)
218
+
219
+ def val_dataloader(self):
220
+ return DataLoader(self.val_ds, batch_size=self.batch_size, shuffle=False,
221
+ num_workers=self.num_workers, prefetch_factor=4 if self.num_workers else None)
222
+
223
+ def test_dataloader(self):
224
+ return DataLoader(self.test_ds, batch_size=self.batch_size, shuffle=False,
225
+ num_workers=self.num_workers, prefetch_factor=1 if self.num_workers else None)
226
+
227
+
forecasting/evaluation.py CHANGED
@@ -474,7 +474,7 @@ def load_evaluation_config(config_path):
474
  config_data = yaml.load(stream, Loader=yaml.SafeLoader)
475
 
476
  # Resolve variable substitutions
477
- config_data = resolve_config_variables(config_data)
478
  return config_data
479
 
480
 
@@ -488,7 +488,7 @@ def main():
488
  import argparse
489
 
490
  parser = argparse.ArgumentParser(description='Run FOXES solar flare evaluation')
491
- parser.add_argument('-config', type=str, default='evaluation_config.yaml',
492
  help='Path to evaluation config YAML file')
493
  args = parser.parse_args()
494
 
 
474
  config_data = yaml.load(stream, Loader=yaml.SafeLoader)
475
 
476
  # Resolve variable substitutions
477
+ config_data: dict = resolve_config_variables(config_data)
478
  return config_data
479
 
480
 
 
488
  import argparse
489
 
490
  parser = argparse.ArgumentParser(description='Run FOXES solar flare evaluation')
491
+ parser.add_argument('--config', type=str, default='evaluation_config.yaml',
492
  help='Path to evaluation config YAML file')
493
  args = parser.parse_args()
494
 
forecasting/evaluation_config.yaml CHANGED
@@ -3,7 +3,7 @@
3
  # =============================================================================
4
  # Used by evaluation.py to compute metrics and generate plots.
5
  #
6
- # Usage: python evaluation.py -config evaluation_config.yaml
7
  # =============================================================================
8
 
9
  model_predictions:
 
3
  # =============================================================================
4
  # Used by evaluation.py to compute metrics and generate plots.
5
  #
6
+ # Usage: python evaluation.py --config evaluation_config.yaml
7
  # =============================================================================
8
 
9
  model_predictions:
forecasting/inference.py CHANGED
@@ -143,8 +143,6 @@ def evaluate_model_on_dataset(model, dataset, batch_size=16, times=None, config_
143
  flux_contributions = None
144
 
145
  current_batch_size = predictions.shape[0]
146
- batch_weights = []
147
- batch_flux_contributions = []
148
 
149
  # Process each sample in the batch to reduce memory footprint
150
  for i in range(current_batch_size):
@@ -319,14 +317,14 @@ def main():
319
  return recursive_substitute(config_dict, variables)
320
 
321
  parser = argparse.ArgumentParser()
322
- parser.add_argument('-config', type=str, default='inference_config.yaml', required=True,
323
  help='Path to the inference configuration YAML file.')
324
  args = parser.parse_args()
325
 
326
  with open(args.config, 'r') as stream:
327
  config_data = yaml.load(stream, Loader=yaml.SafeLoader)
328
 
329
- config_data = resolve_config_variables(config_data)
330
 
331
  model_params = config_data.get('model_params', {})
332
  input_size = model_params.get('input_size', 512)
 
143
  flux_contributions = None
144
 
145
  current_batch_size = predictions.shape[0]
 
 
146
 
147
  # Process each sample in the batch to reduce memory footprint
148
  for i in range(current_batch_size):
 
317
  return recursive_substitute(config_dict, variables)
318
 
319
  parser = argparse.ArgumentParser()
320
+ parser.add_argument('--config', type=str, default='inference_config.yaml', required=True,
321
  help='Path to the inference configuration YAML file.')
322
  args = parser.parse_args()
323
 
324
  with open(args.config, 'r') as stream:
325
  config_data = yaml.load(stream, Loader=yaml.SafeLoader)
326
 
327
+ config_data: dict = resolve_config_variables(config_data)
328
 
329
  model_params = config_data.get('model_params', {})
330
  input_size = model_params.get('input_size', 512)
forecasting/inference_config.yaml CHANGED
@@ -5,7 +5,7 @@
5
  # AIA data and write out SXR predictions (+ optional attention/flux maps).
6
  #
7
  # Usage:
8
- # python forecasting/inference.py -config forecasting/inference_config.yaml
9
  # =============================================================================
10
 
11
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
 
5
  # AIA data and write out SXR predictions (+ optional attention/flux maps).
6
  #
7
  # Usage:
8
+ # python forecasting/inference.py --config forecasting/inference_config.yaml
9
  # =============================================================================
10
 
11
  wavelengths: [94, 131, 171, 193, 211, 304, 335]
forecasting/trained_weights_and_normalization/{FOXES_Model_Checkpoint.ckpt → inverted-attention-mask.ckpt} RENAMED
File without changes
forecasting/trained_weights_and_normalization/localized-attention-mask.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:df37bef4ac95a3c14e777221ce1fd3fbdf56da78a192d7418303848d84aa2f46
3
+ size 224141974
forecasting/trained_weights_and_normalization/no-attention-mask.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d6965e5b7c1f41565b1eebf059ce5506d6e6c8b8483f7b569a854f1902ae45b
3
+ size 89939767
training/__init__.py ADDED
File without changes
training/callbacks.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyTorch Lightning callbacks for visualizing training progress: predicted vs.
3
+ true SXR flux, and Vision Transformer attention maps, logged to Weights & Biases.
4
+
5
+ Used by train.py — not meant to be run standalone.
6
+ """
7
+ import wandb
8
+ from pytorch_lightning import Callback
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import torch
12
+
13
+ from forecasting.model import unnormalize_sxr
14
+
15
+
16
+ class ImagePredictionLogger_SXR(Callback):
17
+ """
18
+ PyTorch Lightning callback for logging AIA input images and corresponding
19
+ true vs predicted Soft X-Ray (SXR) flux values to Weights & Biases (wandb).
20
+
21
+ This helps monitor model performance across validation epochs by
22
+ comparing predicted vs. ground-truth flare intensities.
23
+ """
24
+
25
+ def __init__(self, data_samples, sxr_norm):
26
+ """
27
+ Initialize callback with validation samples and normalization parameters.
28
+
29
+ Parameters
30
+ ----------
31
+ data_samples : list
32
+ List of validation samples (AIA image, SXR target pairs).
33
+ sxr_norm : np.ndarray
34
+ Normalization statistics used to unnormalize predicted flux values.
35
+ """
36
+ super().__init__()
37
+ self.data_samples = data_samples
38
+ self.sxr_norm = sxr_norm
39
+
40
+ def on_validation_epoch_end(self, trainer, pl_module):
41
+ """
42
+ Log scatter plots comparing predicted and true SXR flux values
43
+ at the end of each validation epoch.
44
+ """
45
+ true_sxr = []
46
+ pred_sxr = []
47
+
48
+ for aia, target in self.data_samples:
49
+ aia = aia.to(pl_module.device).unsqueeze(0)
50
+ # forward() always returns a tuple (global_flux_raw, ...); we only need the flux.
51
+ pred, *_ = pl_module(aia, return_attention=False)
52
+ pred_sxr.append(pred.item())
53
+ true_sxr.append(target.item())
54
+
55
+ true_unorm = unnormalize_sxr(np.array(true_sxr, dtype=np.float32), self.sxr_norm)
56
+ pred_unnorm = unnormalize_sxr(np.array(pred_sxr, dtype=np.float32), self.sxr_norm)
57
+
58
+ fig1 = self.plot_aia_sxr(true_unorm, pred_unnorm)
59
+ trainer.logger.experiment.log({"Soft X-ray flux plots": wandb.Image(fig1)})
60
+ plt.close(fig1)
61
+
62
+ # Flare-class range: A-class starts at 1e-8 W/m^2, X10 is 10x the 1e-4 X-class threshold.
63
+ AXIS_MIN = 1e-8
64
+ AXIS_MAX = 1e-3
65
+
66
+ def plot_aia_sxr(self, val_sxr, pred_sxr):
67
+ """Log-log parity plot: predicted vs. true SXR flux, with a 1:1 reference line."""
68
+ fig, ax = plt.subplots(1, 1, figsize=(4, 4))
69
+
70
+ ax.plot([self.AXIS_MIN, self.AXIS_MAX], [self.AXIS_MIN, self.AXIS_MAX],
71
+ color='gray', linestyle='--', linewidth=1, label='Perfect prediction')
72
+ ax.scatter(val_sxr, pred_sxr, color='blue', alpha=0.7,s=10, label='Predictions')
73
+
74
+ ax.set_xscale('log')
75
+ ax.set_yscale('log')
76
+ ax.set_xlim(self.AXIS_MIN, self.AXIS_MAX)
77
+ ax.set_ylim(self.AXIS_MIN, self.AXIS_MAX)
78
+ ax.set_xlabel("True SXR flux [W/m$^2$]")
79
+ ax.set_ylabel("Predicted SXR flux [W/m$^2$]")
80
+ ax.legend()
81
+ fig.tight_layout()
82
+ return fig
83
+
84
+
85
+ class AttentionMapCallback(Callback):
86
+ """
87
+ PyTorch Lightning callback for visualizing transformer attention maps
88
+ during validation epochs.
89
+
90
+ Supports CLS-token-based and local patch attention visualization.
91
+ """
92
+
93
+ def __init__(self, log_every_n_epochs=1, num_samples=4, patch_size=8, use_local_attention=False):
94
+ """
95
+ Initialize callback.
96
+
97
+ Parameters
98
+ ----------
99
+ log_every_n_epochs : int
100
+ Frequency of logging attention maps.
101
+ num_samples : int
102
+ Number of samples to visualize per epoch.
103
+ patch_size : int
104
+ Patch size used in the Vision Transformer.
105
+ use_local_attention : bool
106
+ If True, visualize local attention patterns instead of CLS attention.
107
+ """
108
+ super().__init__()
109
+ self.patch_size = patch_size
110
+ self.log_every_n_epochs = log_every_n_epochs
111
+ self.num_samples = num_samples
112
+ self.use_local_attention = use_local_attention
113
+
114
+ def on_validation_epoch_end(self, trainer, pl_module):
115
+ """Trigger visualization of attention maps at the end of validation epochs."""
116
+ if trainer.current_epoch % self.log_every_n_epochs == 0:
117
+ self._visualize_attention(trainer, pl_module)
118
+
119
+ def _visualize_attention(self, trainer, pl_module):
120
+ """Generate and log attention maps from the model's attention weights."""
121
+ val_dataloader = trainer.val_dataloaders
122
+ if val_dataloader is None:
123
+ return
124
+
125
+ pl_module.eval()
126
+ with torch.no_grad():
127
+ batch = next(iter(val_dataloader))
128
+ imgs, labels = batch
129
+ imgs = imgs[:self.num_samples].to(pl_module.device)
130
+
131
+ # forward(return_attention=True) -> (global_flux_raw, attention_weights, patch_flux_raw)
132
+ _, attention_weights, patch_flux_raw = pl_module(imgs, return_attention=True)
133
+
134
+ for sample_idx in range(min(self.num_samples, imgs.size(0))):
135
+ fig = self._plot_attention_map(
136
+ imgs[sample_idx],
137
+ attention_weights,
138
+ sample_idx,
139
+ trainer.current_epoch,
140
+ patch_size=self.patch_size,
141
+ patch_flux=patch_flux_raw[sample_idx] if patch_flux_raw is not None else None
142
+ )
143
+ trainer.logger.experiment.log({"Attention plots": wandb.Image(fig)})
144
+ plt.close(fig)
145
+
146
+ def _plot_attention_map(self, image, attention_weights, sample_idx, epoch, patch_size, patch_flux=None):
147
+ """Plot and return a visualization of the attention heatmaps for a single image."""
148
+ img_np = image.cpu().numpy()
149
+ if len(img_np.shape) == 3 and img_np.shape[0] in [1, 3]:
150
+ img_np = np.transpose(img_np, (1, 2, 0))
151
+
152
+ H, W = img_np.shape[:2]
153
+ grid_h, grid_w = H // patch_size, W // patch_size
154
+
155
+ last_layer_attention = attention_weights[-1]
156
+ sample_attention = last_layer_attention[sample_idx]
157
+ avg_attention = sample_attention.mean(dim=0)
158
+
159
+ if self.use_local_attention:
160
+ # Spatial center of the grid, not the middle of the flattened sequence
161
+ # (those only coincide when grid_w == 1) — row-major flatten: idx = row*grid_w + col.
162
+ center_patch_idx = (grid_h // 2) * grid_w + (grid_w // 2)
163
+ center_attention = avg_attention[center_patch_idx, :].cpu()
164
+ avg_attention_map = avg_attention.mean(dim=0).cpu()
165
+ attention_map = avg_attention_map.reshape(grid_h, grid_w)
166
+ center_map = center_attention.reshape(grid_h, grid_w)
167
+ else:
168
+ cls_attention = avg_attention[0, 1:].cpu()
169
+ attention_map = cls_attention.reshape(grid_h, grid_w)
170
+ center_map = None
171
+
172
+ if len(img_np[0, 0, :]) >= 6:
173
+ rgb_channels = [0, 2, 4]
174
+ img_display = np.stack([(img_np[:, :, i] + 1) / 2 for i in rgb_channels], axis=2)
175
+ img_display = np.clip(img_display, 0, 1)
176
+ else:
177
+ img_display = (img_np[:, :, 0] + 1) / 2
178
+ img_display = np.stack([img_display] * 3, axis=2)
179
+
180
+ fig, axes = plt.subplots(2, 3, figsize=(15, 10))
181
+ fig.suptitle(f'Attention Visualization - Epoch {epoch}, Sample {sample_idx}', fontsize=16)
182
+
183
+ axes[0, 0].imshow(img_display)
184
+ axes[0, 0].set_title('Original Image')
185
+ axes[0, 0].axis('off')
186
+
187
+ im1 = axes[0, 1].imshow(attention_map, cmap='hot', interpolation='nearest')
188
+ axes[0, 1].set_title('Attention Map')
189
+ axes[0, 1].axis('off')
190
+ plt.colorbar(im1, ax=axes[0, 1])
191
+
192
+ axes[0, 2].imshow(img_display)
193
+ axes[0, 2].imshow(attention_map, cmap='hot', alpha=0.6, interpolation='nearest')
194
+ axes[0, 2].set_title('Attention Overlay')
195
+ axes[0, 2].axis('off')
196
+
197
+ if center_map is not None:
198
+ im2 = axes[1, 0].imshow(center_map, cmap='hot', interpolation='nearest')
199
+ axes[1, 0].set_title('Center Patch Attention')
200
+ axes[1, 0].axis('off')
201
+ plt.colorbar(im2, ax=axes[1, 0])
202
+ else:
203
+ axes[1, 0].text(0.5, 0.5, 'Center attention\nnot available',
204
+ ha='center', va='center', transform=axes[1, 0].transAxes)
205
+ axes[1, 0].set_title('Center Patch Attention')
206
+ axes[1, 0].axis('off')
207
+
208
+ if patch_flux is not None:
209
+ patch_flux_np = patch_flux.cpu().numpy().reshape(grid_h, grid_w)
210
+ im3 = axes[1, 1].imshow(patch_flux_np, cmap='viridis', interpolation='nearest')
211
+ axes[1, 1].set_title('Patch Flux')
212
+ axes[1, 1].axis('off')
213
+ plt.colorbar(im3, ax=axes[1, 1])
214
+ else:
215
+ axes[1, 1].text(0.5, 0.5, 'Patch flux\nnot available',
216
+ ha='center', va='center', transform=axes[1, 1].transAxes)
217
+ axes[1, 1].set_title('Patch Flux')
218
+ axes[1, 1].axis('off')
219
+
220
+ axes[1, 2].hist(attention_map.flatten(), bins=50, alpha=0.7)
221
+ axes[1, 2].set_title('Attention Distribution')
222
+ axes[1, 2].set_xlabel('Attention Weight')
223
+ axes[1, 2].set_ylabel('Frequency')
224
+
225
+ plt.tight_layout()
226
+ return fig
training/train.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training script for the AIA-GOES multimodal solar flare forecasting model using PyTorch Lightning.
3
+
4
+ This script:
5
+ 1. Loads configuration from a YAML file with variable substitution (e.g., ${base_dir} references).
6
+ 2. Initializes the AIA-GOES DataModule.
7
+ 3. Configures logging with Weights & Biases.
8
+ 4. Builds and trains a Vision Transformer (ViTLocal) model.
9
+ 5. Optionally computes dynamic base class weights for flare categories (Quiet, C, M, X).
10
+ 6. Saves model checkpoints (.ckpt).
11
+
12
+ Usage:
13
+ python training/train.py --config training/train_config.yaml
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import torch
24
+ import wandb
25
+ import yaml
26
+ from pytorch_lightning import Trainer
27
+ from pytorch_lightning.callbacks import ModelCheckpoint
28
+ from pytorch_lightning.loggers import WandbLogger
29
+
30
+ PROJECT_ROOT = Path(__file__).parent.parent.absolute()
31
+ sys.path.insert(0, str(PROJECT_ROOT))
32
+
33
+ from training.callbacks import AttentionMapCallback, ImagePredictionLogger_SXR
34
+ from forecasting.dataset import AIAGOESDataModule
35
+ from forecasting.model import ViTLocal, unnormalize_sxr
36
+
37
+
38
+ def resolve_config_variables(config_dict):
39
+ """
40
+ Recursively resolve ${variable} references within the config.
41
+
42
+ This function processes configuration dictionaries to substitute variable
43
+ references of the form ${variable_name} with their actual values defined
44
+ elsewhere in the configuration.
45
+ """
46
+ variables = {}
47
+ for key, value in config_dict.items():
48
+ if isinstance(value, str) and not value.startswith('${'):
49
+ variables[key] = value
50
+
51
+ def substitute_value(value, variables):
52
+ if isinstance(value, str):
53
+ pattern = r'\$\{([^}]+)\}'
54
+ for match in re.finditer(pattern, value):
55
+ var_name = match.group(1)
56
+ if var_name in variables:
57
+ value = value.replace(f'${{{var_name}}}', variables[var_name])
58
+ return value
59
+
60
+ def recursive_substitute(obj, variables):
61
+ if isinstance(obj, dict):
62
+ return {k: recursive_substitute(v, variables) for k, v in obj.items()}
63
+ elif isinstance(obj, list):
64
+ return [recursive_substitute(item, variables) for item in obj]
65
+ else:
66
+ return substitute_value(obj, variables)
67
+
68
+ return recursive_substitute(config_dict, variables)
69
+
70
+
71
+ def get_base_weights(data_module, sxr_norm):
72
+ """
73
+ Compute inverse-frequency weights for flare classes based on training data.
74
+
75
+ The weights help balance loss contributions from imbalanced flare categories
76
+ by making rare classes (M/X flares) count for more in the loss.
77
+
78
+ Parameters
79
+ ----------
80
+ data_module : AIAGOESDataModule
81
+ Initialized DataModule providing the train_dataloader.
82
+ sxr_norm : np.ndarray
83
+ Normalization parameters for SXR.
84
+
85
+ Returns
86
+ -------
87
+ dict
88
+ Class weights for quiet, C, M, and X classes.
89
+ """
90
+ print("Calculating base weights from training data...")
91
+ c_threshold, m_threshold, x_threshold = 1e-6, 1e-5, 1e-4
92
+
93
+ quiet_count = c_count = m_count = x_count = total = 0
94
+ train_loader = data_module.train_dataloader()
95
+ print(f"Processing {len(train_loader)} batches...")
96
+
97
+ for batch_idx, (aia_batch, sxr_batch) in enumerate(train_loader):
98
+ if batch_idx % 50 == 0:
99
+ print(f"Processed {batch_idx}/{len(train_loader)} batches...")
100
+
101
+ sxr_un = unnormalize_sxr(sxr_batch, sxr_norm)
102
+ sxr_un_flat = sxr_un.reshape(-1)
103
+
104
+ total += len(sxr_un_flat)
105
+ quiet_count += (sxr_un_flat < c_threshold).sum()
106
+ c_count += ((sxr_un_flat >= c_threshold) & (sxr_un_flat < m_threshold)).sum()
107
+ m_count += ((sxr_un_flat >= m_threshold) & (sxr_un_flat < x_threshold)).sum()
108
+ x_count += (sxr_un_flat >= x_threshold).sum()
109
+
110
+ quiet_count, c_count, m_count, x_count = (max(c, 1) for c in (quiet_count, c_count, m_count, x_count))
111
+
112
+ weights = {
113
+ 'quiet': total / quiet_count,
114
+ 'c_class': total / c_count,
115
+ 'm_class': total / m_count,
116
+ 'x_class': total / x_count,
117
+ }
118
+ print(f"Total samples: {total}")
119
+ print(f"Quiet: {quiet_count} (weight {weights['quiet']:.4f}), "
120
+ f"C: {c_count} (weight {weights['c_class']:.4f}), "
121
+ f"M: {m_count} (weight {weights['m_class']:.4f}), "
122
+ f"X: {x_count} (weight {weights['x_class']:.4f})")
123
+ return weights
124
+
125
+
126
+ def resolve_devices(gpu_config):
127
+ """Resolve accelerator/devices/strategy from the gpu_ids config value."""
128
+ if gpu_config == -1:
129
+ print("Using CPU for training")
130
+ return "cpu", 1, "auto"
131
+
132
+ if not torch.cuda.is_available():
133
+ print("No GPUs available, falling back to CPU")
134
+ return "cpu", 1, "auto"
135
+
136
+ if gpu_config == "all":
137
+ print(f"Using all available GPUs ({torch.cuda.device_count()} GPUs)")
138
+ return "gpu", -1, "auto"
139
+ if isinstance(gpu_config, list):
140
+ print(f"Using GPUs: {gpu_config}")
141
+ return "gpu", gpu_config, "auto"
142
+ print(f"Using GPU {gpu_config}")
143
+ return "gpu", [gpu_config], "auto"
144
+
145
+
146
+ def main():
147
+ parser = argparse.ArgumentParser(description='Train the FOXES ViTLocal model.')
148
+ parser.add_argument('--config', type=str, default='training/train_config.yaml', required=True,
149
+ help='Path to train_config.yaml')
150
+ args = parser.parse_args()
151
+
152
+ with open(args.config, 'r') as stream:
153
+ config_data = yaml.load(stream, Loader=yaml.SafeLoader)
154
+ config_data: dict = resolve_config_variables(config_data)
155
+
156
+ print("Resolved paths:")
157
+ print(f"AIA dir: {config_data['data']['aia_dir']}")
158
+ print(f"SXR dir: {config_data['data']['sxr_dir']}")
159
+ print(f"Checkpoints dir: {config_data['data']['checkpoints_dir']}")
160
+
161
+ sxr_norm = np.load(config_data['data']['sxr_norm_path'])
162
+ wavelengths = config_data['wavelengths']
163
+
164
+ data_module = AIAGOESDataModule(
165
+ aia_train_dir=config_data['data']['aia_dir'] + "/train",
166
+ aia_val_dir=config_data['data']['aia_dir'] + "/val",
167
+ aia_test_dir=config_data['data']['aia_dir'] + "/test",
168
+ sxr_train_dir=config_data['data']['sxr_dir'] + "/train",
169
+ sxr_val_dir=config_data['data']['sxr_dir'] + "/val",
170
+ sxr_test_dir=config_data['data']['sxr_dir'] + "/test",
171
+ batch_size=config_data['batch_size'],
172
+ num_workers=min(8, os.cpu_count()),
173
+ sxr_norm=sxr_norm,
174
+ wavelengths=wavelengths,
175
+ )
176
+ data_module.setup()
177
+
178
+ wandb_logger = WandbLogger(
179
+ entity=config_data['wandb']['entity'],
180
+ project=config_data['wandb']['project'],
181
+ job_type=config_data['wandb']['job_type'],
182
+ tags=config_data['wandb']['tags'],
183
+ name=config_data['wandb']['run_name'],
184
+ notes=config_data['wandb']['notes'],
185
+ config=config_data,
186
+ )
187
+
188
+ # Callbacks
189
+ total_n_valid = len(data_module.val_ds)
190
+ plot_samples = [data_module.val_ds[i] for i in range(0, total_n_valid, max(1, total_n_valid // 4))]
191
+ sxr_plot_callback = ImagePredictionLogger_SXR(plot_samples, sxr_norm)
192
+ patch_size = config_data.get('vit_architecture', {}).get('patch_size', 16)
193
+ attention_callback = AttentionMapCallback(patch_size=patch_size, use_local_attention=True)
194
+
195
+ base_weights = get_base_weights(data_module, sxr_norm) if config_data.get('calculate_base_weights') else None
196
+ model = ViTLocal(model_kwargs=config_data['vit_architecture'], sxr_norm=sxr_norm, base_weights=base_weights)
197
+
198
+ checkpoint_callback = ModelCheckpoint(
199
+ dirpath=config_data['data']['checkpoints_dir'],
200
+ monitor='val_total_loss',
201
+ mode='min',
202
+ save_top_k=10,
203
+ filename=f"{config_data['wandb']['run_name']}-{{epoch:02d}}-{{val_total_loss:.4f}}",
204
+ )
205
+
206
+ accelerator, devices, strategy = resolve_devices(config_data.get('gpu_ids', -1))
207
+
208
+ trainer = Trainer(
209
+ default_root_dir=config_data['data']['checkpoints_dir'],
210
+ accelerator=accelerator,
211
+ devices=devices,
212
+ strategy=strategy,
213
+ max_epochs=config_data['epochs'],
214
+ callbacks=[sxr_plot_callback, attention_callback, checkpoint_callback],
215
+ logger=wandb_logger,
216
+ log_every_n_steps=10,
217
+ )
218
+ trainer.fit(model, data_module)
219
+ wandb.finish()
220
+ print(f"Training complete. Checkpoints saved to {config_data['data']['checkpoints_dir']}")
221
+
222
+
223
+ if __name__ == '__main__':
224
+ main()
training/train_config.yaml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # FOXES Training Configuration
3
+ # =============================================================================
4
+ # Used by train.py to train the ViTLocal model on paired AIA/SXR .npy data.
5
+ # Expects aia_dir/sxr_dir to each have train/val/test subfolders (that's what
6
+ # data/build_dataset.py produces).
7
+ #
8
+ # Usage:
9
+ # python training/train.py --config training/train_config.yaml
10
+ # =============================================================================
11
+ wavelengths: [94, 131, 171, 193, 211, 304, 335]
12
+
13
+ # GPU configuration:
14
+ # 0 (or any int) - that single GPU
15
+ # [0, 1] - those specific GPUs
16
+ # "all" - every available GPU
17
+ # -1 - CPU only
18
+ gpu_ids: -1
19
+
20
+ batch_size: 6
21
+ epochs: 150
22
+
23
+ # Compute inverse-frequency loss weights (quiet/C/M/X) from the training data
24
+ # before starting. Takes one extra pass over the training set.
25
+ calculate_base_weights: false
26
+
27
+ vit_architecture:
28
+ embed_dim: 256
29
+ num_channels: 7
30
+ num_classes: 1
31
+ patch_size: 8
32
+ num_patches: 4096
33
+ hidden_dim: 1024
34
+ num_heads: 8
35
+ num_layers: 8
36
+ dropout: 0.1
37
+ learning_rate: 0.0001
38
+ # Self-attention masking - one of:
39
+ # inverted - the released model's behavior: each patch attends to DISTANT
40
+ # patches (the flipped local-attention mask it was trained with)
41
+ # local - true local attention: each patch attends to its neighborhood
42
+ # none - standard full/global attention (no masking)
43
+ mask_mode: inverted
44
+ local_window: 9 # neighbourhood side length (patches), used by inverted/local
45
+
46
+ data:
47
+ aia_dir: "/Volumes/T9/testing_foxes/AIA_processed"
48
+ sxr_dir: "/Volumes/T9/testing_foxes/SXR_processed"
49
+ sxr_norm_path: "/Volumes/T9/testing_foxes/normalized_sxr.npy"
50
+ checkpoints_dir: "/Volumes/T9/chkpt/" # update to actual checkpoint
51
+
52
+ wandb:
53
+ entity: "ggoodwin5-georgia-state-university" # your W&B username or team name
54
+ project: FOXES
55
+ job_type: training
56
+ tags:
57
+ - aia
58
+ - sxr
59
+ - regression
60
+ run_name: run_1
61
+ notes: AIA to SXR translation