Endpoints
| Path | Contents |
|---|---|
scores/index.json | the index of the sharded scores export: the shard of every station, the column list, and which stations, models, variables, windows and leads exist. scores/latest.json serves the same document — before methodology v0.3 it was the whole table in one 21 MB file. |
/station/{station}/cards.json | the per-station shard: every published aggregate for that station under scores, compact {columns, rows} encoding with station_id lifted into the envelope, plus one entry in cards per model and lead day. scores/by-station/{station}.json resolves to a pointer at this file. |
scores/leaderboard.json | the station_id=ALL slice only. |
leaderboard/{window}-{init}z-{method}-{variable}.json | one pre-built file per site view, ranked, with a permalink per row. |
/station/{station}/cards.json#{model}-{lead} | one permanent-link card inside that bundle: its pairwise comparisons and its daily error series. The card's scores are the rows of scores whose model_id and lead_day match; they are published once per station rather than once per card. |
pairwise/latest.json | paired model-vs-model MAE differences (station_id=ALL). |
stations.json | station metadata. |
models.json | model registry incl. the persistence baseline. |
status.json | pipeline completeness report. |
diagnostics.json | the diurnal-structure diagnostic behind /diagnostics/: per-model bias at each of the four synoptic instants, bias against lead day, and the sampling penalty bias(*_cli) − bias(*_s), over the 90-day station_id=ALL slice. Published without attribution to any cause. |
openapi.json | OpenAPI 3.1 description of the above. |
curl
# the front-page ranking, straight to a table
curl -s https://castcheck.zifanzhang.com/api/v1/leaderboard/90d-00z-bilinear-t2.json \
| jq -r '.results[] | [.rank, .model_id, .mae, .bias, .n] | @tsv'
# one permanent-link card: cards is a list, each entry carrying id = "{model}-{lead}"
curl -s https://castcheck.zifanzhang.com/station/KNYC/cards.json \
| jq '.cards[] | select(.id == "gfs-1") | .pairwise.columns'
# is anything missing today?
curl -s https://castcheck.zifanzhang.com/api/v1/status.json | jq '.ok, .n_current_gaps' Python
No key, no rate limit on our side. One platform caveat: Cloudflare's edge rejects the default Python-urllib User-Agent everywhere on the internet it fronts (error 1010) — that is not a CastCheck setting and applies to any Cloudflare-served site. requests, browsers, curl and an empty User-Agent all pass; for urllib/pandas, set any User-Agent string, as the examples below do.
import urllib.request, json
import pandas as pd
BASE = "https://castcheck.zifanzhang.com/api/v1"
# The CDN in front of the site rejects the default Python user agent, so name your client.
UA = {"User-Agent": "castcheck-example/1.0"}
def read(url):
with urllib.request.urlopen(urllib.request.Request(url, headers=UA)) as r:
return json.load(r)
def table(payload, block=None):
"""A compact {columns, rows} document (or one block of it) as a DataFrame."""
t = payload if block is None else payload[block]
df = pd.DataFrame(t["rows"], columns=t["columns"])
df.attrs.update({k: payload[k] for k in
("generated_at", "data_through", "units", "method", "truth")})
return df
# the scores table is sharded: one bundle per station, listed in scores/index.json.
# Its label columns are dictionary-encoded, so decode them once:
bundle = read("https://castcheck.zifanzhang.com/station/ALL/cards.json")
scores = table(bundle, "scores")
for col, values in bundle["scores"].get("dictionaries", {}).items():
scores[col] = scores[col].map(dict(enumerate(values)))
best = (scores.query("window == '90d' and init_hour == 0 and method == 'bilinear' "
"and variable == 't2' and lead_day == 1 and n >= 30")
.sort_values("mae"))
print(best[["model_id", "n", "mae", "mae_ci_low", "mae_ci_high", "bias"]])
# the per-day record for one station (°C): one row per model/init/lead/method/variable/day,
# and four rows a day for the pooled t2 (valid_hour_utc says which instant). One file per station.
errors = pd.read_csv("https://castcheck.zifanzhang.com/data/daily_errors/KNYC.csv.gz", storage_options=UA) MAE, bias, RMSE and the interval bounds are in °C in the API and the CSVs; the site displays them in °F (multiply a difference by 1.8).
Response envelope
{
"schema_version": "0.3",
"methodology_version": "0.3.1",
"castcheck_version": "0.3.3",
"generated_at": "…UTC ISO-8601…",
"data_through": "2026-10-28",
"next_update": "2026-10-29T11:00:00+00:00",
"window": {"type": "90d", "days": 90, "start": "…", "end": "…"},
"units": {"mae": "degC", "bias": "degC", "rmse": "degC", "n": "days",
"hit1f": "fraction", "skill_persistence": "fraction"},
"method": {"ci": "per-group moving-block bootstrap", "resamples": 1000, "level": 0.95,
"block": "7 days", "ci_min_n": 28, "ci_min_blocks": 4,
"proportions": "Wilson score intervals",
"ref": "https://castcheck.zifanzhang.com/methodology/"},
"truth": {"source": "observed 2 m temperature at the four synoptic instants (t2*, tmax_s, tmin_s); NWS CLI first final issuance (tmax_cli, tmin_cli)"},
"license": "CC-BY-4.0",
"columns": [...], "rows": [[...]]
} Stability
Paths under /api/v1/, the per-station bundles /station/{ICAO}/cards.json and the permanent links /station/{ICAO}/model/{model_id}/lead/{d}/ are fixed. Columns may be added within v1; a removal or a change of meaning bumps schema_version and appears in the changelog. Numbers change every day as history accumulates — always record generated_at and data_through with anything you quote.