#!/usr/bin/env python3 """CFS operational bulletin in the SAME section set as ../HW/ehd_forecast_ope1b.html. Same six sections, same order, same maps, same tables, same CSS, so the two pages can be read side by side. Everything specific to CFS is written where it belongs rather than hidden: the probability is a calibration of the margin, not a vote of 72 AI members (see rt_ope.py), the validation sample is 726 inits of three years instead of 416 of four, and the release rule certifies W1 only, as on the EC page. """ from __future__ import annotations import argparse, base64, datetime as dt, io, json, os, sys from html import escape import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import shapefile from matplotlib.colors import BoundaryNorm, ListedColormap sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cfs_common as C import build_obs as B SHAPE = "/home/tanpv/GrADS_SCR/VN_SubRegs1/Vietnam7.shp" REG_ORDER = [(1, "S1 Tây Bắc"), (2, "S2 Đông Bắc"), (3, "S3 ĐB Bắc Bộ"), (4, "S4 Bắc Trung Bộ"), (5, "S5 Nam Trung Bộ"), (6, "S6 Tây Nguyên"), (7, "S7 Nam Bộ")] P_REGION = 0.20 class Grid: """The 541 land points of VnGAD 0.25 deg, as a 2-D field for pcolormesh.""" def __init__(self): z = np.load(B.TX_NPZ) self.lat, self.lon = z["lat"], z["lon"] self.jj, self.ii = z["jj"], z["ii"] self.reg = z["reg"] self.npt = len(self.jj) def to_grid(self, v): f = np.full((len(self.lat), len(self.lon)), np.nan, "f8") f[self.jj, self.ii] = v return f def load_borders(path=SHAPE): r = shapefile.Reader(path) out = [] for shp in r.shapes(): stops = list(shp.parts) + [len(shp.points)] for i in range(len(stops) - 1): out.append(np.asarray(shp.points[stops[i]:stops[i + 1]])) return out def _panel(ax, grid, borders, f2d, cmap, norm): ax.set_facecolor("#f2f2f2") m = ax.pcolormesh(grid.lon, grid.lat, np.ma.masked_invalid(f2d), cmap=cmap, norm=norm, shading="nearest") for poly in borders: ax.plot(poly[:, 0], poly[:, 1], color="0.25", lw=0.45) ax.set_xlim(grid.lon.min() - .125, grid.lon.max() + .125) ax.set_ylim(grid.lat.min() - .125, grid.lat.max() + .125) ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) for s in ax.spines.values(): s.set_linewidth(.5); s.set_color("0.6") return m def _png(fig): buf = io.BytesIO() fig.savefig(buf, format="png", dpi=125, facecolor="white") plt.close(fig) return base64.b64encode(buf.getvalue()).decode("ascii") def _row(fields, grid, borders, labels, title, cmap, norm, lev, unit=None): n = len(fields) fig, axes = plt.subplots(1, n, figsize=(2.2 * n + 0.9, 4.15), gridspec_kw=dict(wspace=.02, left=.012, right=.90, top=.83, bottom=.03)) axes = np.atleast_1d(axes) for k, ax in enumerate(axes): m = _panel(ax, grid, borders, fields[k], cmap, norm) ax.set_title(labels[k], fontsize=10, pad=3) cax = fig.add_axes([.915, .05, .014, .74]) cb = fig.colorbar(m, cax=cax, ticks=lev[::2]); cb.ax.tick_params(labelsize=8) if unit: cb.set_label(unit, fontsize=8.5) cb.outline.set_linewidth(.5) fig.suptitle(title, fontsize=12.5, y=.995, x=.012, ha="left", va="top") return _png(fig) def prob_row(fields, grid, borders, labels, title): lev = np.linspace(0, 1, 11) return _row(fields, grid, borders, labels, title, "YlOrRd", BoundaryNorm(lev, ncolors=256, clip=False), lev) def scalar_row(fields, grid, borders, labels, title, cmap, unit, hi=None): allv = np.concatenate([f[np.isfinite(f)] for f in fields]) top = hi if hi is not None else max(np.percentile(allv, 98), 1e-6) lev = np.linspace(0.0, top, 11) return _row(fields, grid, borders, labels, title, cmap, BoundaryNorm(lev, ncolors=256, clip=True), lev, unit) def binary_map(f2d, grid, borders, title): cmap = ListedColormap(["#dfe7ef", "#c1272d"]) fig, ax = plt.subplots(figsize=(4.1, 4.4), gridspec_kw=dict(left=.02, right=.98, top=.90, bottom=.03)) _panel(ax, grid, borders, f2d, cmap, BoundaryNorm([0, .5, 1], ncolors=2)) ax.set_title(title, fontsize=11.5, pad=5) return _png(fig) PAGE = """ %(title)s

