Skip to content

Commit 024ae12

Browse files
Fix dashboard to discover nested experiment runs
discover_runs now scans two levels deep, so repeat-year-50/001/ through 050/ are found alongside top-level runs like visc20-run/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f1b0b1e commit 024ae12

1 file changed

Lines changed: 67 additions & 3 deletions

File tree

spectre_utils/monitor_dashboard.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,38 @@
2727
# ---------------------------------------------------------------------------
2828

2929
PANELS = [
30+
# Dynamics
3031
{"title": "Sea Surface Height", "vars": ["dynstat_eta"], "unit": "m"},
3132
{"title": "Temperature", "vars": ["dynstat_theta"], "unit": "°C"},
3233
{"title": "Salinity", "vars": ["dynstat_salt"], "unit": "PSU"},
3334
{"title": "Zonal Velocity (U)", "vars": ["dynstat_uvel"], "unit": "m/s"},
3435
{"title": "Meridional Velocity (V)", "vars": ["dynstat_vvel"], "unit": "m/s"},
3536
{"title": "Vertical Velocity (W)", "vars": ["dynstat_wvel"], "unit": "m/s"},
37+
# Energy & vorticity
38+
{"title": "Kinetic Energy", "vars": ["ke_max", "ke_mean"], "unit": "m²/s²", "raw": True},
39+
{"title": "Potential Energy", "vars": ["pe_b_mean"], "unit": "m²/s²", "raw": True},
40+
{"title": "Vorticity (relative)","vars": ["vort_r_max", "vort_r_min"], "unit": "1/s", "raw": True},
41+
{"title": "Vorticity (absolute)","vars": ["vort_a_mean", "vort_a_sd"], "unit": "1/s", "raw": True},
42+
# Surface expansion
43+
{"title": "Surface Expansion", "vars": ["surfExpan_theta_mean", "surfExpan_salt_mean"], "unit": "", "raw": True},
44+
# EXF forcing
3645
{"title": "Wind Speed", "vars": ["exf_wspeed"], "unit": "m/s"},
3746
{"title": "Wind Stress (U)", "vars": ["exf_ustress"], "unit": "N/m²"},
3847
{"title": "Wind Stress (V)", "vars": ["exf_vstress"], "unit": "N/m²"},
3948
{"title": "Net Heat Flux", "vars": ["exf_hflux"], "unit": "W/m²"},
49+
{"title": "Salt Flux", "vars": ["exf_sflux"], "unit": "g/m²/s"},
50+
{"title": "SW Flux (net)", "vars": ["exf_swflux"], "unit": "W/m²"},
51+
{"title": "LW Flux (net)", "vars": ["exf_lwflux"], "unit": "W/m²"},
4052
{"title": "Air Temperature (2m)","vars": ["exf_atemp"], "unit": "K"},
4153
{"title": "Specific Humidity", "vars": ["exf_aqh"], "unit": "kg/kg"},
4254
{"title": "Shortwave Down", "vars": ["exf_swdown"], "unit": "W/m²"},
4355
{"title": "Longwave Down", "vars": ["exf_lwdown"], "unit": "W/m²"},
4456
{"title": "Freshwater Flux", "vars": ["exf_evap", "exf_precip"], "unit": "m/s"},
57+
# OBC
58+
{"title": "OBC North Transport", "vars": ["obc_N_vVel_Int"], "unit": "m³/s", "raw": True},
59+
{"title": "OBC South Transport", "vars": ["obc_S_vVel_Int"], "unit": "m³/s", "raw": True},
60+
{"title": "OBC East Transport", "vars": ["obc_E_uVel_Int"], "unit": "m³/s", "raw": True},
61+
# CFL
4562
{"title": "Advective CFL", "vars": ["advcfl_uvel_max", "advcfl_vvel_max", "advcfl_wvel_max", "advcfl_W_hf_max"], "unit": "", "raw": True},
4663
{"title": "Tracer CFL", "vars": ["trAdv_CFL_u_max", "trAdv_CFL_v_max", "trAdv_CFL_w_max"], "unit": "", "raw": True},
4764
]
@@ -106,12 +123,23 @@ def poll(self):
106123
# ---------------------------------------------------------------------------
107124

