EmmaScharfmann HF Staff commited on
Commit
e69b4bb
Β·
1 Parent(s): 148f175

add storage

Browse files
Files changed (3) hide show
  1. aifs/archive.py +153 -0
  2. app.py +130 -0
  3. requirements.txt +5 -0
aifs/archive.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ aifs.archive
3
+ ============
4
+ Save/load forecast runs to/from a Hugging Face Hub dataset repo
5
+ (EmmaScharfmann/weather-forecast-archive), so a run from one session can be
6
+ recalled and re-plotted later without re-running the model.
7
+
8
+ Serialization is generic across all three "models" in aifs.compare: AIFS
9
+ states carry their own (irregular-grid) latitudes/longitudes and are stored
10
+ alongside the fields; WeatherNext2/climatology states don't (their grid is
11
+ the fixed module-level constants in aifs.weathernext2), and are stored
12
+ without them, unchanged on load.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ REPO_ID = "EmmaScharfmann/weather-forecast-archive"
24
+ REPO_TYPE = "dataset"
25
+
26
+ # Short filename slugs for the three models in aifs.compare β€” kept as a
27
+ # local mapping (not importing aifs.compare's exact strings) so archive
28
+ # filenames stay stable even if a model's display name changes later.
29
+ _SLUG_BY_MODEL = {
30
+ "AIFS": "AIFS",
31
+ "WeatherNext2": "WeatherNext2",
32
+ "Climatology (ERA5 baseline)": "Climatology",
33
+ }
34
+ _MODEL_BY_SLUG = {slug: model for model, slug in _SLUG_BY_MODEL.items()}
35
+
36
+ _DATETIME_FMT = "%Y%m%dT%H%M%S"
37
+
38
+
39
+ def _serialize_states(states: list[dict]) -> dict:
40
+ """Flattens a states list into one dict of numpy arrays, npz-savable."""
41
+ payload = {"dates": np.array([s["date"].isoformat() for s in states])}
42
+
43
+ if "latitudes" in states[0]:
44
+ payload["latitudes"] = np.asarray(states[0]["latitudes"])
45
+ payload["longitudes"] = np.asarray(states[0]["longitudes"])
46
+
47
+ field_names = sorted(states[0]["fields"].keys())
48
+ payload["field_names"] = np.array(field_names)
49
+ for i, state in enumerate(states):
50
+ for name in field_names:
51
+ payload[f"field__{i}__{name}"] = np.asarray(state["fields"][name])
52
+ return payload
53
+
54
+
55
+ def _deserialize_states(npz) -> list[dict]:
56
+ """Inverse of _serialize_states β€” reconstructs the original states list."""
57
+ dates = [datetime.datetime.fromisoformat(str(d)) for d in npz["dates"]]
58
+ field_names = [str(n) for n in npz["field_names"]]
59
+ has_grid = "latitudes" in npz
60
+
61
+ states = []
62
+ for i, date in enumerate(dates):
63
+ fields = {name: npz[f"field__{i}__{name}"] for name in field_names}
64
+ state = {"date": date, "fields": fields}
65
+ if has_grid:
66
+ state["latitudes"] = npz["latitudes"]
67
+ state["longitudes"] = npz["longitudes"]
68
+ states.append(state)
69
+ return states
70
+
71
+
72
+ def save_run(model: str, states: list[dict], log=lambda msg: None) -> str:
73
+ """Uploads one model's forecast states as a single .npz to the archive dataset.
74
+
75
+ Returns the remote filename (also encodes model/init-date/step-count/
76
+ saved-at, so :func:`list_saved_runs` can describe it without downloading).
77
+ """
78
+ from huggingface_hub import HfApi
79
+
80
+ if not states:
81
+ raise ValueError("No states to save β€” run a forecast first.")
82
+ if model not in _SLUG_BY_MODEL:
83
+ raise ValueError(f"Unknown model {model!r}.")
84
+
85
+ now = datetime.datetime.now(datetime.timezone.utc)
86
+ slug = _SLUG_BY_MODEL[model]
87
+ filename = (
88
+ f"{slug}__init-{states[0]['date'].strftime(_DATETIME_FMT)}"
89
+ f"__{len(states)}steps__saved-{now.strftime(_DATETIME_FMT)}.npz"
90
+ )
91
+
92
+ payload = _serialize_states(states)
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ local_path = Path(tmp) / filename
95
+ np.savez_compressed(str(local_path), **payload)
96
+
97
+ log(f"☁️ Uploading {filename} to {REPO_ID}…")
98
+ HfApi().upload_file(
99
+ path_or_fileobj=str(local_path),
100
+ path_in_repo=filename,
101
+ repo_id=REPO_ID,
102
+ repo_type=REPO_TYPE,
103
+ )
104
+ log(f"βœ… Saved to {REPO_ID}/{filename}")
105
+ return filename
106
+
107
+
108
+ def list_saved_runs(log=lambda msg: None) -> list[dict]:
109
+ """
110
+ Lists archived runs, newest first β€” metadata parsed from filenames only
111
+ (no downloads), so this stays fast even with many saved runs.
112
+ """
113
+ from huggingface_hub import HfApi
114
+
115
+ log(f"☁️ Listing saved runs in {REPO_ID}…")
116
+ files = HfApi().list_repo_files(repo_id=REPO_ID, repo_type=REPO_TYPE)
117
+
118
+ runs = []
119
+ for fname in files:
120
+ if not fname.endswith(".npz"):
121
+ continue
122
+ try:
123
+ slug, rest = fname[:-len(".npz")].split("__init-", 1)
124
+ init_str, rest = rest.split("__", 1)
125
+ steps_str, saved_str = rest.split("__saved-", 1)
126
+ runs.append({
127
+ "filename": fname,
128
+ "model": _MODEL_BY_SLUG.get(slug, slug),
129
+ "init_date": datetime.datetime.strptime(init_str, _DATETIME_FMT),
130
+ "num_steps": int(steps_str.replace("steps", "")),
131
+ "saved_at": datetime.datetime.strptime(saved_str, _DATETIME_FMT),
132
+ })
133
+ except Exception:
134
+ continue # not one of our files β€” skip rather than fail the whole listing
135
+
136
+ runs.sort(key=lambda r: r["saved_at"], reverse=True)
137
+ log(f"βœ… Found {len(runs)} saved run(s).")
138
+ return runs
139
+
140
+
141
+ def load_run(filename: str, log=lambda msg: None) -> tuple[str, list[dict]]:
142
+ """Downloads and deserializes one saved run. Returns (model, states)."""
143
+ from huggingface_hub import hf_hub_download
144
+
145
+ log(f"☁️ Downloading {filename} from {REPO_ID}…")
146
+ local_path = hf_hub_download(repo_id=REPO_ID, repo_type=REPO_TYPE, filename=filename)
147
+ with np.load(local_path) as npz:
148
+ states = _deserialize_states(npz)
149
+
150
+ slug = filename.split("__init-", 1)[0]
151
+ model = _MODEL_BY_SLUG.get(slug, slug)
152
+ log(f"βœ… Loaded {len(states)} step(s) for {model}.")
153
+ return model, states
app.py CHANGED
@@ -187,6 +187,72 @@ def toggle_model_controls(model: str):
187
  )
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  # ── Visualize (any one model/step/field) ───────────────────────────────────────
191
 
