Reference

JSON API v1

Plain static files on a CDN — no server, no keys, no rate limit. Access-Control-Allow-Origin: * and Cache-Control: public, max-age=3600, stale-while-revalidate=86400 on everything under /api/. The per-station bundles are the one exception: they live at /station/{ICAO}/cards.json, outside /api/, and are CORS-open but revalidated on every request rather than cached for an hour. Every score response carries schema_version, generated_at, data_through, window, units, method and truth (status.json is a completeness report and carries its own fields); every result row can be turned into a permanent link.

Base
https://castcheck.zifanzhang.com/api/v1/
Schema
v0.3
Methodology
v0.3.1
Data through
2026-10-28
Next update

Endpoints

PathContents
scores/index.jsonthe 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.jsonthe 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.jsonthe station_id=ALL slice only.
leaderboard/{window}-{init}z-{method}-{variable}.jsonone 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.jsonpaired model-vs-model MAE differences (station_id=ALL).
stations.jsonstation metadata.
models.jsonmodel registry incl. the persistence baseline.
status.jsonpipeline completeness report.
diagnostics.jsonthe 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.jsonOpenAPI 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.