#!/usr/bin/env python3
"""Assemble the session's results into a single self-contained HTML report.
Reads whatever result JSONs exist under outputs/downscale/ and renders:
- downscaling skill by forecast week (RMSE / MAE / ME / CORR)
- EHD event scores at grid-point and regional (EXP1) level, against the targets
- feature importance of the full-predictor model
- maps of per-point skill, one row per forecast week on a shared colour scale
Usage: make_report.py [--out ehd_report.html]
"""
import argparse
import base64
import io
import json
import os
import numpy as np
import xarray as xr
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
ROOT = "/home/tanpv/Progs/claude/HW_EC_10"
OUTD = f"{ROOT}/outputs/downscale"
STATIC = f"{ROOT}/outputs/Cli/static_fields_0.25.nc"
WEEKS = ["W1", "W2", "W3", "W4", "W5", "W6", "ALL"]
TARGETS = {"POD": (">", 0.60), "POFD": ("<", 0.20), "FAR": ("<", 0.20),
"CSI": (">", 0.40), "FBI": ("~", 0.75)}
def fig_to_img(fig):
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
plt.close(fig)
return ("data:image/png;base64,"
+ base64.b64encode(buf.getvalue()).decode())
def load(name):
p = os.path.join(OUTD, name)
return json.load(open(p)) if os.path.exists(p) else None
def table(rows, header, cls=""):
h = "".join(f"
{c} | " for c in header)
b = "".join("" + "".join(f"| {c} | " for c in r) + "
" for r in rows)
return f''
def fmt(v, target=None, digits=3):
if v is None or (isinstance(v, float) and not np.isfinite(v)):
return "–"
s = f"{v:.{digits}f}"
if target:
op, lim = target
ok = (v > lim) if op == ">" else (v < lim) if op == "<" else abs(v - lim) < 0.15
s = f'{s}'
return s
def skill_maps(pred_files, static):
"""One row of maps per forecast week, shared colour scale, RMSE per grid point."""
obs = xr.open_dataset(f"{ROOT}/outputs/OBS_4EC15/tx_obs_aligned.nc")
land = (np.isfinite(static.vn.values) & (static.vn.values > 0))
li = np.where(land.ravel())[0]
o = obs.tx_obs.values.reshape(20, 104, 46, -1)[..., li]
lat, lon = obs.lat.values, obs.lon.values
bounds = [(0, 11), (11, 18), (18, 25), (25, 32), (32, 39), (39, 46)]
imgs = {}
for name, path in pred_files.items():
if not os.path.exists(path):
continue
p = np.load(path)
fig, axes = plt.subplots(1, 6, figsize=(19, 3.4), constrained_layout=True)
rms = []
for (l0, l1) in bounds:
d = p[:, :, l0:l1] - o[:, :, l0:l1]
rms.append(np.sqrt(np.nanmean(d ** 2, axis=(0, 1, 2))))
vmax = float(np.nanpercentile(np.concatenate(rms), 98))
for k, (ax, r) in enumerate(zip(axes, rms)):
grid = np.full(land.size, np.nan)
grid[li] = r
im = ax.pcolormesh(lon, lat, grid.reshape(land.shape),
vmin=0, vmax=vmax, cmap="YlOrRd", shading="auto")
ax.set_title(f"W{k+1}", fontsize=11)
ax.set_aspect(1.0)
ax.set_xticks([]); ax.set_yticks([])
fig.colorbar(im, ax=axes, shrink=0.85, label="RMSE (°C)")
imgs[name] = fig_to_img(fig)
return imgs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=f"{ROOT}/ehd_report.html")
args = ap.parse_args()
static = xr.open_dataset(STATIC)
base = load("baselines.json")
ai = load("ai_lgbm.json")
ehd_b = load("ehd_baselines.json")
ehd_c = load("ehd_classifier.json")
ehd_f = load("ehd_full.json")
ehd_r = load("ehd_full_real.json") # native 1.5 deg REAL MARS data
ehd_rp = load("ehd_full_real_pcs.json") # native 1.5 deg REAL + wide-domain EOF/PC
parts = []
# --- downscaling skill
if base or ai:
rows = []
src = dict(base or {})
if ai:
src["lgbm"] = ai.get("lgbm")
for m, r in src.items():
if not isinstance(r, dict):
continue
rows.append([m] + [fmt(r.get(w, {}).get("RMSE"), digits=2) for w in WEEKS]
+ [fmt(r.get("ALL", {}).get("MAE"), digits=2),
fmt(r.get("ALL", {}).get("CORR"), digits=3)])
parts.append("1. Kỹ năng downscaling Tx (RMSE theo tuần, °C)
"
+ table(rows, ["phương pháp"] + WEEKS + ["MAE (ALL)", "CORR (ALL)"]))
# --- event scores
for title, blob, key in [("2. Điểm sự kiện EHD — các baseline", ehd_b, None),
("3. Phân loại chỉ dùng Tx", ehd_c, "classifier"),
("4. Mô hình đủ predictor (proxy 1.5°)", ehd_f, "full"),
("5. Mô hình đủ predictor — SỐ LIỆU THẬT 1.5°",
ehd_r, "full"),
("6. SỐ LIỆU THẬT 1.5° + EOF/PC miền rộng",
ehd_rp, "full")]:
if not blob:
continue
items = blob.items() if key is None else [(key, blob[key])]
for lvl in ("gridpoint", "regional_EHDE"):
rows = []
for nm, r in items:
if not isinstance(r, dict) or lvl not in r:
continue
for w in WEEKS:
v = r[lvl].get(w)
if not v:
continue
rows.append([nm, w, fmt(v["base"]), fmt(v["POD"], TARGETS["POD"]),
fmt(v["POFD"], TARGETS["POFD"]),
fmt(v["FAR"], TARGETS["FAR"]),
fmt(v["FBI"], TARGETS["FBI"]),
fmt(v["CSI"], TARGETS["CSI"])])
if rows:
parts.append(f"{title} — {lvl}
"
+ table(rows, ["mô hình", "tuần", "base", "POD", "POFD",
"FAR", "FBI", "CSI"]))
parts.insert(len(parts), "")
# --- feature importance (standard: render for EVERY model that emits it)
def fi_chart(name, gain):
top = list(gain.items())[:25]
fig, ax = plt.subplots(figsize=(7, 6.5))
ax.barh([k for k, _ in top][::-1], [v for _, v in top][::-1], color="#c2410c")
ax.set_xlabel("normalized gain")
ax.set_title(f"Feature importance — {name}")
return f'{name}
'
fi_sources = []
if ehd_rp and "feature_gain" in ehd_rp.get("full", {}):
fi_sources.append(("SỐ LIỆU THẬT 1.5° + EOF/PC miền rộng",
ehd_rp["full"]["feature_gain"]))
if ehd_r and "feature_gain" in ehd_r.get("full", {}):
fi_sources.append(("EHD đủ predictor — SỐ LIỆU THẬT 1.5°",
ehd_r["full"]["feature_gain"]))
if ehd_f and "feature_gain" in ehd_f.get("full", {}):
fi_sources.append(("EHD đủ predictor (proxy 1.5°)", ehd_f["full"]["feature_gain"]))
if ai and isinstance(ai.get("lgbm"), dict) and "feature_gain" in ai:
fi_sources.append(("downscaling LightGBM", ai["feature_gain"]))
if ehd_c and "feature_gain" in ehd_c:
fi_sources.append(("phân loại chỉ dùng Tx", ehd_c["feature_gain"]))
if fi_sources:
parts.append("5. Đóng góp của các đặc trưng
"
+ "".join(fi_chart(n, g) for n, g in fi_sources))
# --- maps
imgs = skill_maps({"BCSD": f"{OUTD}/pred/pred_bcsd.npy",
"LightGBM": f"{OUTD}/pred/pred_lgbm.npy"}, static)
for nm, src in imgs.items():
parts.append(f"Bản đồ RMSE theo tuần — {nm}
")
css = """
body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:32px;max-width:1400px;
color:#1f2328;line-height:1.5}
h1{border-bottom:3px solid #c2410c;padding-bottom:8px}
h2{margin-top:34px;color:#7c2d12}
table{border-collapse:collapse;margin:10px 0 22px;font-size:14px}
th,td{border:1px solid #d0d7de;padding:5px 10px;text-align:right}
th{background:#f6f8fa;font-weight:600}
td:first-child,th:first-child{text-align:left}
tbody tr:nth-child(even){background:#fafbfc}
.ok{color:#137333;font-weight:600}.bad{color:#b3261e}
img{max-width:100%;border:1px solid #d0d7de;border-radius:6px;margin:8px 0}
.note{background:#fff8e1;border-left:4px solid #f59e0b;padding:12px 16px;margin:18px 0}
"""
html = f"""
HW_EC_10 — Dự báo EHD từ S2S ECMWF 1.5°
HW_EC_10 — Downscaling và dự báo ngày nóng cực đoan từ S2S ECMWF 1.5°
Chỉ tiêu mục tiêu: POD > 0,6 · POFD < 0,2 · FAR < 0,2 ·
CSI > 0,4 · FBI ~ 0,75. Giá trị xanh là đạt,
đỏ là chưa đạt.
Lưu ý: FAR = 1 − POD/FBI là một đẳng thức, nên FAR không độc lập với POD và FBI.
Kết quả dưới đây dựng trên proxy 1.5° (trung bình bộ EC 0.25° lên lưới 1.5° thật);
sẽ chạy lại trên số liệu 1.5° gốc khi tải xong.
Kiểm định: k-fold 5 × 4 năm; mọi khí hậu, ngưỡng và chuẩn hoá đều dựng
riêng từ các năm training của từng fold.
{''.join(parts)}
"""
with open(args.out, "w") as fh:
fh.write(html)
print(f"wrote {args.out} ({os.path.getsize(args.out)/1e6:.2f} MB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())