192
  def plot_selected(model: str, step_str: str, field: str, all_states: dict):
@@ -273,6 +339,29 @@ textarea, input, select { background: #1c2128!important; color: #cdd9e5!importan
273
  textarea::placeholder, input::placeholder { color: #a8b3c0!important; }
274
  .output-markdown { color: #cdd9e5!important; }
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  /* === Notes / Markdown prose === */
277
  .prose,
278
  .prose p,
@@ -302,6 +391,10 @@ with gr.Blocks(css=DARK_CSS, title="Weather Model Comparison") as demo:
302
 
303
  all_states = gr.State({})
304
 
 
 
 
 
305
  with gr.Row():
306
  with gr.Column(scale=1, elem_classes="panel"):
307
  gr.Markdown("### βš™οΈ Run a Forecast")
@@ -359,6 +452,8 @@ with gr.Blocks(css=DARK_CSS, title="Weather Model Comparison") as demo:
359
  label="Detailed log", lines=8, interactive=False,
360
  placeholder="Progress details will appear here…",
361
  )
 
 
362
 
363
  with gr.Column(scale=2, elem_classes="panel"):
364
  gr.Markdown("### πŸ—ΊοΈ Visualize")
@@ -372,6 +467,21 @@ with gr.Blocks(css=DARK_CSS, title="Weather Model Comparison") as demo:
372
  viz_img = gr.Image(label="Map", type="filepath")
373
  viz_stats_md = gr.Markdown()
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  gr.Markdown("---")
376
  with gr.Row():
377
  with gr.Column(scale=1, elem_classes="panel"):
@@ -417,6 +527,26 @@ with gr.Blocks(css=DARK_CSS, title="Weather Model Comparison") as demo:
417
  inputs=[cmp_model_a_dd, cmp_step_a_dd, cmp_model_b_dd, cmp_step_b_dd, cmp_field_dd, all_states],
418
  outputs=[cmp_img_a, cmp_img_b, cmp_img_diff, cmp_stats_md],
419
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  gr.Markdown(
422
  """
 
187
  )
188
 
189
 
190
+ def format_status(all_states: dict) -> str:
191
+ """One line per model: whether it's been run this session and, if so, its step range."""
192
+ lines = []
193
+ for model in MODELS:
194
+ states = all_states.get(model)
195
+ if states:
196
+ lines.append(f"- **{model}**: βœ… {len(states)} step(s) ready β€” {states[0]['date']} β†’ {states[-1]['date']}")
197
+ else:
198
+ lines.append(f"- **{model}**: ⬜ not run yet")
199
+ return "\n".join(lines)
200
+
201
+
202
+ # ── Forecast archive (save/load runs to the HF dataset in aifs.archive) ───────
203
+
204
+ def save_current_run(model: str, all_states: dict) -> str:
205
+ from aifs.archive import save_run
206
+
207
+ states = all_states.get(model)
208
+ if not states:
209
+ return f"⚠️ Run {model} first, then save it."
210
+
211
+ log_lines: list[str] = []
212
+ try:
213
+ save_run(model, states, log=log_lines.append)
214
+ return "\n".join(log_lines)
215
+ except Exception as exc:
216
+ return "\n".join(log_lines) + f"\n❌ Error: {exc}"
217
+
218
+
219
+ def refresh_saved_runs():
220
+ from aifs.archive import list_saved_runs
221
+
222
+ log_lines: list[str] = []
223
+ try:
224
+ runs = list_saved_runs(log=log_lines.append)
225
+ except Exception as exc:
226
+ return gr.update(choices=[], value=None), "\n".join(log_lines) + f"\n❌ Error: {exc}"
227
+
228
+ choices = [
229
+ (
230
+ f"{r['model']} β€” init {r['init_date']:%Y-%m-%d %H:%M} β€” {r['num_steps']} step(s) "
231
+ f"β€” saved {r['saved_at']:%Y-%m-%d %H:%M}",
232
+ r["filename"],
233
+ )
234
+ for r in runs
235
+ ]
236
+ return gr.update(choices=choices, value=(choices[0][1] if choices else None)), "\n".join(log_lines)
237
+
238
+
239
+ def load_saved_run(filename: str, all_states: dict):
240
+ from aifs.archive import load_run
241
+
242
+ if not filename:
243
+ return all_states, "⚠️ Pick a saved run to load first."
244
+
245
+ log_lines: list[str] = []
246
+ try:
247
+ model, states = load_run(filename, log=log_lines.append)
248
+ except Exception as exc:
249
+ return all_states, "\n".join(log_lines) + f"\n❌ Error: {exc}"
250
+
251
+ new_all_states = dict(all_states)
252
+ new_all_states[model] = states
253
+ return new_all_states, "\n".join(log_lines)
254
+
255
+
256
  # ── Visualize (any one model/step/field) ───────────────────────────────────────
257
 
258
  def plot_selected(model: str, step_str: str, field: str, all_states: dict):
 
339
  textarea::placeholder, input::placeholder { color: #a8b3c0!important; }
340
  .output-markdown { color: #cdd9e5!important; }
341
 
342
+ /* === Dropdown / radio internals ===
343
+ Gradio's Dropdown/Radio are custom components, not a plain <select>/
344
+ <input> β€” the rule above doesn't reach their popup list or selected-pill
345
+ styling, which otherwise falls back to Gradio's default (light) theme. */
346
+ ul[class*="options"], li[class*="item"] {
347
+ background: #1c2128!important;
348
+ color: #cdd9e5!important;
349
+ }
350
+ li[class*="item"]:hover, li[class*="item"][aria-selected="true"] {
351
+ background: #30363d!important;
352
+ color: #cdd9e5!important;
353
+ }
354
+ .wrap label {
355
+ background: #1c2128!important;
356
+ color: #cdd9e5!important;
357
+ border-color: #30363d!important;
358
+ }
359
+ label.selected, label[class*="selected"] {
360
+ background: #1f6feb!important;
361
+ color: #ffffff!important;
362
+ border-color: #1f6feb!important;
363
+ }
364
+
365
  /* === Notes / Markdown prose === */
366
  .prose,
367
  .prose p,
 
391
 
392
  all_states = gr.State({})
393
 
394
+ with gr.Group(elem_classes="panel"):
395
+ gr.Markdown("### πŸ“‹ Session Status")
396
+ status_md = gr.Markdown(format_status({}))
397
+
398
  with gr.Row():
399
  with gr.Column(scale=1, elem_classes="panel"):
400
  gr.Markdown("### βš™οΈ Run a Forecast")
 
452
  label="Detailed log", lines=8, interactive=False,
453
  placeholder="Progress details will appear here…",
454
  )
455
+ save_btn = gr.Button("πŸ’Ύ Save Current Run to Archive", variant="secondary")
456
+ save_status = gr.Textbox(label="Save log", lines=2, interactive=False)
457
 
458
  with gr.Column(scale=2, elem_classes="panel"):
459
  gr.Markdown("### πŸ—ΊοΈ Visualize")
 
467
  viz_img = gr.Image(label="Map", type="filepath")
468
  viz_stats_md = gr.Markdown()
469
 
470
+ gr.Markdown("---")
471
+ with gr.Row():
472
+ with gr.Column(elem_classes="panel"):
473
+ gr.Markdown(
474
+ "### πŸ“‚ Saved Runs\n"
475
+ "Forecasts saved above persist in the "
476
+ "[weather-forecast-archive](https://huggingface.co/datasets/EmmaScharfmann/weather-forecast-archive) "
477
+ "dataset β€” load one back here to plot/compare it without re-running the model."
478
+ )
479
+ with gr.Row():
480
+ saved_runs_dd = gr.Dropdown(choices=[], label="Saved run", scale=3)
481
+ refresh_saved_btn = gr.Button("πŸ”„ Refresh", scale=1)
482
+ load_saved_btn = gr.Button("πŸ“₯ Load", variant="secondary", scale=1)
483
+ saved_runs_status = gr.Textbox(label="Archive log", lines=2, interactive=False)
484
+
485
  gr.Markdown("---")
486
  with gr.Row():
487
  with gr.Column(scale=1, elem_classes="panel"):
 
527
  inputs=[cmp_model_a_dd, cmp_step_a_dd, cmp_model_b_dd, cmp_step_b_dd, cmp_field_dd, all_states],
528
  outputs=[cmp_img_a, cmp_img_b, cmp_img_diff, cmp_stats_md],
529
  )
530
+ save_btn.click(
531
+ fn=save_current_run,
532
+ inputs=[model_dd, all_states],
533
+ outputs=[save_status],
534
+ )
535
+ refresh_saved_btn.click(
536
+ fn=refresh_saved_runs,
537
+ inputs=[],
538
+ outputs=[saved_runs_dd, saved_runs_status],
539
+ )
540
+ load_saved_btn.click(
541
+ fn=load_saved_run,
542
+ inputs=[saved_runs_dd, all_states],
543
+ outputs=[all_states, saved_runs_status],
544
+ )
545
+ all_states.change(
546
+ fn=format_status,
547
+ inputs=all_states,
548
+ outputs=status_md,
549
+ )
550
 
551
  gr.Markdown(
552
  """
requirements.txt CHANGED
@@ -23,6 +23,11 @@ cartopy
23
  git+https://github.com/kashif/transformers.git@add-weathernext2
24
  accelerate
25
 
 
 
 
 
 
26
  # HF Space environment config:
27
  # Python: 3.12.12 (main, Feb 24 2026, 21:49:09) [GCC 14.2.0]
28
  #Platform: Linux-6.12.80-106.156.amzn2023.x86_64-x86_64-with-glibc2.41
 
23
  git+https://github.com/kashif/transformers.git@add-weathernext2
24
  accelerate
25
 
26
+ # Forecast archive (save/load runs to the EmmaScharfmann/weather-forecast-archive
27
+ # HF dataset) β€” already an implicit dependency of transformers, listed
28
+ # explicitly since aifs/archive.py imports it directly.
29
+ huggingface_hub
30
+
31
  # HF Space environment config:
32
  # Python: 3.12.12 (main, Feb 24 2026, 21:49:09) [GCC 14.2.0]
33
  #Platform: Linux-6.12.80-106.156.amzn2023.x86_64-x86_64-with-glibc2.41