%(title)s

%(subtitle)s

%(body)s """ def main(): ap = argparse.ArgumentParser() ap.add_argument("--init", default="2026082900") ap.add_argument("--out", default=os.path.join(C.ROOT, "ehd_cfs_forecast_ope.html")) ap.add_argument("--ec-verify", default="/home/tanpv/Progs/claude/HW/outputs/" "AI_Results/final_verify_ope1b.json", help="bảng kỹ năng của bản tin EC, chỉ để đặt cạnh so sánh") a = ap.parse_args() z = np.load(os.path.join(C.OUT, "RT", "ope_%s.npz" % a.init), allow_pickle=True) cert = json.load(open(os.path.join(C.OUT, "RT", "fcst_%s.json" % a.init))) skill = json.loads(str(z["skill"])) skill_reg = json.loads(str(z["skill_reg"])) rel = json.loads(str(z["rel"])) delta = float(z["delta"]) init = dt.date(int(a.init[:4]), int(a.init[4:6]), int(a.init[6:8])) grid = Grid(); borders = load_borders() W = C.WEEK_NAMES sev_acc = {w: z["sev_acc_%s" % w] for w in W} sev_max = {w: z["sev_max_%s" % w] for w in W} prob = {w: z["prob_%s" % w] for w in W} pred = {w: z["pred_%s" % w].astype(bool) for w in W} cov_mod = {w: z["cov_mod_%s" % w] for w in W} cov_obs = {w: z["cov_obs_%s" % w] for w in W} nvalid = len(cov_obs["W1"]) body = [] labs = ["%s\n%s–%s" % (w, (init + dt.timedelta(days=int(l[0]))).strftime("%d/%m"), (init + dt.timedelta(days=int(l[-1]))).strftime("%d/%m")) for w, l in C.WEEKS] # -------------------------------------------------------------- 1 rows = ["" ""] for w, lts in C.WEEKS: d0 = init + dt.timedelta(days=int(lts[0])) d1 = init + dt.timedelta(days=int(lts[-1])) prod = "bản đồ nhị phân + xác suất" if w == "W1" else "chỉ trường xác suất" rows.append("" % (w, d0.strftime("%d/%m/%Y"), d1.strftime("%d/%m/%Y"), lts.size, prod)) rows.append("
Cửa sổNgày hiệu lựcSố ngàySản phẩm phát hành
%s%s → %s%d%s
") body.append("

1. Hạn dự báo và sản phẩm

" + "".join(rows)) # -------------------------------------------------------------- 2 body.append("

2. Cường độ nắng nóng dự báo, W1–W6

") png = scalar_row([grid.to_grid(sev_acc[w]) for w in W], grid, borders, labs, "Tổng Sev≥0 tích luỹ trong tuần — khởi tạo %s" % init.strftime("%d/%m/%Y"), "YlOrRd", "°C·ngày") body.append('
Sev tích luỹ
' % png) body.append('
Đây là bản đồ chính. Mỗi ô lưới là ' 'Σngày max(Sev, 0) trong tuần, với ' 'Sev = TxCFS hạ quy mô − ngưỡng EHD. Một ngày vượt ' 'ngưỡng thì giá trị nhỏ, nhiều ngày vượt thì lớn, không ngày nào ' 'vượt thì đúng bằng 0. Số ô bằng 0 theo hạn: %s.
' % ", ".join("%s %d/541" % (w, int((sev_acc[w] == 0).sum())) for w in W)) png = scalar_row([grid.to_grid(sev_max[w]) for w in W], grid, borders, labs, "Sev cực đại tuần của chuỗi đã chứng nhận (BCSD)", "YlOrBr", "°C") body.append('
Sev cực đại
' % png) body.append('
Trường đỉnh nắng nóng: ' 'max(Tx tuần − ngưỡng, 0) lấy thẳng từ chuỗi BCSD đã chứng ' 'nhận — đây chính là đại lượng mà quy tắc phát hành nhị phân đặt ' 'ngưỡng lên. Ở bản tin EC, ô tương ứng là trung bình của 72 thành ' 'viên AI; ở đây hệ CFS chỉ có một chuỗi tất định nên không có ' 'độ tán thành viên để lấy trung bình, và trường này có thể ' 'hiện được trường hợp “không ngày nào vượt ngưỡng” (bằng 0).
') b = binary_map(grid.to_grid(pred["W1"].astype("f4")), grid, borders, "W1 — EHDE (đỏ = có)") body.append("

2b. Sản phẩm nhị " "phân phát hành (chỉ W1, %s → %s)

" % (init.strftime("%d/%m"), (init + dt.timedelta(days=10)).strftime("%d/%m/%Y"))) body.append('
W1 nhị phân
' % b) # -------------------------------------------------------------- 3 png = prob_row([grid.to_grid(prob[w]) for w in W], grid, borders, labs, "Xác suất xảy ra EHDE — khởi tạo %s" % init.strftime("%d/%m/%Y")) body.append("

3. Trường xác suất EHDE, W1–W6

" '
xác suất W1-W6
' % png) body.append('
Đây là chỗ khác về bản chất so với bản ' 'tin EC. Bên EC, P(yEHDE) = M/N là tỉ lệ phiếu của 72 ' 'thành viên AI. Hệ CFS đã chứng nhận là một chuỗi tất định (BCSD), ' 'không có thành viên để bỏ phiếu, nên xác suất ở đây là hiệu ' 'chuẩn P(EHD | biên độ vượt ngưỡng), khớp bằng hồi quy logistic ' 'trên các năm huấn luyện của bộ hồi báo, riêng cho từng tuần, ' 'gộp trên toàn bộ 541 điểm. Hai đại lượng cùng dự báo một sự kiện và ' 'đều được khớp ngoài mẫu kiểm định, nhưng không phải cùng một ước ' 'lượng; khi so sánh cần nhớ điều đó. Điểm số Brier trên năm kiểm ' 'định: %s.
' % ", ".join("%s %.4f" % (w, skill[w]["BRIER"]) for w in W)) # độ tin cậy của xác suất rows = ["" + "".join("" % w for w in W) + ""] for k in range(10): cells = [] for w in W: nk, pm, om = rel[w][k] cells.append("" % ("—" if not nk or om != om else "%.2f" % om)) rows.append("%s" % (k / 10, (k + 1) / 10, "".join(cells))) rows.append("
Khoảng xác suất%s
%s
%.1f–%.1f
") body.append("

3b. Độ tin cậy — " "tần suất quan trắc thực tế trong mỗi khoảng xác suất (%d init kiểm " "định)

" % nvalid + "".join(rows)) # -------------------------------------------------------------- 4 variants = [("Quy tắc nghiệp vụ (biên độ ≥ %+.2f °C)" % delta, {w: pred[w] for w in W}, True)] for t in (0.5, 0.6, 0.7): variants.append(("P(yEHDE) > %.1f" % t, {w: prob[w] > t for w in W}, False)) body.append("

4. EHDE theo vùng — bốn phương án xác định yEHDE

") body.append('
Tiêu chí vùng giữ nguyên theo EXP1: vùng ' 'được coi là có EHDE nếu > 20% số ô lưới của vùng đó có ' 'yEHDE (ô tô đậm). Cái thay đổi giữa bốn bảng là cách xác định ' 'yEHDE tại từng ô lưới. Chỉ bảng đầu là quy tắc đang phát ' 'hành, và chỉ cột W1 của nó được chứng nhận; ba bảng sau để so sánh ' 'độ nhạy theo ngưỡng xác suất.
') for name, dec, is_op in variants: rows = ["" + "".join("%s" % ("" if (w == "W1" or not is_op) else ' class="ref"', w) for w in W) + ""] for code, nmv in REG_ORDER: sel = grid.reg == code cells = [] for w in W: P = dec[w][sel].mean() cls = "yes" if P > P_REGION else "no" if is_op and w != "W1": cls += " ref" cells.append('' % (cls, 100 * P)) rows.append("%s" % (escape(nmv), sel.sum(), "".join(cells))) nreg = [sum(1 for c, _ in REG_ORDER if dec[w][grid.reg == c].mean() > P_REGION) for w in W] rows.append('' + "".join("" % x for x in nreg) + "
Vùngn ô
%.0f%%
%s%d
số vùng có EHDE%d/7
") tag = " (đang phát hành)" if is_op else "" body.append("

%s%s

" % (name, tag) + "".join(rows)) # -------------------------------------------------------------- 5 rows = ["" "" ""] for w in W: f = pred[w].mean(); o = cov_obs[w] p95 = np.percentile(o, 95); over = f > p95 rows.append('' '' % (' class="warn"' if over else "", w, 100 * f, 100 * np.median(o), 100 * p95, "vượt p95 — không phát nhị phân" if over else "trong dải hợp lý")) rows.append("
Cửa sổDiện tích dự báoTrung vị quan trắcPhân vị 95 quan trắcĐánh giá
%s%.1f%%%.1f%%%.1f%%%s
") body.append("

5. Kiểm tra độ hợp lý của diện tích dự báo

" + "".join(rows)) deg_m = [100.0 * (cov_mod[w] > 0.95).mean() for w in W[3:]] deg_o = max(100.0 * (cov_obs[w] > 0.95).mean() for w in W[3:]) body.append('
Diện tích dự báo đặt cạnh phân bố diện tích ' 'quan trắc trên %d lần khởi tạo của ba năm kiểm định. Trạng ' 'thái suy biến “cả nước cùng bật” (>95%% số ô): ở W4–W6 mô hình ' 'phát %.1f–%.1f%% số lần so với %.1f%% trong quan trắc. Chuỗi BCSD ' 'không có suy biến này, khác với hệ EC.
' % (nvalid, min(deg_m), max(deg_m), deg_o)) # -------------------------------------------------------------- 6 ms = ["POD", "POFD", "FAR", "FBI", "CSI", "SEDI", "AUC"] ec = None if os.path.exists(a.ec_verify): ec = json.load(open(a.ec_verify))["national"] body.append("

6. Kỹ năng tham chiếu

") body.append('
Bảng chia làm hai đơn vị chấm điểm, vì ' 'trộn chúng là cách dễ nhất để kết luận sai. 6a chấm theo ' 'từng ô lưới — đây là đơn vị của bảng mục 6 trong bản tin EC ' 'nên đặt cạnh nhau được. 6b chấm theo vùng dưới quy ' 'tắc EXP1 (vùng có EHDE khi > 20%% số ô) — đây mới là đơn vị của ' 'sản phẩm đang phát hành và là đơn vị mà bộ chỉ tiêu của dự ' 'án được phát biểu. Hai bảng có tần suất nền khác hẳn nhau ' '(%.3f so với %.3f ở W1) nên KHÔNG so chéo giữa 6a và 6b.
' % (skill["W1"]["base"], skill_reg["W1"]["base"])) rows = ["" + "".join("" % w for w in W) + ""] for m in ms: rows.append("%s" % (m, "".join("" % skill[w][m] for w in W))) rows.append("%s" % ("".join("" % ec[w][m] for w in W) if ec else "")) rows.append("%s" % "".join("" % skill[w]["base"] for w in W)) if ec: rows.append("%s" % "".join("" % ec[w]["base_rate"] for w in W)) rows.append("
Chỉ sốHệ%s
%sCFSv2
%.3f
EC (ope1b)
%.3f—
tần suất nềnCFSv2
%.3f
EC (ope1b)
%.3f
") body.append("

6a. Theo ô lưới — " "%d init kiểm định (2001/2006/2009), đặt cạnh bản tin EC (416 init, " "4 năm)

" % nvalid + "".join(rows)) body.append('
Đọc bảng này có ba điều phải nhớ. ' '(1) Tần suất nền khác nhau (CFS %.3f, EC %.3f ở W1) vì hai hệ dùng ' 'năm kiểm định khác nhau; FAR và CSI nhạy với tần suất nền, POD và ' 'POFD thì không. (2) Dung lượng kiểm định khác nhau: %d init / 3 năm ' 'so với 416 init / 4 năm. (3) Điểm làm việc khác nhau: CFS chốt ' 'δ = %+.2f °C theo ràng buộc cấp vùng, nên nếu đọc nó ở cấp ô ' 'lưới thì nó là một điểm bảo thủ (FBI %.2f), không phải điểm tối ưu ' 'của chính bảng này. Với ba lưu ý đó, kết luận vẫn rõ: ở mọi hạn ' 'và mọi chỉ số, EC hơn CFS — AUC W1 %.3f so với %.3f, và khoảng ' 'cách giữ nguyên đến W6.
' % (skill["W1"]["base"], ec["W1"]["base_rate"] if ec else float("nan"), nvalid, delta, skill["W1"]["FBI"], ec["W1"]["AUC"] if ec else float("nan"), skill["W1"]["AUC"])) tgt = {"POD": (">", 0.60), "POFD": ("<", 0.20), "FAR": ("<", 0.20), "CSI": (">", 0.40)} rows = ["" + "".join("" % w for w in W) + ""] for m in ms: cells = "".join("%.3f" % ("" if w == "W1" else ' class="ref"', skill_reg[w][m]) for w in W) if m in tgt: op, v = tgt[m] ok = (skill_reg["W1"][m] > v) if op == ">" else (skill_reg["W1"][m] < v) extra = "" % ( op, v, "yes" if ok else "warn", "ĐẠT" if ok else "KHÔNG") elif m == "FBI": extra = "" % skill_reg["W1"][m] else: extra = "" rows.append("%s%s" % (m, cells, extra)) rows.append("%s
Chỉ số%sChỉ tiêuW1 đạt?
%s %.2f%s~ 1%.2f — thấp——
%s
tần suất nền——
" % "".join("%.3f" % ("" if w == "W1" else ' class="ref"', skill_reg[w]["base"]) for w in W)) body.append("

6b. Theo vùng, quy " "tắc EXP1 — đơn vị của sản phẩm đang phát hành

" + "".join(rows)) body.append('
Cột W1 là sản phẩm được chứng nhận: đạt POD, ' 'POFD, FAR và CSI; chỉ FBI %.2f là thấp, tức hệ báo thiếu chứ ' 'không báo thừa. Bootstrap khối năm 5000 lần trên năm kiểm định: ' 'trọn bộ chỉ tiêu đạt ở 58,7%% số lần, ràng buộc cưỡi ngưỡng duy ' 'nhất là POFD. Từ W2 trở đi FAR vọt lên 0,36–0,43 và không ngưỡng ' 'nào kéo được xuống dưới 0,2 mà vẫn giữ POD — đó là trần tín hiệu ' 'của CFS ở hạn đó, nên các hạn này chỉ phát trường xác suất và ' 'trường cường độ, không phát nhị phân.
' % skill_reg["W1"]["FBI"]) sub = ("Khởi tạo %s 00Z từ CFSv2 nghiệp vụ, thành viên 01 (hệ được " "huấn luyện trên một thành viên tất định của bộ hồi báo, nên không dùng " "trung bình 4 thành viên). 541 ô lưới đất VnGAD 0,25°, ranh giới bảy vùng " "khí hậu từ Vietnam7.shp. Ngưỡng EHD " "min(Tx90p, 35 °C), Tx90p trên cửa sổ 31 ngày. " "EHDE của một tuần = có ít nhất một ngày EHD trong tuần. Hạ quy mô bằng " "BCSD khớp trên các năm huấn luyện trong cửa sổ ±30 ngày quanh " "chính ngày trong năm của tuần đang dự báo. Không tham số nào được khớp " "lại trên chính bản dự báo này." % init.strftime("%d/%m/%Y")) title = ("Dự báo nội mùa nắng nóng cực đoan (EHDE) — CFSv2, khởi tạo %s" % init.strftime("%d/%m/%Y")) with open(a.out, "w") as fh: fh.write(PAGE % dict(title=escape(title), subtitle=sub, body="\n".join(body))) print("[done] -> %s" % a.out, flush=True) if __name__ == "__main__": main()