|
27 | 27 | # --------------------------------------------------------------------------- |
28 | 28 |
|
29 | 29 | PANELS = [ |
| 30 | + # Dynamics |
30 | 31 | {"title": "Sea Surface Height", "vars": ["dynstat_eta"], "unit": "m"}, |
31 | 32 | {"title": "Temperature", "vars": ["dynstat_theta"], "unit": "°C"}, |
32 | 33 | {"title": "Salinity", "vars": ["dynstat_salt"], "unit": "PSU"}, |
33 | 34 | {"title": "Zonal Velocity (U)", "vars": ["dynstat_uvel"], "unit": "m/s"}, |
34 | 35 | {"title": "Meridional Velocity (V)", "vars": ["dynstat_vvel"], "unit": "m/s"}, |
35 | 36 | {"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 |
36 | 45 | {"title": "Wind Speed", "vars": ["exf_wspeed"], "unit": "m/s"}, |
37 | 46 | {"title": "Wind Stress (U)", "vars": ["exf_ustress"], "unit": "N/m²"}, |
38 | 47 | {"title": "Wind Stress (V)", "vars": ["exf_vstress"], "unit": "N/m²"}, |
39 | 48 | {"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²"}, |
40 | 52 | {"title": "Air Temperature (2m)","vars": ["exf_atemp"], "unit": "K"}, |
41 | 53 | {"title": "Specific Humidity", "vars": ["exf_aqh"], "unit": "kg/kg"}, |
42 | 54 | {"title": "Shortwave Down", "vars": ["exf_swdown"], "unit": "W/m²"}, |
43 | 55 | {"title": "Longwave Down", "vars": ["exf_lwdown"], "unit": "W/m²"}, |
44 | 56 | {"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 |
45 | 62 | {"title": "Advective CFL", "vars": ["advcfl_uvel_max", "advcfl_vvel_max", "advcfl_wvel_max", "advcfl_W_hf_max"], "unit": "", "raw": True}, |
46 | 63 | {"title": "Tracer CFL", "vars": ["trAdv_CFL_u_max", "trAdv_CFL_v_max", "trAdv_CFL_w_max"], "unit": "", "raw": True}, |
47 | 64 | ] |
@@ -106,12 +123,23 @@ def poll(self): |
106 | 123 | # --------------------------------------------------------------------------- |
107 | 124 |
|
108 | 125 | def discover_runs(simulation_dir): |
109 | | - """Find subdirectories containing STDOUT.0000.""" |
| 126 | + """Find subdirectories containing STDOUT.0000 (up to two levels deep).""" |
110 | 127 | runs = [] |
111 | 128 | for d in sorted(os.listdir(simulation_dir)): |
112 | 129 | 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")): |
114 | 133 | 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 |
115 | 143 | return runs |
116 | 144 |
|
117 | 145 |
|
@@ -272,7 +300,10 @@ def scan_plots(plots_dir): |
272 | 300 | <h1>MITgcm Live Monitor</h1><span class="live"></span> |
273 | 301 | <select id="run-select" style="margin-left:12px;" onchange="switchRun()"></select> |
274 | 302 | </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> |
276 | 307 | </div> |
277 | 308 | <div class="summary"> |
278 | 309 | <div class="item"><span class="label">Job</span><span class="value" id="s_job">—</span></div> |
@@ -346,6 +377,7 @@ def scan_plots(plots_dir): |
346 | 377 | currentRun = document.getElementById('run-select').value; |
347 | 378 | charts = []; // force chart rebuild |
348 | 379 | document.getElementById('grid').innerHTML = ''; |
| 380 | + document.getElementById('csv-link').href = '/csv?run=' + encodeURIComponent(currentRun); |
349 | 381 | poll(); |
350 | 382 | pollPlots(); |
351 | 383 | } |
@@ -560,6 +592,20 @@ def do_GET(self): |
560 | 592 | plots = {} |
561 | 593 | self._respond(200, "application/json", json.dumps(plots).encode()) |
562 | 594 |
|
| 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 | + |
563 | 609 | elif path.startswith("/img/"): |
564 | 610 | # /img/<run_name>/<filename> |
565 | 611 | parts = path[5:].split("/", 1) |
@@ -596,6 +642,24 @@ def do_GET(self): |
596 | 642 | else: |
597 | 643 | self._respond(404, "text/plain", b"Not found") |
598 | 644 |
|
| 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 | + |
599 | 663 | def log_message(self, format, *args): |
600 | 664 | pass |
601 | 665 |
|
|
0 commit comments