Raw model output on the native 0.25° grid — no MOS, no bias correction, no post-processing. Scores understate operational forecast quality. Read the fairness statement →

CastCheck methodology v0.2 · data through 2026-08-30

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/. Every response carries schema_version, generated_at, data_through, window, units, method and truth; every result row can be turned into a permanent link.

Endpoints

PathContents
scores/latest.json every published aggregate, compact {columns, rows} encoding.
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.
scores/{station}/{model}/{lead}.json one permanent-link card: every window/init/method/variable, the pairwise table and the daily error series.
pairwise/latest.json paired model-vs-model MAE differences (station_id=ALL).
stations.jsonstation metadata.
models.jsonmodel registry incl. the persistence baseline.
status.jsonpipeline completeness report.
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-tmax.json \
  | jq -r '.results[] | [.rank, .model_id, .mae, .bias, .n] | @tsv'

# one permanent-link card
curl -s https://castcheck.zifanzhang.com/api/v1/scores/ALL/gfs/1.json | jq '.scores.columns'

# is anything missing today?
curl -s https://castcheck.zifanzhang.com/api/v1/status.json | jq '.ok, .n_current_gaps'

Python

import io, urllib.request, json
import pandas as pd

BASE = "https://castcheck.zifanzhang.com/api/v1"

def table(path):
    """Read a compact {columns, rows} endpoint into a DataFrame."""
    with urllib.request.urlopen(f"{BASE}/{path}") as r:
        payload = json.load(r)
    df = pd.DataFrame(payload["rows"], columns=payload["columns"])
    df.attrs.update({k: payload[k] for k in
                     ("generated_at", "data_through", "units", "method", "truth")})
    return df

scores = table("scores/latest.json")
best = (scores.query("station_id == 'ALL' and window == '90d' and init_hour == 0 "
                     "and method == 'bilinear' and variable == 'tmax' and lead_day == 1 "
                     "and n >= 30")
              .sort_values("mae"))
print(best[["model_id", "n", "mae", "mae_ci_low", "mae_ci_high", "bias"]])

# the full per-day record (°C), ~one row per station/model/init/lead/method/variable/day
errors = pd.read_csv("https://castcheck.zifanzhang.com/data/daily_errors.csv.gz")

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.1",
  "methodology_version": "0.2",
  "castcheck_version": "0.1.0",
  "generated_at": "…UTC ISO-8601…",
  "data_through": "2026-08-30",
  "next_update": "2026-08-31T11: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": "moving-block bootstrap", "resamples": 1000, "level": 0.95,
             "block": "7 days", "ref": "https://castcheck.zifanzhang.com/methodology/"},
  "truth": {"source": "NWS Daily Climate Report (CLI), first final issuance"},
  "license": "CC-BY-4.0",
  "columns": [...], "rows": [[...]]
}

Stability

Paths under /api/v1/ 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.