Spaces:
Running
Running
Switch to the 2026-10-25 CAS-Canglong SST model: 12-month mean, spread and quintile probabilities; held-out skill and hindcast tab; Oct 2026 IRI plume; atmosphere 2026-10-22
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +26 -0
- README.md +18 -12
- app.py +217 -99
- assets/maps/atm_msl_w1.png +2 -2
- assets/maps/atm_msl_w2.png +2 -2
- assets/maps/atm_msl_w3.png +2 -2
- assets/maps/atm_msl_w4.png +2 -2
- assets/maps/atm_msl_w5.png +2 -2
- assets/maps/atm_msl_w6.png +2 -2
- assets/maps/atm_t2m_w1.png +2 -2
- assets/maps/atm_t2m_w2.png +2 -2
- assets/maps/atm_t2m_w3.png +2 -2
- assets/maps/atm_t2m_w4.png +2 -2
- assets/maps/atm_t2m_w5.png +2 -2
- assets/maps/atm_t2m_w6.png +2 -2
- assets/maps/atm_tp_w1.png +2 -2
- assets/maps/atm_tp_w2.png +2 -2
- assets/maps/atm_tp_w3.png +2 -2
- assets/maps/atm_tp_w4.png +2 -2
- assets/maps/atm_tp_w5.png +2 -2
- assets/maps/atm_tp_w6.png +2 -2
- assets/maps/atm_z500_w1.png +2 -2
- assets/maps/atm_z500_w2.png +2 -2
- assets/maps/atm_z500_w3.png +2 -2
- assets/maps/atm_z500_w4.png +2 -2
- assets/maps/atm_z500_w5.png +2 -2
- assets/maps/atm_z500_w6.png +2 -2
- assets/maps/{sst_t13.png → pupper_t01.png} +2 -2
- assets/maps/{sst_t14.png → pupper_t02.png} +2 -2
- assets/maps/{sst_t15.png → pupper_t03.png} +2 -2
- assets/maps/{sst_t16.png → pupper_t04.png} +2 -2
- assets/maps/pupper_t05.png +3 -0
- assets/maps/pupper_t06.png +3 -0
- assets/maps/pupper_t07.png +3 -0
- assets/maps/pupper_t08.png +3 -0
- assets/maps/pupper_t09.png +3 -0
- assets/maps/pupper_t10.png +3 -0
- assets/maps/pupper_t11.png +3 -0
- assets/maps/pupper_t12.png +3 -0
- assets/maps/sigma_t01.png +3 -0
- assets/maps/sigma_t02.png +3 -0
- assets/maps/sigma_t03.png +3 -0
- assets/maps/sigma_t04.png +3 -0
- assets/maps/sigma_t05.png +3 -0
- assets/maps/sigma_t06.png +3 -0
- assets/maps/sigma_t07.png +3 -0
- assets/maps/sigma_t08.png +3 -0
- assets/maps/sigma_t09.png +3 -0
- assets/maps/sigma_t10.png +3 -0
- assets/maps/sigma_t11.png +3 -0
.gitattributes
CHANGED
|
@@ -93,3 +93,29 @@ assets/maps/ssta_t15.png filter=lfs diff=lfs merge=lfs -text
|
|
| 93 |
assets/maps/ssta_t16.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
data/canglong_atmos_start20261008.nc filter=lfs diff=lfs merge=lfs -text
|
| 95 |
data/canglong_sst_init202609.nc filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
assets/maps/ssta_t16.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
data/canglong_atmos_start20261008.nc filter=lfs diff=lfs merge=lfs -text
|
| 95 |
data/canglong_sst_init202609.nc filter=lfs diff=lfs merge=lfs -text
|
| 96 |
+
assets/maps/pupper_t01.png filter=lfs diff=lfs merge=lfs -text
|
| 97 |
+
assets/maps/pupper_t02.png filter=lfs diff=lfs merge=lfs -text
|
| 98 |
+
assets/maps/pupper_t03.png filter=lfs diff=lfs merge=lfs -text
|
| 99 |
+
assets/maps/pupper_t04.png filter=lfs diff=lfs merge=lfs -text
|
| 100 |
+
assets/maps/pupper_t05.png filter=lfs diff=lfs merge=lfs -text
|
| 101 |
+
assets/maps/pupper_t06.png filter=lfs diff=lfs merge=lfs -text
|
| 102 |
+
assets/maps/pupper_t07.png filter=lfs diff=lfs merge=lfs -text
|
| 103 |
+
assets/maps/pupper_t08.png filter=lfs diff=lfs merge=lfs -text
|
| 104 |
+
assets/maps/pupper_t09.png filter=lfs diff=lfs merge=lfs -text
|
| 105 |
+
assets/maps/pupper_t10.png filter=lfs diff=lfs merge=lfs -text
|
| 106 |
+
assets/maps/pupper_t11.png filter=lfs diff=lfs merge=lfs -text
|
| 107 |
+
assets/maps/pupper_t12.png filter=lfs diff=lfs merge=lfs -text
|
| 108 |
+
assets/maps/sigma_t01.png filter=lfs diff=lfs merge=lfs -text
|
| 109 |
+
assets/maps/sigma_t02.png filter=lfs diff=lfs merge=lfs -text
|
| 110 |
+
assets/maps/sigma_t03.png filter=lfs diff=lfs merge=lfs -text
|
| 111 |
+
assets/maps/sigma_t04.png filter=lfs diff=lfs merge=lfs -text
|
| 112 |
+
assets/maps/sigma_t05.png filter=lfs diff=lfs merge=lfs -text
|
| 113 |
+
assets/maps/sigma_t06.png filter=lfs diff=lfs merge=lfs -text
|
| 114 |
+
assets/maps/sigma_t07.png filter=lfs diff=lfs merge=lfs -text
|
| 115 |
+
assets/maps/sigma_t08.png filter=lfs diff=lfs merge=lfs -text
|
| 116 |
+
assets/maps/sigma_t09.png filter=lfs diff=lfs merge=lfs -text
|
| 117 |
+
assets/maps/sigma_t10.png filter=lfs diff=lfs merge=lfs -text
|
| 118 |
+
assets/maps/sigma_t11.png filter=lfs diff=lfs merge=lfs -text
|
| 119 |
+
assets/maps/sigma_t12.png filter=lfs diff=lfs merge=lfs -text
|
| 120 |
+
data/canglong_atmos_start20261022.nc filter=lfs diff=lfs merge=lfs -text
|
| 121 |
+
data/canglong_sst_round5_init202609.nc filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -9,7 +9,11 @@ python_version: "3.12"
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
license: mit
|
| 12 |
-
short_description:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
tags:
|
| 14 |
- weather
|
| 15 |
- climate
|
|
@@ -22,19 +26,21 @@ tags:
|
|
| 22 |
|
| 23 |
# 🐉 CAS-Canglong · global SST & ENSO forecast
|
| 24 |
|
| 25 |
-
The
|
| 26 |
-
|
| 27 |
-
[
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
* **Ocean & ENSO** —
|
| 30 |
-
outlook against the IRI ENSO plume.
|
| 31 |
* **Regional outlook** — Niño regions, the Indian Ocean Dipole, the tropical North Atlantic, the Kuroshio, the
|
| 32 |
-
Mediterranean and more,
|
|
|
|
| 33 |
* **Atmosphere · 6 weeks (experimental)** — weekly 2 m temperature, precipitation, sea-level pressure and 500 hPa height
|
| 34 |
from the CAS-Canglong atmospheric run, read with [WeatherPre](https://github.com/GISWLH/WeatherPre).
|
| 35 |
-
* **
|
| 36 |
|
| 37 |
-
The
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
This is a research forecast, not an official warning product; see the *About* tab for limitations.
|
|
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
license: mit
|
| 12 |
+
short_description: 12-month global SST & ENSO outlook with probabilities
|
| 13 |
+
models:
|
| 14 |
+
- LonghaoWang/CAS-Canglong-SST
|
| 15 |
+
datasets:
|
| 16 |
+
- LonghaoWang/SST-Hindcast-2016-2022-1deg-lead1-12
|
| 17 |
tags:
|
| 18 |
- weather
|
| 19 |
- climate
|
|
|
|
| 26 |
|
| 27 |
# 🐉 CAS-Canglong · global SST & ENSO forecast
|
| 28 |
|
| 29 |
+
The latest run of the **2026-10-25 release of the CAS-Canglong 1° SST model**
|
| 30 |
+
([weights and code](https://huggingface.co/LonghaoWang/CAS-Canglong-SST) ·
|
| 31 |
+
[2016–2022 hindcast](https://huggingface.co/datasets/LonghaoWang/SST-Hindcast-2016-2022-1deg-lead1-12) ·
|
| 32 |
+
[paper, *Advanced Science* 2026](https://doi.org/10.1002/advs.78271) · [code](https://github.com/GISWLH/CAS-Canglong) ·
|
| 33 |
+
live portal [canglong.earth](https://canglong.earth)).
|
| 34 |
|
| 35 |
+
* **Ocean & ENSO** — 12 monthly maps of SST, SST anomaly, the chance of a top-quintile month and the forecast spread, and
|
| 36 |
+
the Niño 3.4 outlook against the IRI ENSO plume.
|
| 37 |
* **Regional outlook** — Niño regions, the Indian Ocean Dipole, the tropical North Atlantic, the Kuroshio, the
|
| 38 |
+
Mediterranean and more, with the last 12 observed months.
|
| 39 |
+
* **Skill & hindcasts** — a held-out test on 2023–2026 targets and the 2016–2022 Niño 3.4 hindcast, lead by lead.
|
| 40 |
* **Atmosphere · 6 weeks (experimental)** — weekly 2 m temperature, precipitation, sea-level pressure and 500 hPa height
|
| 41 |
from the CAS-Canglong atmospheric run, read with [WeatherPre](https://github.com/GISWLH/WeatherPre).
|
| 42 |
+
* **Model & data** — the NetCDF and CSV files behind every map, and code to load the model and the hindcast.
|
| 43 |
|
| 44 |
+
The Space only displays precomputed fields; they come from the frozen model run on a GPU workstation and are rebuilt with
|
| 45 |
+
[`hf_space/scripts/build_assets.py`](https://github.com/GISWLH/CAS-Canglong/tree/main/hf_space). This is a research
|
| 46 |
+
forecast, not an official warning product; see the *About* tab for its limits, in particular for ENSO.
|
|
|
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""CAS-Canglong forecast showcase (Hugging Face Space).
|
| 2 |
|
| 3 |
-
Everything shown is read from `data/` and `assets/`, written by `scripts/build_assets.py` from one forecast
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
|
|
@@ -18,16 +18,22 @@ DATA, MAPS = ROOT / "data", ROOT / "assets" / "maps"
|
|
| 18 |
RUN = json.loads((DATA / "run.json").read_text())
|
| 19 |
SST = xr.open_dataset(DATA / RUN["sst_file"]).load()
|
| 20 |
IRI = json.loads((DATA / "iri_plume.json").read_text())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
SURFACE, GRIDLINE, BASELINE, INK, INK2, MUTED = "#fcfcfb", "#e1e0d9", "#c3c2b7", "#0b0b0b", "#52514e", "#898781"
|
| 23 |
CAS_BLUE, DYN_ORANGE, STAT_AQUA, CONTEXT = "#2a78d6", "#eb6834", "#1baf7a", "#c3c2b7"
|
| 24 |
FONT = "system-ui, -apple-system, Segoe UI, sans-serif"
|
| 25 |
|
| 26 |
-
FRAMES = RUN["frames"] # index 0 = observed
|
| 27 |
WEEKS = RUN["weeks"]
|
| 28 |
MONTHS = pd.to_datetime(SST.time.values)
|
| 29 |
OBS_MONTH = pd.Timestamp(SST.obs_time.values[-1])
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# lat/lon boxes on the 0..360 grid; DMI is west minus east
|
| 33 |
REGIONS = {
|
|
@@ -44,6 +50,18 @@ REGIONS = {
|
|
| 44 |
"Mediterranean (30°N–46°N, 0°–36°E)": [(30, 46, 0, 36)],
|
| 45 |
"Global ocean (60°S–60°N)": [(-60, 60, 0, 360)],
|
| 46 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def box_mean(field: xr.DataArray, boxes) -> np.ndarray:
|
|
@@ -56,7 +74,8 @@ def box_mean(field: xr.DataArray, boxes) -> np.ndarray:
|
|
| 56 |
|
| 57 |
|
| 58 |
def region_series(name: str) -> np.ndarray:
|
| 59 |
-
|
|
|
|
| 60 |
spec = REGIONS[name]
|
| 61 |
if spec == "dmi":
|
| 62 |
return box_mean(fields, [(-10, 10, 50, 70)]) - box_mean(fields, [(-10, 0, 90, 110)])
|
|
@@ -89,18 +108,8 @@ def _center(season: str) -> pd.Timestamp | None:
|
|
| 89 |
|
| 90 |
def plume_figure() -> go.Figure:
|
| 91 |
fig = go.Figure()
|
| 92 |
-
obs = [(_center(o["season"]), o["data"], o["label"]) for o in IRI["observed"]]
|
| 93 |
-
iri_anchor = max(obs, key=lambda o: o[0])
|
| 94 |
-
|
| 95 |
-
def connect_observation(anchor, x, y, color, width, group):
|
| 96 |
-
# A visual bridge to the first forecast; no extra forecast values are introduced.
|
| 97 |
-
fig.add_trace(go.Scatter(x=[anchor[0], x[0]], y=[anchor[1], y[0]], mode="lines",
|
| 98 |
-
line=dict(color=color, width=width, dash="dot"),
|
| 99 |
-
legendgroup=group, showlegend=False, hoverinfo="skip"))
|
| 100 |
-
|
| 101 |
for k, m in enumerate(IRI["models"]):
|
| 102 |
x = [_center(s) for s in m["seasons"]]
|
| 103 |
-
connect_observation(iri_anchor, x, m["data"], CONTEXT, 1, "iri")
|
| 104 |
fig.add_trace(go.Scatter(x=x, y=m["data"], mode="lines", line=dict(color=CONTEXT, width=1),
|
| 105 |
name="IRI plume models", legendgroup="iri", showlegend=k == 0, opacity=0.9,
|
| 106 |
hovertemplate=f"{m['model']} ({m['type'].lower()}): %{{y:+.2f}} °C<extra></extra>"))
|
|
@@ -109,26 +118,20 @@ def plume_figure() -> go.Figure:
|
|
| 109 |
if avg["model"] not in colors: continue
|
| 110 |
x = [_center(s) for s in avg["seasons"]]
|
| 111 |
name = f"IRI {avg['model']} mean"
|
| 112 |
-
|
| 113 |
-
fig.add_trace(go.Scatter(x=x, y=avg["data"], mode="lines+markers", name=name, legendgroup=name,
|
| 114 |
line=dict(color=colors[avg["model"]], width=2), marker=dict(size=8, line=dict(color=SURFACE, width=2)),
|
| 115 |
hovertemplate=f"{name}: %{{y:+.2f}} °C<extra></extra>"))
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
xs = MONTHS[1:-1]
|
| 118 |
-
cas_anchor = (OBS_MONTH, float(SST.observed_nino34.values[-1]))
|
| 119 |
-
connect_observation(cas_anchor, xs, run, CAS_BLUE, 3, "cas")
|
| 120 |
fig.add_trace(go.Scatter(x=xs, y=run, mode="lines+markers", name="CAS-Canglong (3-month mean)",
|
| 121 |
-
legendgroup="cas",
|
| 122 |
line=dict(color=CAS_BLUE, width=3), marker=dict(size=8, line=dict(color=SURFACE, width=2)),
|
| 123 |
hovertemplate="CAS-Canglong: %{y:+.2f} °C<extra></extra>"))
|
| 124 |
fig.add_annotation(x=xs[-1], y=run[-1], text="CAS-Canglong", xanchor="left", xshift=8, showarrow=False,
|
| 125 |
font=dict(color=INK, size=12))
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
marker=dict(color=CAS_BLUE, size=10, symbol="diamond", line=dict(color=SURFACE, width=2)),
|
| 129 |
-
hovertemplate="ERA5 observed %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 130 |
-
fig.add_trace(go.Scatter(x=[o[0] for o in obs], y=[o[1] for o in obs], mode="lines+markers", name="Observed (IRI)",
|
| 131 |
-
line=dict(color=INK, width=2),
|
| 132 |
marker=dict(color=INK, size=9, symbol="diamond", line=dict(color=SURFACE, width=2)),
|
| 133 |
text=[o[2] for o in obs], hovertemplate="%{text}: %{y:+.2f} °C<extra></extra>"))
|
| 134 |
for y, label in ((0.5, "El Niño +0.5 °C"), (-0.5, "La Niña −0.5 °C")):
|
|
@@ -136,38 +139,48 @@ def plume_figure() -> go.Figure:
|
|
| 136 |
fig.add_annotation(x=0, xref="paper", y=y, text=label, xanchor="left", yanchor="bottom", xshift=4,
|
| 137 |
showarrow=False, font=dict(color=MUTED, size=11))
|
| 138 |
fig.update_xaxes(dtick="M2", tickformat="%b<br>%Y", tickangle=0)
|
| 139 |
-
return _layout(fig, "Niño 3.4 SST anomaly (°C)", 460)
|
| 140 |
|
| 141 |
|
| 142 |
def region_figure(name: str) -> go.Figure:
|
| 143 |
y = region_series(name)
|
| 144 |
-
x = [
|
|
|
|
| 145 |
fig = go.Figure()
|
| 146 |
-
fig.add_trace(go.Scatter(x=x, y=y, mode="lines+markers", name="
|
| 147 |
-
line=dict(color=
|
| 148 |
-
marker=dict(size=[0] + [8] * len(MONTHS), line=dict(color=SURFACE, width=2)),
|
| 149 |
-
text=["ERA5 observed"] + ["CAS-Canglong forecast"] * len(MONTHS),
|
| 150 |
-
hovertemplate="%{text} %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 151 |
-
fig.add_trace(go.Scatter(x=x[:1], y=y[:1], mode="markers", name=f"ERA5 observed {OBS_MONTH:%b %Y}",
|
| 152 |
-
marker=dict(color=INK, size=10, symbol="diamond", line=dict(color=SURFACE, width=2)),
|
| 153 |
hovertemplate="observed %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
if name.startswith("Niño"):
|
| 155 |
for v in (0.5, -0.5):
|
| 156 |
fig.add_hline(y=v, line=dict(color=MUTED, width=1, dash="dot"))
|
| 157 |
fig.update_xaxes(dtick="M2", tickformat="%b<br>%Y", tickangle=0)
|
| 158 |
-
return _layout(fig, "SST anomaly (°C)", 400)
|
| 159 |
|
| 160 |
|
| 161 |
def region_table(name: str) -> pd.DataFrame:
|
| 162 |
-
y = region_series(name)
|
| 163 |
labels = [f"{OBS_MONTH:%b %Y} (observed)"] + [f"{t:%b %Y}" for t in MONTHS]
|
| 164 |
return pd.DataFrame({"Month": labels, "Lead (months)": ["0"] + [str(i) for i in range(1, len(MONTHS) + 1)],
|
| 165 |
"SST anomaly (°C)": [f"{v:+.2f}" for v in y]})
|
| 166 |
|
| 167 |
|
| 168 |
def map_path(field: str, index: int) -> str:
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
def atmos_path(var: str, week: int) -> str:
|
|
@@ -176,80 +189,152 @@ def atmos_path(var: str, week: int) -> str:
|
|
| 176 |
return str(MAPS / f"atm_{code}_w{int(week)}.png")
|
| 177 |
|
| 178 |
|
| 179 |
-
def frame_label(index: int) -> str:
|
| 180 |
-
return f"**{FRAMES[int(index)]['label']}**"
|
| 181 |
-
|
| 182 |
-
|
| 183 |
def week_label(week: int) -> str:
|
| 184 |
return f"**{WEEKS[int(week) - 1]['label']}**"
|
| 185 |
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
glob = region_series("Global ocean (60°S–60°N)")
|
| 190 |
KPIS = [
|
| 191 |
-
("Niño 3.4 ·
|
| 192 |
-
("Niño 3.4 ·
|
| 193 |
-
("
|
| 194 |
-
("
|
|
|
|
| 195 |
]
|
| 196 |
kpi_html = "".join(f'<div class="kpi"><div class="kpi-label">{a}</div><div class="kpi-value">{b}</div>'
|
| 197 |
f'<div class="kpi-note">{c}</div></div>' for a, b, c in KPIS)
|
| 198 |
|
| 199 |
HEADER = f"""
|
| 200 |
<div class="hero">
|
| 201 |
-
<div class="hero-kicker">CAS-Canglong ·
|
| 202 |
<h1>🐉 Global sea-surface temperature & ENSO outlook</h1>
|
| 203 |
-
<p>Input: ERA5 monthly means through <b>{OBS_MONTH:%B %Y}</b> →
|
| 204 |
-
(
|
| 205 |
<div class="chips">
|
|
|
|
|
|
|
| 206 |
<a href="https://doi.org/10.1002/advs.78271" target="_blank">📄 Paper · Advanced Science</a>
|
| 207 |
<a href="https://github.com/GISWLH/CAS-Canglong" target="_blank">⭐ GitHub</a>
|
| 208 |
<a href="https://canglong.earth" target="_blank">🌍 canglong.earth</a>
|
| 209 |
-
<a href="https://github.com/GISWLH/WeatherAI" target="_blank">🧩 WeatherAI (PyTorch port)</a>
|
| 210 |
<a href="https://github.com/GISWLH/WeatherPre" target="_blank">📊 WeatherPre</a>
|
| 211 |
</div>
|
| 212 |
</div>
|
| 213 |
<div class="kpis">{kpi_html}</div>
|
| 214 |
"""
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
ABOUT = f"""
|
| 217 |
### What this is
|
| 218 |
-
CAS-Canglong
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
* **
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
* **Atmosphere (6 weeks, experimental)** — CAS-Canglong V3.5 weekly means published on canglong.earth, read through
|
| 227 |
[WeatherPre](https://github.com/GISWLH/WeatherPre) (`wp.forecast("canglong", "t2m", "6w")`).
|
| 228 |
|
| 229 |
### How to read the maps
|
| 230 |
-
* **SST anomaly** maps
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
| 237 |
|
| 238 |
### Limits — please read
|
| 239 |
-
* A research forecast, not an official or operational warning product.
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
*
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
### Provenance of this run
|
| 248 |
-
*
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
`{RUN['checkpoint_sha256']['highres_checkpoint'][:16]}…`; input cache `{RUN['input_cache_sha256'][:16]}…`.
|
| 253 |
* Atmosphere: {RUN['atmos_source']}.
|
| 254 |
|
| 255 |
### Cite
|
|
@@ -275,7 +360,7 @@ CSS = """
|
|
| 275 |
.hero .chips a {text-decoration: none !important; padding: 5px 12px; border-radius: 999px; font-size: .9rem;
|
| 276 |
border: 1px solid var(--border-color-primary); color: var(--body-text-color) !important; background: var(--block-background-fill);}
|
| 277 |
.hero .chips a:hover {border-color: var(--color-accent);}
|
| 278 |
-
.kpis {display: grid; grid-template-columns: repeat(auto-fit, minmax(
|
| 279 |
.kpi {padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border-color-primary); background: var(--block-background-fill);}
|
| 280 |
.kpi-label {font-size: .8rem; color: var(--body-text-color-subdued);}
|
| 281 |
.kpi-value {font-size: 1.55rem; font-weight: 650; margin: 2px 0;}
|
|
@@ -287,26 +372,43 @@ with gr.Blocks(title="CAS-Canglong · SST & ENSO forecast") as demo:
|
|
| 287 |
with gr.Tabs():
|
| 288 |
with gr.Tab("🌊 Ocean & ENSO"):
|
| 289 |
with gr.Row(equal_height=True):
|
| 290 |
-
field = gr.Radio(
|
| 291 |
lead = gr.Slider(0, len(FRAMES) - 1, value=1, step=1, label="Month (0 = last observed input month)", scale=3)
|
| 292 |
-
caption = gr.Markdown(frame_label(1))
|
| 293 |
image = gr.Image(map_path("SST anomaly", 1), show_label=False, interactive=False, buttons=["download", "fullscreen"])
|
| 294 |
gr.Markdown("#### Niño 3.4 outlook vs the IRI ENSO plume")
|
| 295 |
gr.Plot(plume_figure(), show_label=False)
|
| 296 |
-
gr.Markdown("
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
lead.change(lambda f, i: (map_path(f, i), frame_label(i)), [field, lead], [image, caption])
|
| 302 |
-
field.change(lambda f, i: map_path(f, i), [field, lead], image)
|
| 303 |
with gr.Tab("📍 Regional outlook"):
|
| 304 |
region = gr.Dropdown(list(REGIONS), value=list(REGIONS)[0], label="Region")
|
| 305 |
rplot = gr.Plot(region_figure(list(REGIONS)[0]), show_label=False)
|
| 306 |
-
gr.Markdown(
|
| 307 |
-
|
| 308 |
rtable = gr.Dataframe(region_table(list(REGIONS)[0]), interactive=False)
|
| 309 |
region.change(lambda r: (region_figure(r), region_table(r)), region, [rplot, rtable])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
with gr.Tab("🌦️ Atmosphere · 6 weeks (experimental)"):
|
| 311 |
gr.Markdown("Weekly-mean fields of the CAS-Canglong V3.5 atmospheric run published on canglong.earth. "
|
| 312 |
"**Not drift-corrected** — high-latitude temperatures drift warm with lead time (see *About*).")
|
|
@@ -318,15 +420,31 @@ with gr.Blocks(title="CAS-Canglong · SST & ENSO forecast") as demo:
|
|
| 318 |
aimg = gr.Image(atmos_path("2 m temperature", 1), show_label=False, interactive=False, buttons=["download", "fullscreen"])
|
| 319 |
week.change(lambda v, w: (atmos_path(v, w), week_label(w)), [avar, week], [aimg, wcap])
|
| 320 |
avar.change(lambda v, w: atmos_path(v, w), [avar, week], aimg)
|
| 321 |
-
with gr.Tab("📦
|
| 322 |
-
gr.Markdown("
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
file_count="multiple", label="Downloads", interactive=False)
|
| 325 |
gr.Code(f"""import xarray as xr
|
| 326 |
-
from huggingface_hub import hf_hub_download
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
with gr.Tab("ℹ️ About"):
|
| 331 |
gr.Markdown(ABOUT)
|
| 332 |
|
|
|
|
| 1 |
"""CAS-Canglong forecast showcase (Hugging Face Space).
|
| 2 |
|
| 3 |
+
Everything shown is read from `data/` and `assets/`, written by `scripts/build_assets.py` from one forecast release.
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
|
|
|
|
| 18 |
RUN = json.loads((DATA / "run.json").read_text())
|
| 19 |
SST = xr.open_dataset(DATA / RUN["sst_file"]).load()
|
| 20 |
IRI = json.loads((DATA / "iri_plume.json").read_text())
|
| 21 |
+
HINDCAST = pd.read_csv(DATA / "hindcast_nino34.csv", parse_dates=["target_month"])
|
| 22 |
+
HINDCAST_SKILL = pd.read_csv(DATA / "hindcast_nino34_skill.csv")
|
| 23 |
+
HELDOUT = pd.read_csv(DATA / "round6_heldout_metrics.csv")
|
| 24 |
+
MODEL_REPO, HINDCAST_REPO = RUN["model_repo"], RUN["hindcast_dataset"]
|
| 25 |
|
| 26 |
SURFACE, GRIDLINE, BASELINE, INK, INK2, MUTED = "#fcfcfb", "#e1e0d9", "#c3c2b7", "#0b0b0b", "#52514e", "#898781"
|
| 27 |
CAS_BLUE, DYN_ORANGE, STAT_AQUA, CONTEXT = "#2a78d6", "#eb6834", "#1baf7a", "#c3c2b7"
|
| 28 |
FONT = "system-ui, -apple-system, Segoe UI, sans-serif"
|
| 29 |
|
| 30 |
+
FRAMES = RUN["frames"] # index 0 = observed last input month, 1..12 = forecast leads
|
| 31 |
WEEKS = RUN["weeks"]
|
| 32 |
MONTHS = pd.to_datetime(SST.time.values)
|
| 33 |
OBS_MONTH = pd.Timestamp(SST.obs_time.values[-1])
|
| 34 |
+
HIST = pd.to_datetime(SST.hist_time.values) # last 12 observed months, ending with OBS_MONTH
|
| 35 |
+
NINO = SST.nino34_9120.values.astype(float)
|
| 36 |
+
NINO_HIST = SST.nino34_history_9120.values.astype(float)
|
| 37 |
|
| 38 |
# lat/lon boxes on the 0..360 grid; DMI is west minus east
|
| 39 |
REGIONS = {
|
|
|
|
| 50 |
"Mediterranean (30°N–46°N, 0°–36°E)": [(30, 46, 0, 36)],
|
| 51 |
"Global ocean (60°S–60°N)": [(-60, 60, 0, 360)],
|
| 52 |
}
|
| 53 |
+
FIELDS = {"SST anomaly": "ssta", "SST": "sst", "Chance of a top-quintile month": "pupper", "Forecast uncertainty (σ)": "sigma"}
|
| 54 |
+
HELDOUT_TRACKS = {
|
| 55 |
+
"12 common start months, Jul 2023–Jun 2024 (8 models)": "all_available_models",
|
| 56 |
+
"25 common start months, Jul 2023–Jul 2025 (7 models)": "longer_common_period",
|
| 57 |
+
"34 fixed target months, Dec 2023–Sep 2026 (own models only)": "own_fixed_target_months",
|
| 58 |
+
}
|
| 59 |
+
HELDOUT_REGIONS = {"Global": "Global", "Niño 3.4": "Nino3.4", "Tropical Indo-Pacific (20°S–20°N)": "WP",
|
| 60 |
+
"North Pacific (20°N–70°N)": "PDO", "Arctic (70°N–90°N)": "AO", "South Indian (60°S–40°S)": "SIO",
|
| 61 |
+
"South Pacific (60°S–40°S)": "SPO", "South Atlantic (60°S–40°S)": "SAO"}
|
| 62 |
+
HELDOUT_LABELS = {"Cutoff-2022 model": "CAS-Canglong method, refit with data to 2022",
|
| 63 |
+
"Seasonal baseline": "frozen seasonal model (before calibration)",
|
| 64 |
+
"Original baseline": "original head", "Surface baseline": "surface head", "Joint baseline": "joint head"}
|
| 65 |
|
| 66 |
|
| 67 |
def box_mean(field: xr.DataArray, boxes) -> np.ndarray:
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def region_series(name: str) -> np.ndarray:
|
| 77 |
+
"""12 observed months (ending with the last input month) followed by the 12 forecast months, 1991-2020 base."""
|
| 78 |
+
fields = xr.concat([SST.history_ssta_9120.rename(hist_time="t"), SST.ssta_9120.rename(time="t")], "t")
|
| 79 |
spec = REGIONS[name]
|
| 80 |
if spec == "dmi":
|
| 81 |
return box_mean(fields, [(-10, 10, 50, 70)]) - box_mean(fields, [(-10, 0, 90, 110)])
|
|
|
|
| 108 |
|
| 109 |
def plume_figure() -> go.Figure:
|
| 110 |
fig = go.Figure()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
for k, m in enumerate(IRI["models"]):
|
| 112 |
x = [_center(s) for s in m["seasons"]]
|
|
|
|
| 113 |
fig.add_trace(go.Scatter(x=x, y=m["data"], mode="lines", line=dict(color=CONTEXT, width=1),
|
| 114 |
name="IRI plume models", legendgroup="iri", showlegend=k == 0, opacity=0.9,
|
| 115 |
hovertemplate=f"{m['model']} ({m['type'].lower()}): %{{y:+.2f}} °C<extra></extra>"))
|
|
|
|
| 118 |
if avg["model"] not in colors: continue
|
| 119 |
x = [_center(s) for s in avg["seasons"]]
|
| 120 |
name = f"IRI {avg['model']} mean"
|
| 121 |
+
fig.add_trace(go.Scatter(x=x, y=avg["data"], mode="lines+markers", name=name,
|
|
|
|
| 122 |
line=dict(color=colors[avg["model"]], width=2), marker=dict(size=8, line=dict(color=SURFACE, width=2)),
|
| 123 |
hovertemplate=f"{name}: %{{y:+.2f}} °C<extra></extra>"))
|
| 124 |
+
fig.add_trace(go.Scatter(x=HIST, y=NINO_HIST, mode="lines", name="ERA5 observed (monthly)",
|
| 125 |
+
line=dict(color=INK, width=1.5), hovertemplate="ERA5 %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 126 |
+
run = pd.Series(NINO).rolling(3, center=True).mean().values[1:-1] # forecast-only seasons, OND .. JAS
|
| 127 |
xs = MONTHS[1:-1]
|
|
|
|
|
|
|
| 128 |
fig.add_trace(go.Scatter(x=xs, y=run, mode="lines+markers", name="CAS-Canglong (3-month mean)",
|
|
|
|
| 129 |
line=dict(color=CAS_BLUE, width=3), marker=dict(size=8, line=dict(color=SURFACE, width=2)),
|
| 130 |
hovertemplate="CAS-Canglong: %{y:+.2f} °C<extra></extra>"))
|
| 131 |
fig.add_annotation(x=xs[-1], y=run[-1], text="CAS-Canglong", xanchor="left", xshift=8, showarrow=False,
|
| 132 |
font=dict(color=INK, size=12))
|
| 133 |
+
obs = [(_center(o["season"]), o["data"], o["label"]) for o in IRI["observed"]]
|
| 134 |
+
fig.add_trace(go.Scatter(x=[o[0] for o in obs], y=[o[1] for o in obs], mode="markers", name="Observed (IRI)",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
marker=dict(color=INK, size=9, symbol="diamond", line=dict(color=SURFACE, width=2)),
|
| 136 |
text=[o[2] for o in obs], hovertemplate="%{text}: %{y:+.2f} °C<extra></extra>"))
|
| 137 |
for y, label in ((0.5, "El Niño +0.5 °C"), (-0.5, "La Niña −0.5 °C")):
|
|
|
|
| 139 |
fig.add_annotation(x=0, xref="paper", y=y, text=label, xanchor="left", yanchor="bottom", xshift=4,
|
| 140 |
showarrow=False, font=dict(color=MUTED, size=11))
|
| 141 |
fig.update_xaxes(dtick="M2", tickformat="%b<br>%Y", tickangle=0)
|
| 142 |
+
return _layout(fig, "Niño 3.4 SST anomaly (°C, 1991–2020 base)", 460)
|
| 143 |
|
| 144 |
|
| 145 |
def region_figure(name: str) -> go.Figure:
|
| 146 |
y = region_series(name)
|
| 147 |
+
x = [*HIST, *MONTHS]
|
| 148 |
+
n = len(HIST)
|
| 149 |
fig = go.Figure()
|
| 150 |
+
fig.add_trace(go.Scatter(x=x[:n], y=y[:n], mode="lines+markers", name="ERA5 observed",
|
| 151 |
+
line=dict(color=INK, width=1.5), marker=dict(size=6),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
hovertemplate="observed %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 153 |
+
fig.add_trace(go.Scatter(x=x[n - 1:n + 1], y=y[n - 1:n + 1], mode="lines", line=dict(color=CAS_BLUE, width=2, dash="dot"),
|
| 154 |
+
showlegend=False, hoverinfo="skip"))
|
| 155 |
+
fig.add_trace(go.Scatter(x=x[n:], y=y[n:], mode="lines+markers", name="CAS-Canglong forecast",
|
| 156 |
+
line=dict(color=CAS_BLUE, width=2), marker=dict(size=8, line=dict(color=SURFACE, width=2)),
|
| 157 |
+
hovertemplate="%{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 158 |
if name.startswith("Niño"):
|
| 159 |
for v in (0.5, -0.5):
|
| 160 |
fig.add_hline(y=v, line=dict(color=MUTED, width=1, dash="dot"))
|
| 161 |
fig.update_xaxes(dtick="M2", tickformat="%b<br>%Y", tickangle=0)
|
| 162 |
+
return _layout(fig, "SST anomaly (°C, 1991–2020 base)", 400)
|
| 163 |
|
| 164 |
|
| 165 |
def region_table(name: str) -> pd.DataFrame:
|
| 166 |
+
y = region_series(name)[len(HIST) - 1:]
|
| 167 |
labels = [f"{OBS_MONTH:%b %Y} (observed)"] + [f"{t:%b %Y}" for t in MONTHS]
|
| 168 |
return pd.DataFrame({"Month": labels, "Lead (months)": ["0"] + [str(i) for i in range(1, len(MONTHS) + 1)],
|
| 169 |
"SST anomaly (°C)": [f"{v:+.2f}" for v in y]})
|
| 170 |
|
| 171 |
|
| 172 |
def map_path(field: str, index: int) -> str:
|
| 173 |
+
code, i = FIELDS[field], int(index)
|
| 174 |
+
if code in ("pupper", "sigma"):
|
| 175 |
+
i = max(i, 1) # probability and spread exist for forecast months only
|
| 176 |
+
return str(MAPS / f"{code}_{FRAMES[i]['key']}.png")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def frame_label(field: str, index: int) -> str:
|
| 180 |
+
i = int(index)
|
| 181 |
+
if FIELDS[field] in ("pupper", "sigma") and i == 0:
|
| 182 |
+
return f"**{FRAMES[1]['label']}** — probability and spread maps start at lead 1"
|
| 183 |
+
return f"**{FRAMES[i]['label']}**"
|
| 184 |
|
| 185 |
|
| 186 |
def atmos_path(var: str, week: int) -> str:
|
|
|
|
| 189 |
return str(MAPS / f"atm_{code}_w{int(week)}.png")
|
| 190 |
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
def week_label(week: int) -> str:
|
| 193 |
return f"**{WEEKS[int(week) - 1]['label']}**"
|
| 194 |
|
| 195 |
|
| 196 |
+
def heldout_figure(track: str, region: str, metric: str) -> go.Figure:
|
| 197 |
+
col = "acc_spatial" if metric.startswith("Spatial ACC") else "rmse_K"
|
| 198 |
+
d = HELDOUT[(HELDOUT.track == HELDOUT_TRACKS[track]) & (HELDOUT.region == HELDOUT_REGIONS[region])]
|
| 199 |
+
fig = go.Figure()
|
| 200 |
+
greys = iter(["#898781", "#a9a79f", "#c3c2b7", "#d9d8d2"])
|
| 201 |
+
nwp = iter([DYN_ORANGE, "#b8421b", "#f3a5a1"])
|
| 202 |
+
for model, g in sorted(d.groupby("model"), key=lambda kv: (kv[0] != "Cutoff-2022 model", "baseline" not in kv[0], kv[0])):
|
| 203 |
+
g = g.sort_values("lead")
|
| 204 |
+
main = model == "Cutoff-2022 model"
|
| 205 |
+
color = CAS_BLUE if main else next(greys) if "baseline" in model else next(nwp)
|
| 206 |
+
fig.add_trace(go.Scatter(x=g.lead, y=g[col], mode="lines+markers", name=HELDOUT_LABELS.get(model, f"{model} (raw archive)"),
|
| 207 |
+
line=dict(color=color, width=3 if main else 1.6, dash=None if main or "baseline" in model else "dash"),
|
| 208 |
+
marker=dict(size=7 if main else 5),
|
| 209 |
+
hovertemplate=f"{model} · lead %{{x}}: %{{y:.3f}}<extra></extra>"))
|
| 210 |
+
fig.update_xaxes(title="Lead (months)", dtick=1)
|
| 211 |
+
fig = _layout(fig, "Spatial ACC" if col == "acc_spatial" else "RMSE (°C)", 480)
|
| 212 |
+
fig.update_layout(hovermode="closest", margin=dict(r=40, b=110), legend=dict(y=-0.2))
|
| 213 |
+
return fig
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def heldout_table(track: str, region: str) -> pd.DataFrame:
|
| 217 |
+
d = HELDOUT[(HELDOUT.track == HELDOUT_TRACKS[track]) & (HELDOUT.region == HELDOUT_REGIONS[region])]
|
| 218 |
+
t = d.groupby("model").agg(acc=("acc_spatial", "mean"), rmse=("rmse_K", "mean"), months=("n_months", "first"),
|
| 219 |
+
first=("first_valid", "min"), last=("last_valid", "max")).sort_values("acc", ascending=False)
|
| 220 |
+
return pd.DataFrame({"Model": [HELDOUT_LABELS.get(m, f"{m} (raw archive)") for m in t.index],
|
| 221 |
+
"Mean spatial ACC (leads 1–12)": t.acc.round(3).values,
|
| 222 |
+
"Mean RMSE °C (leads 1–12)": t.rmse.round(3).values, "Samples per lead": t.months.values,
|
| 223 |
+
"Target months": [f"{a} – {b}" for a, b in zip(t["first"], t["last"])]})
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def hindcast_figure(lead: int) -> go.Figure:
|
| 227 |
+
fig = go.Figure()
|
| 228 |
+
fig.add_trace(go.Scatter(x=HINDCAST.target_month, y=HINDCAST.obs, mode="lines", name="ERA5 observed",
|
| 229 |
+
line=dict(color=INK, width=2), hovertemplate="observed %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 230 |
+
fig.add_trace(go.Scatter(x=HINDCAST.target_month, y=HINDCAST[f"lead{int(lead)}"], mode="lines",
|
| 231 |
+
name=f"CAS-Canglong hindcast, lead {int(lead)}", line=dict(color=CAS_BLUE, width=2),
|
| 232 |
+
hovertemplate="hindcast %{x|%b %Y}: %{y:+.2f} °C<extra></extra>"))
|
| 233 |
+
for v in (0.5, -0.5):
|
| 234 |
+
fig.add_hline(y=v, line=dict(color=MUTED, width=1, dash="dot"))
|
| 235 |
+
fig.update_xaxes(dtick="M6", tickformat="%b<br>%Y", tickangle=0)
|
| 236 |
+
return _layout(fig, "Niño 3.4 anomaly (°C, 1959–2009 base)", 400)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def hindcast_caption(lead: int) -> str:
|
| 240 |
+
r = HINDCAST_SKILL[HINDCAST_SKILL.lead == int(lead)].iloc[0]
|
| 241 |
+
return (f"Lead {int(lead)}: correlation **{r['corr']:.2f}**, RMSE **{r['rmse_K']:.2f} °C** over {int(r['n'])} target months "
|
| 242 |
+
"(2016–2022). These targets were used to calibrate the released model, so the scores are in-sample.")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
above = [t for t, v in zip(MONTHS, NINO) if v >= 0.5]
|
| 246 |
glob = region_series("Global ocean (60°S–60°N)")
|
| 247 |
KPIS = [
|
| 248 |
+
(f"Niño 3.4 · {OBS_MONTH:%b %Y}", f"{NINO_HIST[-1]:+.2f} °C", "observed (ERA5), last input month"),
|
| 249 |
+
(f"Niño 3.4 · {MONTHS[0]:%b %Y}", f"{NINO[0]:+.2f} °C", "forecast, 1-month lead"),
|
| 250 |
+
(f"Niño 3.4 · {MONTHS[3]:%b %Y}", f"{NINO[3]:+.2f} °C", "forecast, 4-month lead"),
|
| 251 |
+
("El Niño (≥ +0.5 °C)", f"{above[0]:%b %Y} – {above[-1]:%b %Y}" if above else "–", f"{len(above)} of {len(NINO)} forecast months"),
|
| 252 |
+
("Global ocean 60°S–60°N", f"{glob[len(HIST)]:+.2f} °C", f"area-mean anomaly, {MONTHS[0]:%b %Y}"),
|
| 253 |
]
|
| 254 |
kpi_html = "".join(f'<div class="kpi"><div class="kpi-label">{a}</div><div class="kpi-value">{b}</div>'
|
| 255 |
f'<div class="kpi-note">{c}</div></div>' for a, b, c in KPIS)
|
| 256 |
|
| 257 |
HEADER = f"""
|
| 258 |
<div class="hero">
|
| 259 |
+
<div class="hero-kicker">CAS-Canglong · research forecast · model release 2026-10-25</div>
|
| 260 |
<h1>🐉 Global sea-surface temperature & ENSO outlook</h1>
|
| 261 |
+
<p>Input: ERA5 monthly means through <b>{OBS_MONTH:%B %Y}</b> → mean, spread and calibrated quintile probabilities for
|
| 262 |
+
<b>{MONTHS[0]:%b %Y} – {MONTHS[-1]:%b %Y}</b> (12 months) · six-week atmosphere from <b>{RUN['atmos_start']}</b></p>
|
| 263 |
<div class="chips">
|
| 264 |
+
<a href="https://huggingface.co/{MODEL_REPO}" target="_blank">🧠 Model weights</a>
|
| 265 |
+
<a href="https://huggingface.co/datasets/{HINDCAST_REPO}" target="_blank">🗂️ Hindcast 2016–2022</a>
|
| 266 |
<a href="https://doi.org/10.1002/advs.78271" target="_blank">📄 Paper · Advanced Science</a>
|
| 267 |
<a href="https://github.com/GISWLH/CAS-Canglong" target="_blank">⭐ GitHub</a>
|
| 268 |
<a href="https://canglong.earth" target="_blank">🌍 canglong.earth</a>
|
|
|
|
| 269 |
<a href="https://github.com/GISWLH/WeatherPre" target="_blank">📊 WeatherPre</a>
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
<div class="kpis">{kpi_html}</div>
|
| 273 |
"""
|
| 274 |
|
| 275 |
+
HELDOUT_NOTE = """
|
| 276 |
+
**What was held out.** These curves come from a copy of the released method that was re-fitted with targets up to
|
| 277 |
+
December 2022 only (calibration settings chosen on 2021–2022) and then scored on 2023-01 to 2026-09 targets it never saw.
|
| 278 |
+
The four base heads were trained on targets up to August 2009 and validated up to April 2016. The published weights themselves were calibrated with
|
| 279 |
+
targets through September 2026, so they have no held-out score of their own. The method design was shaped by earlier
|
| 280 |
+
looks at the post-2022 period, so this is a retrospective out-of-time check, not a pristine blind test.
|
| 281 |
+
Spatial ACC is the area-weighted anomaly correlation of each map (not the grid-point temporal correlation). The three
|
| 282 |
+
dynamical systems are raw ensemble-mean archives with no drift or bias correction, which inflates their RMSE. 12–34
|
| 283 |
+
monthly samples per lead are serially correlated; no significance test was made. **Niño 3.4 is the weak spot:** there the
|
| 284 |
+
refit's ACC is below that of its own uncalibrated seasonal model at every lead.
|
| 285 |
+
"""
|
| 286 |
+
|
| 287 |
ABOUT = f"""
|
| 288 |
### What this is
|
| 289 |
+
CAS-Canglong learns the coupled ocean–atmosphere system from ERA5 to predict global sea-surface temperature (SST)
|
| 290 |
+
([Wang et al., *Advanced Science* 2026](https://doi.org/10.1002/advs.78271); preprint
|
| 291 |
+
[arXiv:2409.05369](https://arxiv.org/abs/2409.05369)). This Space shows the **2026-10-25 release of the 1° monthly SST
|
| 292 |
+
model** ([weights and code on Hugging Face](https://huggingface.co/{MODEL_REPO})):
|
| 293 |
+
|
| 294 |
+
* **Inputs** — the last 120 months of eight ERA5 monthly fields on a 1° grid: SST, mean sea-level pressure, surface latent
|
| 295 |
+
heat flux, 10 m u and v wind, 500 and 850 hPa geopotential, net surface solar radiation.
|
| 296 |
+
* **Core** — four frozen heads built on a 1° CAS-Canglong network: the original network/EOF blend, a slow-background
|
| 297 |
+
branch, a joint ocean–atmosphere branch and a seasonal-state branch whose response depends on the calendar month.
|
| 298 |
+
* **Calibration** — a per-grid-cell ridge correction (α = 0.05, 19 coefficients per lead and cell, using the four head
|
| 299 |
+
forecasts, the observed SST anomaly of the last 1, 3 and 12 months, annual/semi-annual harmonics, a time trend and a
|
| 300 |
+
few seasonal interactions),
|
| 301 |
+
mixed 10 % with 90 % of the seasonal model; a CRPS-fitted Gaussian spread; and quintile probabilities calibrated in the
|
| 302 |
+
spirit of the probability-based correction of [Guan et al., *Nature* 2026](https://www.nature.com/articles/s41586-026-11126-x).
|
| 303 |
+
* **Output** — 12 monthly leads on 40,701 ocean cells (nominal 1°): mean SST, Gaussian σ and quantiles, and the
|
| 304 |
+
probability of each fifth of the 1959–2009 climate. Leads 13–16 are not produced.
|
| 305 |
* **Atmosphere (6 weeks, experimental)** — CAS-Canglong V3.5 weekly means published on canglong.earth, read through
|
| 306 |
[WeatherPre](https://github.com/GISWLH/WeatherPre) (`wp.forecast("canglong", "t2m", "6w")`).
|
| 307 |
|
| 308 |
### How to read the maps
|
| 309 |
+
* **SST anomaly** maps and all regional and Niño 3.4 series here use the WMO 1991–2020 normal, computed from the same
|
| 310 |
+
ERA5 record as the inputs, so they can be compared with the [IRI ENSO plume]({IRI['source']}) (published
|
| 311 |
+
{IRI['published']}, redrawn). The model's own reference is 1959-01–2009-08; on that base Niño 3.4 is 0.04–0.23 °C higher,
|
| 312 |
+
depending on the month. Both are in the NetCDF.
|
| 313 |
+
* **Chance of a top-quintile month** is the calibrated probability that the monthly SST falls in the warmest fifth of the
|
| 314 |
+
1959–2009 climate for that calendar month (20 % = climatological odds). Because the ocean has warmed since then, high
|
| 315 |
+
values are widespread.
|
| 316 |
+
* **Forecast uncertainty (σ)** is a per-cell Gaussian spread. The probabilities are marginal per grid cell, not a coherent
|
| 317 |
+
ensemble, so they cannot be summed into regional or Niño 3.4 uncertainties.
|
| 318 |
|
| 319 |
### Limits — please read
|
| 320 |
+
* A research forecast, not an official or operational warning product.
|
| 321 |
+
* The released weights were calibrated with targets through 2026-09, and the calibration settings were selected on
|
| 322 |
+
year-blocked diagnostics of the same years. Every historical score of these exact weights, including the published
|
| 323 |
+
2016–2022 hindcast, is therefore in-sample. The *Skill & hindcasts* tab shows the held-out check of a refit copy.
|
| 324 |
+
* **ENSO:** the forecast damps the 2026 El Niño quickly. Niño 3.4 drops from {NINO_HIST[-1]:+.2f} °C observed in
|
| 325 |
+
{OBS_MONTH:%B} to {NINO[0]:+.2f} °C in {MONTHS[0]:%B}, well below the IRI dynamical models. The damping is already in
|
| 326 |
+
the four frozen base heads (lead 1: +1.5 to +1.8 °C on the model base), and Niño 3.4 is the region where the held-out
|
| 327 |
+
check found this method weakest. Read the Niño 3.4 outlook with that in mind. canglong.earth still serves the previous
|
| 328 |
+
16-month Public-Swin run, whose Niño 3.4 stays at +3.2 °C or more through December 2026 (on its 1959–2016 base).
|
| 329 |
+
* The six-week atmospheric run is **not drift-corrected**; high-latitude 2 m temperature drifts warm with lead time
|
| 330 |
+
(about +5 °C in week 1 to +14 °C in week 6 poleward of 60°N in earlier runs). Read those fields as model output; anomaly
|
| 331 |
+
maps are not shown.
|
| 332 |
|
| 333 |
### Provenance of this run
|
| 334 |
+
* Forecast file: `{RUN['sst_file']}`, derived from the release NetCDF (SHA-256 `{(RUN['source_forecast_sha256'] or '')[:16]}…`)
|
| 335 |
+
without changing any value. Checks: SST − anomaly reproduces the stored climatology to
|
| 336 |
+
{RUN['checks']['forecast_sst_minus_ssta_vs_climatology_max_abs_K']:.1e} K, and the observed {OBS_MONTH:%B} anomaly
|
| 337 |
+
equals the model's own input context (max |Δ| = {RUN['checks']['observed_ssta_vs_model_context_max_abs_K']} K).
|
|
|
|
| 338 |
* Atmosphere: {RUN['atmos_source']}.
|
| 339 |
|
| 340 |
### Cite
|
|
|
|
| 360 |
.hero .chips a {text-decoration: none !important; padding: 5px 12px; border-radius: 999px; font-size: .9rem;
|
| 361 |
border: 1px solid var(--border-color-primary); color: var(--body-text-color) !important; background: var(--block-background-fill);}
|
| 362 |
.hero .chips a:hover {border-color: var(--color-accent);}
|
| 363 |
+
.kpis {display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 14px 0 4px;}
|
| 364 |
.kpi {padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border-color-primary); background: var(--block-background-fill);}
|
| 365 |
.kpi-label {font-size: .8rem; color: var(--body-text-color-subdued);}
|
| 366 |
.kpi-value {font-size: 1.55rem; font-weight: 650; margin: 2px 0;}
|
|
|
|
| 372 |
with gr.Tabs():
|
| 373 |
with gr.Tab("🌊 Ocean & ENSO"):
|
| 374 |
with gr.Row(equal_height=True):
|
| 375 |
+
field = gr.Radio(list(FIELDS), value="SST anomaly", label="Field", scale=2)
|
| 376 |
lead = gr.Slider(0, len(FRAMES) - 1, value=1, step=1, label="Month (0 = last observed input month)", scale=3)
|
| 377 |
+
caption = gr.Markdown(frame_label("SST anomaly", 1))
|
| 378 |
image = gr.Image(map_path("SST anomaly", 1), show_label=False, interactive=False, buttons=["download", "fullscreen"])
|
| 379 |
gr.Markdown("#### Niño 3.4 outlook vs the IRI ENSO plume")
|
| 380 |
gr.Plot(plume_figure(), show_label=False)
|
| 381 |
+
gr.Markdown("Monthly CAS-Canglong Niño 3.4 (°C, 1991–2020 base): " + ", ".join(
|
| 382 |
+
f"{t:%b %Y} {v:+.2f}" for t, v in zip(MONTHS, NINO)) + ". The forecast damps the observed El Niño "
|
| 383 |
+
"much faster than the IRI dynamical models; see *About* and *Skill & hindcasts*.")
|
| 384 |
+
lead.change(lambda f, i: (map_path(f, i), frame_label(f, i)), [field, lead], [image, caption])
|
| 385 |
+
field.change(lambda f, i: (map_path(f, i), frame_label(f, i)), [field, lead], [image, caption])
|
|
|
|
|
|
|
| 386 |
with gr.Tab("📍 Regional outlook"):
|
| 387 |
region = gr.Dropdown(list(REGIONS), value=list(REGIONS)[0], label="Region")
|
| 388 |
rplot = gr.Plot(region_figure(list(REGIONS)[0]), show_label=False)
|
| 389 |
+
gr.Markdown("Box means of the native 1° anomaly field (cos-latitude weighted, model ocean cells only), "
|
| 390 |
+
"relative to ERA5 1991–2020, with the last 12 observed months for context.")
|
| 391 |
rtable = gr.Dataframe(region_table(list(REGIONS)[0]), interactive=False)
|
| 392 |
region.change(lambda r: (region_figure(r), region_table(r)), region, [rplot, rtable])
|
| 393 |
+
with gr.Tab("📈 Skill & hindcasts"):
|
| 394 |
+
gr.Markdown("#### Held-out test on 2023–2026")
|
| 395 |
+
with gr.Row():
|
| 396 |
+
htrack = gr.Dropdown(list(HELDOUT_TRACKS), value=list(HELDOUT_TRACKS)[0], label="Comparison", scale=3)
|
| 397 |
+
hregion = gr.Dropdown(list(HELDOUT_REGIONS), value="Global", label="Region", scale=2)
|
| 398 |
+
hmetric = gr.Radio(["Spatial ACC (higher is better)", "RMSE (lower is better)"],
|
| 399 |
+
value="Spatial ACC (higher is better)", label="Metric", scale=2)
|
| 400 |
+
hplot = gr.Plot(heldout_figure(list(HELDOUT_TRACKS)[0], "Global", "Spatial ACC"), show_label=False)
|
| 401 |
+
htable = gr.Dataframe(heldout_table(list(HELDOUT_TRACKS)[0], "Global"), interactive=False)
|
| 402 |
+
gr.Markdown(HELDOUT_NOTE)
|
| 403 |
+
for c in (htrack, hregion, hmetric):
|
| 404 |
+
c.change(lambda t, r, m: (heldout_figure(t, r, m), heldout_table(t, r)), [htrack, hregion, hmetric], [hplot, htable])
|
| 405 |
+
gr.Markdown(f"#### Hindcast 2016–2022 · [dataset](https://huggingface.co/datasets/{HINDCAST_REPO})")
|
| 406 |
+
hlead = gr.Slider(1, 12, value=3, step=1, label="Lead (months)")
|
| 407 |
+
hcplot = gr.Plot(hindcast_figure(3), show_label=False)
|
| 408 |
+
hccap = gr.Markdown(hindcast_caption(3))
|
| 409 |
+
hlead.change(lambda k: (hindcast_figure(k), hindcast_caption(k)), hlead, [hcplot, hccap])
|
| 410 |
+
gr.Dataframe(HINDCAST_SKILL.rename(columns={"lead": "Lead", "n": "Months", "corr": "Correlation", "rmse_K": "RMSE (°C)"}),
|
| 411 |
+
interactive=False, label="Niño 3.4 hindcast scores by lead (in-sample)")
|
| 412 |
with gr.Tab("🌦️ Atmosphere · 6 weeks (experimental)"):
|
| 413 |
gr.Markdown("Weekly-mean fields of the CAS-Canglong V3.5 atmospheric run published on canglong.earth. "
|
| 414 |
"**Not drift-corrected** — high-latitude temperatures drift warm with lead time (see *About*).")
|
|
|
|
| 420 |
aimg = gr.Image(atmos_path("2 m temperature", 1), show_label=False, interactive=False, buttons=["download", "fullscreen"])
|
| 421 |
week.change(lambda v, w: (atmos_path(v, w), week_label(w)), [avar, week], [aimg, wcap])
|
| 422 |
avar.change(lambda v, w: atmos_path(v, w), [avar, week], aimg)
|
| 423 |
+
with gr.Tab("📦 Model & data"):
|
| 424 |
+
gr.Markdown(f"* **Model** — frozen weights, calibration states, inference code and a CPU example: "
|
| 425 |
+
f"[{MODEL_REPO}](https://huggingface.co/{MODEL_REPO})\n"
|
| 426 |
+
f"* **Hindcast** — observed and lead 1–12 SST for every target month of 2016–2022: "
|
| 427 |
+
f"[{HINDCAST_REPO}](https://huggingface.co/datasets/{HINDCAST_REPO})\n"
|
| 428 |
+
"* **This run** — the files behind every map and chart on this page:")
|
| 429 |
+
gr.File([str(DATA / RUN["sst_file"]), str(DATA / "nino34.csv"), str(DATA / RUN["atmos_file"]),
|
| 430 |
+
str(DATA / "hindcast_nino34.csv"), str(DATA / "round6_heldout_metrics.csv"), str(DATA / "run.json")],
|
| 431 |
file_count="multiple", label="Downloads", interactive=False)
|
| 432 |
gr.Code(f"""import xarray as xr
|
| 433 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 434 |
+
|
| 435 |
+
# this run: mean, anomaly (both bases), sigma, 10/90 % quantiles, quintile probabilities, Nino 3.4
|
| 436 |
+
ds = xr.open_dataset(hf_hub_download("LonghaoWang/CAS-Canglong", "data/{RUN['sst_file']}", repo_type="space"))
|
| 437 |
+
print(ds.nino34_9120.to_series().round(2))
|
| 438 |
+
|
| 439 |
+
# the model: verify the frozen package and replay the October 2026 forecast on a CPU
|
| 440 |
+
pkg = snapshot_download("{MODEL_REPO}", allow_patterns=["frozen_model_20261025_v1/*"])
|
| 441 |
+
# cd <pkg>/frozen_model_20261025_v1 && python -B run_frozen.py --verify-only
|
| 442 |
+
# python -B run_frozen.py --prepared-input examples/input_only_202610.npz --output /tmp/forecast_202610.nc
|
| 443 |
+
|
| 444 |
+
# the 2016-2022 hindcast (obs + lead1..lead12 per target month)
|
| 445 |
+
hc = xr.open_dataset(hf_hub_download("{HINDCAST_REPO}",
|
| 446 |
+
"hindcast_2016_2022_v1/sst_hindcast_201601_202212_lead01_12.nc", repo_type="dataset"))""",
|
| 447 |
+
language="python", label="Load in Python")
|
| 448 |
with gr.Tab("ℹ️ About"):
|
| 449 |
gr.Markdown(ABOUT)
|
| 450 |
|
assets/maps/atm_msl_w1.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_msl_w2.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_msl_w3.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_msl_w4.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_msl_w5.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_msl_w6.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w1.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w2.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w3.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w4.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w5.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_t2m_w6.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w1.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w2.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w3.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w4.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w5.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_tp_w6.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w1.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w2.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w3.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w4.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w5.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/atm_z500_w6.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/maps/{sst_t13.png → pupper_t01.png}
RENAMED
|
File without changes
|
assets/maps/{sst_t14.png → pupper_t02.png}
RENAMED
|
File without changes
|
assets/maps/{sst_t15.png → pupper_t03.png}
RENAMED
|
File without changes
|
assets/maps/{sst_t16.png → pupper_t04.png}
RENAMED
|
File without changes
|
assets/maps/pupper_t05.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t06.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t07.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t08.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t09.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t10.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t11.png
ADDED
|
Git LFS Details
|
assets/maps/pupper_t12.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t01.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t02.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t03.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t04.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t05.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t06.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t07.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t08.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t09.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t10.png
ADDED
|
Git LFS Details
|
assets/maps/sigma_t11.png
ADDED
|
Git LFS Details
|