108125
def discover_runs(simulation_dir):
109-
"""Find subdirectories containing STDOUT.0000."""
126+
"""Find subdirectories containing STDOUT.0000 (up to two levels deep)."""
110127
runs = []
111128
for d in sorted(os.listdir(simulation_dir)):
112129
full = os.path.join(simulation_dir, d)
113-
if os.path.isdir(full) and os.path.exists(os.path.join(full, "STDOUT.0000")):
130+
if not os.path.isdir(full):
131+
continue
132+
if os.path.exists(os.path.join(full, "STDOUT.0000")):
114133
runs.append(d)
134+
else:
135+
# Check one level deeper (e.g. repeat-year-50/001/)
136+
try:
137+
for sub in sorted(os.listdir(full)):
138+
subfull = os.path.join(full, sub)
139+
if os.path.isdir(subfull) and os.path.exists(os.path.join(subfull, "STDOUT.0000")):
140+
runs.append(os.path.join(d, sub))
141+
except OSError:
142+
pass
115143
return runs
116144

117145

@@ -272,7 +300,10 @@ def scan_plots(plots_dir):
272300
<h1>MITgcm Live Monitor</h1><span class="live"></span>
273301
<select id="run-select" style="margin-left:12px;" onchange="switchRun()"></select>
274302
</div>
275-
<div id="status">connecting...</div>
303+
<div style="display:flex; gap:8px; align-items:center;">
304+
<a id="csv-link" href="/csv" style="font-size:11px; color:#2563eb; text-decoration:none;">Download CSV</a>
305+
<span id="status">connecting...</span>
306+
</div>
276307
</div>
277308
<div class="summary">
278309
<div class="item"><span class="label">Job</span><span class="value" id="s_job">&mdash;</span></div>
@@ -346,6 +377,7 @@ def scan_plots(plots_dir):
346377
currentRun = document.getElementById('run-select').value;
347378
charts = []; // force chart rebuild
348379
document.getElementById('grid').innerHTML = '';
380+
document.getElementById('csv-link').href = '/csv?run=' + encodeURIComponent(currentRun);
349381
poll();
350382
pollPlots();
351383
}
@@ -560,6 +592,20 @@ def do_GET(self):
560592
plots = {}
561593
self._respond(200, "application/json", json.dumps(plots).encode())
562594

595+
elif path.startswith("/csv"):
596+
run = self._get_run()
597+
w = self._get_watcher(run) if run else None
598+
if w:
599+
w.poll()
600+
csv = self._build_csv(w.records, run)
601+
self.send_response(200)
602+
self.send_header("Content-Type", "text/csv")
603+
self.send_header("Content-Disposition", f'attachment; filename="monitor_{run}.csv"')
604+
self.end_headers()
605+
self.wfile.write(csv.encode())
606+
else:
607+
self._respond(404, "text/plain", b"No data")
608+
563609
elif path.startswith("/img/"):
564610
# /img/<run_name>/<filename>
565611
parts = path[5:].split("/", 1)
@@ -596,6 +642,24 @@ def do_GET(self):
596642
else:
597643
self._respond(404, "text/plain", b"Not found")
598644

645+
def _build_csv(self, records, run_name):
646+
"""Build CSV string from monitor records."""
647+
if not records:
648+
return ""
649+
# Collect all keys across all records
650+
all_keys = set()
651+
for r in records:
652+
all_keys.update(r.keys())
653+
# Add model_date column
654+
t0 = datetime.strptime(self.start_date, "%Y-%m-%d")
655+
cols = ["model_date"] + sorted(all_keys)
656+
lines = [",".join(cols)]
657+
for r in records:
658+
date = (t0 + timedelta(seconds=r.get("time_secondsf", 0))).strftime("%Y-%m-%d %H:%M")
659+
vals = [date] + [str(r.get(k, "")) for k in sorted(all_keys)]
660+
lines.append(",".join(vals))
661+
return "\n".join(lines)
662+
599663
def log_message(self, format, *args):
600664
pass
601665

0 commit comments

Comments
 (0)