Spaces:
Running on Zero
Running on Zero
| """ | |
| aifs.era5_verify | |
| ================= | |
| Score an AIFS forecast (a list of state dicts from :func:`aifs.forecast.run_forecast`, | |
| initialised from a *historical* date via ``aifs.initial_conditions.load_ics(date=...)``) | |
| against EarthMover's public ERA5 reanalysis, plus a persistence baseline (ERA5 held | |
| fixed at the initial time β "assume nothing changes"). | |
| Only forecasts initialised from a past date can be scored this way: a forecast's | |
| valid times are in the future relative to when it was run, and ERA5 (reanalysis) | |
| for those times doesn't exist until well after the fact. | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import numpy as np | |
| from aifs.era5_env import fetch_era5_fields | |
| STEP_HOURS = 6 # AIFS advances in fixed 6-hour steps (see aifs.forecast) | |
| _EPOCH = datetime.datetime(1940, 1, 1) # ERA5's valid_time units: "hours since 1940-01-01" | |
| #: AIFS field name -> (ERA5 group, ERA5 variable name, pressure level or None). | |
| #: Names/groups confirmed against the live store; wave fields (swh, mwp, ...) are | |
| #: NOT included here β EarthMover's ERA5 mirror doesn't carry the wave stream. | |
| EVAL_FIELD_MAP = { | |
| "2t": ("single", "t2m", None), | |
| "10u": ("single", "u10", None), | |
| "10v": ("single", "v10", None), | |
| "100u": ("single", "u100", None), | |
| "100v": ("single", "v100", None), | |
| "msl": ("single", "msl", None), | |
| "sp": ("single", "sp", None), | |
| "tcw": ("single", "tcw", None), | |
| "t_850": ("pressure", "t", 850), | |
| "t_500": ("pressure", "t", 500), | |
| "u_850": ("pressure", "u", 850), | |
| "v_850": ("pressure", "v", 850), | |
| "z_500": ("pressure", "z", 500), | |
| "q_700": ("pressure", "q", 700), | |
| } | |
| #: ERA5's regular 0.25 degree grid (see aifs.era5_worker probing): lat descends | |
| #: 90 -> -90, lon ascends 0 -> 359.75 β same [0, 360) convention AIFS uses. | |
| _ERA5_LAT0, _ERA5_DLAT, _ERA5_NLAT = 90.0, -0.25, 721 | |
| _ERA5_LON0, _ERA5_DLON, _ERA5_NLON = 0.0, 0.25, 1440 | |
| def _time_idx(dt: datetime.datetime) -> int: | |
| return int((dt - _EPOCH).total_seconds() // 3600) | |
| def _nearest_indices(lats: np.ndarray, lons: np.ndarray) -> tuple[np.ndarray, np.ndarray]: | |
| """Map each (lat, lon) point to its nearest cell in ERA5's regular grid.""" | |
| lat_idx = np.clip(np.round((lats - _ERA5_LAT0) / _ERA5_DLAT).astype(int), 0, _ERA5_NLAT - 1) | |
| lon_idx = np.round((lons - _ERA5_LON0) / _ERA5_DLON).astype(int) % _ERA5_NLON | |
| return lat_idx, lon_idx | |
| def sample_at_points(grid: np.ndarray, lat_idx: np.ndarray, lon_idx: np.ndarray) -> np.ndarray: | |
| """Sample a (721, 1440) ERA5 grid at pre-computed nearest-neighbour indices.""" | |
| return grid[lat_idx, lon_idx] | |
| def metrics(forecast: np.ndarray, truth: np.ndarray) -> dict: | |
| """RMSE / MAE / bias / correlation between two same-shape point arrays, NaN-safe.""" | |
| mask = ~(np.isnan(forecast) | np.isnan(truth)) | |
| f, t = np.asarray(forecast)[mask], np.asarray(truth)[mask] | |
| if len(f) < 2: | |
| return {"rmse": float("nan"), "mae": float("nan"), "bias": float("nan"), "corr": float("nan"), "n": int(len(f))} | |
| diff = f - t | |
| corr = float(np.corrcoef(f, t)[0, 1]) | |
| return { | |
| "rmse": float(np.sqrt(np.mean(diff ** 2))), | |
| "mae": float(np.mean(np.abs(diff))), | |
| "bias": float(np.mean(diff)), | |
| "corr": corr, | |
| "n": int(len(f)), | |
| } | |
| def _fetch_era5_bounds(group: str, extra_requests: list[dict], log): | |
| """ | |
| Fetch a group's live ``valid_time`` coverage bounds together with | |
| ``extra_requests`` in one batched call β avoids paying icechunk's | |
| session-open cost twice. EarthMover's free ERA5 archive is NOT current | |
| to "now" (it lags real-time by several months, and how far isn't | |
| documented reliably), so callers check this live rather than trusting | |
| a hardcoded date. | |
| Returns ``(era5_start, era5_end, arrays, meta)`` β ``arrays``/``meta`` | |
| cover ALL requests: bounds are indices 0 and 1, ``extra_requests`` | |
| start at index 2. | |
| """ | |
| bounds_requests = [ | |
| {"group": group, "var": "valid_time", "level": None, "time_idx": 0}, | |
| {"group": group, "var": "valid_time", "level": None, "time_idx": -1}, | |
| ] | |
| arrays, meta = fetch_era5_fields(bounds_requests + extra_requests, log=log) | |
| if 0 in meta["errors"] or 1 in meta["errors"]: | |
| raise RuntimeError( | |
| f"Could not read ERA5's time coverage for group '{group}': " | |
| f"{meta['errors'].get(0) or meta['errors'].get(1)}" | |
| ) | |
| era5_start = _EPOCH + datetime.timedelta(hours=float(arrays[0])) | |
| era5_end = _EPOCH + datetime.timedelta(hours=float(arrays[1])) | |
| return era5_start, era5_end, arrays, meta | |
| def evaluate_forecast(states: list[dict], field_name: str, log=lambda msg: None) -> dict: | |
| """ | |
| Score every step of a historical-init forecast against ERA5, plus a | |
| persistence baseline (ERA5 held fixed at t0). | |
| Returns a dict: ``{"field", "lats", "lons", "per_step": [...]}`` β each | |
| per_step entry has ``date``, ``lead_h``, ``forecast``, ``truth`` (both | |
| sampled onto the AIFS grid), and ``model_*``/``persistence_*`` metrics. | |
| """ | |
| if not states: | |
| raise ValueError("No forecast states to evaluate β run a forecast first.") | |
| if field_name not in EVAL_FIELD_MAP: | |
| raise ValueError( | |
| f"'{field_name}' has no ERA5 counterpart configured. " | |
| f"Available: {sorted(EVAL_FIELD_MAP)}" | |
| ) | |
| group, var, level = EVAL_FIELD_MAP[field_name] | |
| lats = np.asarray(states[0]["latitudes"]).ravel() | |
| lons = np.asarray(states[0]["longitudes"]).ravel() | |
| lat_idx, lon_idx = _nearest_indices(lats, lons) | |
| t0 = states[0]["date"] - datetime.timedelta(hours=STEP_HOURS) | |
| valid_times = [t0] + [s["date"] for s in states] | |
| # De-duplicate in case any two steps land on the same valid time. | |
| unique_times = sorted(set(valid_times)) | |
| data_requests = [ | |
| {"group": group, "var": var, "level": level, "time_idx": _time_idx(t)} | |
| for t in unique_times | |
| ] | |
| log(f"π‘ Fetching ERA5 '{var}'" + (f"@{level}hPa" if level else "") + | |
| f" for {len(unique_times)} valid time(s)β¦") | |
| era5_start, era5_end, arrays, meta = _fetch_era5_bounds(group, data_requests, log) | |
| earliest_needed, latest_needed = unique_times[0], unique_times[-1] | |
| if latest_needed > era5_end or earliest_needed < era5_start: | |
| raise ValueError( | |
| f"This forecast needs ERA5 truth from {earliest_needed} to {latest_needed}, but " | |
| f"EarthMover's free ERA5 archive currently only covers {era5_start} to {era5_end}. " | |
| f"Pick an earlier historical init date β with enough room before {era5_end} to fit " | |
| f"your full lead time β and run the forecast again." | |
| ) | |
| data_errors = {i - 2: err for i, err in meta["errors"].items() if i >= 2} | |
| if data_errors: | |
| first_err = next(iter(data_errors.values())) | |
| raise RuntimeError(f"Could not read '{field_name}' from EarthMover's ERA5 store: {first_err}") | |
| truth_by_time = { | |
| unique_times[i]: sample_at_points(arrays[i + 2], lat_idx, lon_idx) | |
| for i in range(len(unique_times)) | |
| } | |
| truth_t0 = truth_by_time[t0] | |
| per_step = [] | |
| for i, state in enumerate(states, start=1): | |
| truth = truth_by_time[state["date"]] | |
| forecast = np.asarray(state["fields"][field_name]) | |
| m = metrics(forecast, truth) | |
| p = metrics(truth_t0, truth) | |
| per_step.append({ | |
| "date": str(state["date"]), | |
| "lead_h": i * STEP_HOURS, | |
| "forecast": forecast, | |
| "truth": truth, | |
| **{f"model_{k}": v for k, v in m.items()}, | |
| **{f"persistence_{k}": v for k, v in p.items()}, | |
| }) | |
| log(f"β Scored {len(per_step)} step(s) against ERA5 for '{field_name}'.") | |
| return {"field": field_name, "lats": lats, "lons": lons, "per_step": per_step} | |
| def _climatology_grid(group: str, var: str, level: int | None, valid_date: datetime.datetime, | |
| num_years: int, log) -> tuple[np.ndarray, np.ndarray, list[int]]: | |
| """ | |
| Core climatology fetch: the ERA5 mean/std grid (both (721, 1440), NOT | |
| sampled onto any particular points yet) for one field on one calendar | |
| day/hour, averaged over up to `num_years` past years. Shared by | |
| :func:`compute_climatology` (vs. an existing forecast state) and | |
| :func:`run_climatology_baseline` (a standalone multi-step "model"). | |
| """ | |
| # Request a few extra candidate years beyond num_years so that skipped | |
| # years (Feb 29 in a non-leap year, or anything ERA5 doesn't have yet | |
| # for the most recent year or two) still leave enough successful | |
| # samples β cheaper than pre-checking the archive's bounds separately. | |
| BUFFER = 5 | |
| candidate_years = [] | |
| year = valid_date.year - 1 # climatology uses PAST years only | |
| while len(candidate_years) < num_years + BUFFER and year >= 1940: | |
| try: | |
| candidate_years.append(valid_date.replace(year=year)) | |
| except ValueError: | |
| pass # Feb 29 in a non-leap year β skip it | |
| year -= 1 | |
| requests = [ | |
| {"group": group, "var": var, "level": level, "time_idx": _time_idx(t)} | |
| for t in candidate_years | |
| ] | |
| log(f"π‘ Fetching up to {num_years} years of ERA5 '{var}'" + (f"@{level}hPa" if level else "") + | |
| f" for {valid_date.strftime('%m-%d %H:%M')} UTCβ¦") | |
| arrays, meta = fetch_era5_fields(requests, log=log) | |
| successful = [(candidate_years[i], arrays[i]) for i in range(len(candidate_years)) if i not in meta["errors"]] | |
| if len(successful) < 2: | |
| first_err = next(iter(meta["errors"].values()), "no data available") | |
| raise RuntimeError( | |
| f"Could not build a climatology for '{var}' β only {len(successful)} of " | |
| f"{len(candidate_years)} candidate years had ERA5 data ({first_err})." | |
| ) | |
| successful = successful[:num_years] | |
| used_years = [t.year for t, _ in successful] | |
| samples = np.stack([grid for _, grid in successful]) | |
| clim_mean = np.nanmean(samples, axis=0) | |
| clim_std = np.nanstd(samples, axis=0) | |
| return clim_mean, clim_std, used_years | |
| def compute_climatology(state: dict, field_name: str, num_years: int = 10, log=lambda msg: None) -> dict: | |
| """ | |
| Compare ONE forecast state's field against ERA5's climatology for that | |
| calendar day/hour β the mean (and spread) over ``num_years`` years of | |
| ERA5 history on the same month/day/hour. | |
| Unlike :func:`evaluate_forecast`, this works for a LIVE (today's or a | |
| future) forecast: no ERA5 truth is needed for the forecast's own valid | |
| time, only for many *past* years on the same calendar day β sidestepping | |
| ERA5's coverage-ceiling problem entirely. It answers "is this forecast | |
| unusual for the time of year?", not "is this forecast correct?". | |
| """ | |
| if field_name not in EVAL_FIELD_MAP: | |
| raise ValueError( | |
| f"'{field_name}' has no ERA5 counterpart configured. " | |
| f"Available: {sorted(EVAL_FIELD_MAP)}" | |
| ) | |
| if num_years < 2: | |
| raise ValueError("num_years must be at least 2 to compute a meaningful climatology.") | |
| group, var, level = EVAL_FIELD_MAP[field_name] | |
| lats = np.asarray(state["latitudes"]).ravel() | |
| lons = np.asarray(state["longitudes"]).ravel() | |
| lat_idx, lon_idx = _nearest_indices(lats, lons) | |
| valid_date: datetime.datetime = state["date"] | |
| clim_mean_grid, clim_std_grid, used_years = _climatology_grid(group, var, level, valid_date, num_years, log) | |
| clim_mean = sample_at_points(clim_mean_grid, lat_idx, lon_idx) | |
| clim_std = sample_at_points(clim_std_grid, lat_idx, lon_idx) | |
| forecast = np.asarray(state["fields"][field_name]) | |
| anomaly = forecast - clim_mean | |
| zscore = np.full_like(anomaly, np.nan) | |
| valid_std = clim_std > 1e-6 | |
| zscore[valid_std] = anomaly[valid_std] / clim_std[valid_std] | |
| log(f"β Built a {len(used_years)}-year climatology ({min(used_years)}β{max(used_years)}) for '{field_name}'.") | |
| return { | |
| "field": field_name, "lats": lats, "lons": lons, | |
| "date": str(valid_date), "years": used_years, | |
| "forecast": forecast, "climatology_mean": clim_mean, "climatology_std": clim_std, | |
| "anomaly": anomaly, "zscore": zscore, | |
| } | |
| def run_climatology_baseline(date: datetime.datetime, num_steps: int, num_years: int = 10, | |
| log=lambda msg: None) -> list[dict]: | |
| """ | |
| A naive "forecast" built from pure ERA5 climatology: for each of | |
| `num_steps` 6h steps ahead of `date`, every canonical field's value is | |
| just the ERA5 climatological mean for that calendar day/hour (see | |
| :func:`_climatology_grid`) β zero model skill by construction, useful as | |
| a baseline to show whether AIFS/WeatherNext2 actually beat "just the | |
| climate normal". | |
| Returns a states list shaped like WeatherNext2's (regular grid, fields | |
| keyed by *canonical* name from :mod:`aifs.compare` β not AIFS's or | |
| WeatherNext2's own naming) β see aifs.compare.extract. | |
| """ | |
| from aifs.compare import CANONICAL_FIELDS # deferred: avoids a module-load cycle (compare imports this module) | |
| states = [] | |
| for i in range(1, num_steps + 1): | |
| valid_time = date + datetime.timedelta(hours=STEP_HOURS * i) | |
| fields = {} | |
| for canonical_name, spec in CANONICAL_FIELDS.items(): | |
| clim_mean, _, _ = _climatology_grid( | |
| spec["era5_group"], spec["era5_var"], spec["era5_level"], valid_time, num_years, log, | |
| ) | |
| fields[canonical_name] = clim_mean | |
| states.append({"date": valid_time, "fields": fields}) | |
| log(f"β Climatology baseline step {i}/{num_steps} ready ({valid_time} UTC).") | |
| return states | |
| # ββ Plotting (matches aifs.plot.plot_field's Cartopy tricontourf style) ββββββββ | |
| def _map_figure(lats, lons, data, title: str, cmap: str = "RdBu_r"): | |
| import cartopy.crs as ccrs | |
| import cartopy.feature as cfeature | |
| import matplotlib.pyplot as plt | |
| import matplotlib.tri as tri | |
| lons_plot = np.where(lons > 180, lons - 360, lons) | |
| triangulation = tri.Triangulation(lons_plot, lats) | |
| fig, ax = plt.subplots(figsize=(9, 5), subplot_kw={"projection": ccrs.PlateCarree()}) | |
| ax.coastlines() | |
| ax.add_feature(cfeature.BORDERS, linestyle=":") | |
| contour = ax.tricontourf(triangulation, data, levels=20, transform=ccrs.PlateCarree(), cmap=cmap) | |
| fig.colorbar(contour, ax=ax, orientation="vertical", shrink=0.7) | |
| ax.set_title(title, fontsize=11) | |
| fig.tight_layout() | |
| return fig | |
| def plot_eval_maps(eval_data: dict, step_index: int): | |
| """Forecast / ERA5 truth / (forecast - truth) maps for one evaluated step.""" | |
| step = eval_data["per_step"][step_index] | |
| lats, lons, field = eval_data["lats"], eval_data["lons"], eval_data["field"] | |
| fig_fc = _map_figure(lats, lons, step["forecast"], f"AIFS forecast β {field} @ {step['date']}") | |
| fig_truth = _map_figure(lats, lons, step["truth"], f"ERA5 truth β {field} @ {step['date']}") | |
| fig_diff = _map_figure( | |
| lats, lons, step["forecast"] - step["truth"], | |
| f"Forecast β ERA5 β {field} @ {step['date']}", | |
| ) | |
| return fig_fc, fig_truth, fig_diff | |
| def plot_climatology_maps(clim_data: dict): | |
| """Forecast / ERA5 climatology-mean / anomaly maps for one field.""" | |
| lats, lons, field = clim_data["lats"], clim_data["lons"], clim_data["field"] | |
| year_range = f"{min(clim_data['years'])}β{max(clim_data['years'])}" | |
| n = len(clim_data["years"]) | |
| fig_fc = _map_figure(lats, lons, clim_data["forecast"], f"AIFS forecast β {field} @ {clim_data['date']}") | |
| fig_clim = _map_figure( | |
| lats, lons, clim_data["climatology_mean"], | |
| f"ERA5 climatology mean β {field} ({year_range}, n={n})", | |
| ) | |
| fig_anom = _map_figure( | |
| lats, lons, clim_data["anomaly"], | |
| f"Anomaly (forecast β climatology) β {field}", | |
| ) | |
| return fig_fc, fig_clim, fig_anom | |
| def plot_skill_curve(eval_data: dict): | |
| """RMSE vs lead time: AIFS forecast vs a persistence (ERA5-held-at-t0) baseline.""" | |
| import matplotlib.pyplot as plt | |
| steps = eval_data["per_step"] | |
| lead_h = [s["lead_h"] for s in steps] | |
| model_rmse = [s["model_rmse"] for s in steps] | |
| persistence_rmse = [s["persistence_rmse"] for s in steps] | |
| fig, ax = plt.subplots(figsize=(7, 4)) | |
| ax.plot(lead_h, model_rmse, marker="o", label="AIFS forecast", color="#1f6feb") | |
| ax.plot(lead_h, persistence_rmse, marker="o", linestyle="--", label="Persistence (t0 held fixed)", color="#8b949e") | |
| ax.set_xlabel("Lead time (hours)") | |
| ax.set_ylabel("RMSE") | |
| ax.set_title(f"Skill vs ERA5 β {eval_data['field']}") | |
| ax.legend() | |
| ax.grid(alpha=0.3) | |
| fig.tight_layout() | |
| return fig | |