yzt15806542928's picture
Upload folder using huggingface_hub
5c365c5 verified
Raw
History Blame Contribute Delete
12 kB
"""Evaluate ACE rollouts and render forecast figures."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import torch
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ACE.model.paths import GENERATED_DATA_PATH, INFER_PATH, PIC_DIR, configured_path
from ACE.model.physics import forecast_metrics
from ACE.model.variables import DIAGNOSTIC_CHANNELS, PROGNOSTIC_CHANNELS
CHANNELS = PROGNOSTIC_CHANNELS + DIAGNOSTIC_CHANNELS
MAP_CHANNELS = ("T_0", "T_7", "Ts_land_or_seaice", "ps", "P", "LHF")
SERIES_CHANNELS = ("T_0", "T_7", "qT_7", "ps", "P", "LHF", "SHF")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "conf" / "config.yaml")
parser.add_argument("--prediction-path", type=Path, default=None)
parser.add_argument("--truth-path", type=Path, default=None)
parser.add_argument("--output-dir", type=Path, default=None)
parser.add_argument("--area-weights", type=Path, default=None, help="NPY [H,W] or [H]")
parser.add_argument("--dpi", type=int, default=160)
return parser.parse_args()
def _rollout(values: np.ndarray, name: str) -> np.ndarray:
values = np.asarray(values)
if values.ndim == 5:
values = values[0]
elif values.ndim == 4:
if values.shape[1] != len(CHANNELS):
raise ValueError(f"{name} must have 44 channels, got {values.shape}")
values = values if name == "prediction" else values[:1]
elif values.ndim == 3:
values = values[None]
else:
raise ValueError(f"{name} must be [B,T,C,H,W], [T,C,H,W], or [C,H,W], got {values.shape}")
if values.ndim != 4 or values.shape[1] != len(CHANNELS):
raise ValueError(f"{name} must resolve to [T,44,H,W], got {values.shape}")
return values.astype(np.float32, copy=False)
def _truth(pred_data, truth_path, config):
if truth_path is not None:
data = np.load(truth_path)
return data["predictions"] if "predictions" in data else data["targets"], data
if "targets" in pred_data:
return pred_data["targets"], pred_data
data = np.load(configured_path(config, "data_path", GENERATED_DATA_PATH))
return data["targets"], data
def _coordinates(*sources, height: int, width: int):
lat = lon = None
for source in sources:
if source is None:
continue
if lat is None and "lat" in source:
lat = np.asarray(source["lat"], dtype=np.float32)
if lon is None and "lon" in source:
lon = np.asarray(source["lon"], dtype=np.float32)
lat = lat if lat is not None else np.linspace(-90, 90, height, dtype=np.float32)
lon = lon if lon is not None else np.linspace(0, 360, width, endpoint=False, dtype=np.float32)
if lat.size != height or lon.size != width:
raise ValueError(f"coordinate shape mismatch: lat={lat.shape}, lon={lon.shape}, field={(height, width)}")
return lat, lon
def _area_grid(path, lat, width):
if path is not None:
area = np.asarray(np.load(path), dtype=np.float64)
if area.ndim == 1:
area = area[:, None]
if area.shape != (lat.size, width):
raise ValueError(f"area weights must match {(lat.size, width)}, got {area.shape}")
return area
return np.broadcast_to(np.cos(np.deg2rad(lat))[:, None], (lat.size, width)).copy()
def _global_mean(fields, area):
weights = area / max(float(area.sum()), np.finfo(np.float64).eps)
return (fields * weights[None, None]).sum(axis=(-2, -1))
def _limits(values, symmetric=False):
finite = np.asarray(values, dtype=np.float64)
finite = finite[np.isfinite(finite)]
if finite.size == 0:
return -1.0, 1.0
low, high = np.percentile(finite, (2, 98))
if not np.isfinite(low) or not np.isfinite(high) or low == high:
low, high = float(finite.min()), float(finite.max())
if symmetric:
bound = max(abs(float(low)), abs(float(high)), 1e-12)
return -bound, bound
return float(low), float(high if high > low else low + 1e-12)
def _plt():
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
except ImportError as exc:
raise RuntimeError("PNG visualization requires matplotlib; it is available in develop_base") from exc
def _save_maps(path, pred, lat, lon, dpi):
plt = _plt()
selected = [(name, CHANNELS.index(name)) for name in MAP_CHANNELS if name in CHANNELS]
steps = np.unique(np.linspace(0, pred.shape[0] - 1, min(pred.shape[0], 4), dtype=int))
fig, axes = plt.subplots(len(selected), len(steps), figsize=(3.7 * len(steps), 2.8 * len(selected)), squeeze=False, constrained_layout=True)
extent = (float(lon.min()), float(lon.max()), float(lat.min()), float(lat.max()))
for row, (name, channel) in enumerate(selected):
low, high = _limits(pred[:, channel])
for col, step in enumerate(steps):
image = axes[row, col].imshow(pred[step, channel], origin="lower", extent=extent, aspect="auto", cmap="viridis", vmin=low, vmax=high)
axes[row, col].set_title(f"{name} | lead {(step + 1) * 6} h")
axes[row, col].set_xlabel("longitude (deg)")
axes[row, col].set_ylabel("latitude (deg)")
fig.colorbar(image, ax=axes[row, col], shrink=0.82)
fig.suptitle("ACE rollout fields (first sample)", fontsize=15)
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
def _save_comparison(path, pred, truth, lat, lon, dpi):
plt = _plt()
selected = [(name, CHANNELS.index(name)) for name in ("T_0", "T_7", "ps", "P", "LHF") if name in CHANNELS]
fig, axes = plt.subplots(len(selected), 3, figsize=(11, 2.9 * len(selected)), squeeze=False, constrained_layout=True)
extent = (float(lon.min()), float(lon.max()), float(lat.min()), float(lat.max()))
for row, (name, channel) in enumerate(selected):
predicted, reference = pred[0, channel], truth[0, channel]
error = predicted - reference
low, high = _limits(np.stack([predicted, reference]))
bound = _limits(error, symmetric=True)[1]
for col, (field, title, cmap, vmin, vmax) in enumerate(((predicted, "prediction", "viridis", low, high), (reference, "truth", "viridis", low, high), (error, "prediction - truth", "coolwarm", -bound, bound))):
image = axes[row, col].imshow(field, origin="lower", extent=extent, aspect="auto", cmap=cmap, vmin=vmin, vmax=vmax)
axes[row, col].set_title(f"{name}: {title}")
axes[row, col].set_xlabel("longitude (deg)")
axes[row, col].set_ylabel("latitude (deg)")
fig.colorbar(image, ax=axes[row, col], shrink=0.82)
fig.suptitle("First-step forecast comparison", fontsize=15)
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
def _save_series(path, means, truth_means, dpi):
plt = _plt()
selected = [name for name in SERIES_CHANNELS if name in CHANNELS]
fig, axes = plt.subplots(len(selected), 1, figsize=(10, 2.1 * len(selected)), squeeze=False, sharex=True, constrained_layout=True)
time = np.arange(means.shape[0]) * 6 / 24.0
for row, name in enumerate(selected):
channel = CHANNELS.index(name)
axes[row, 0].plot(time, means[:, channel], marker="o", linewidth=1.6, label="prediction")
if truth_means is not None:
axes[row, 0].plot(np.arange(truth_means.shape[0]) * 6 / 24.0, truth_means[:, channel], "x--", label="truth")
axes[row, 0].set_ylabel(name)
axes[row, 0].grid(alpha=0.25)
axes[row, 0].legend(loc="best", fontsize=8)
axes[-1, 0].set_xlabel("forecast lead (days)")
fig.suptitle("Area-weighted global means", fontsize=15)
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
def _save_heatmap(path, means, dpi):
plt = _plt()
fig, axis = plt.subplots(figsize=(11, 13), constrained_layout=True)
image = axis.imshow(means.T, aspect="auto", interpolation="nearest", cmap="RdBu_r")
axis.set_yticks(np.arange(len(CHANNELS)))
axis.set_yticklabels(CHANNELS, fontsize=7)
axis.set_xticks(np.arange(means.shape[0]))
axis.set_xticklabels([f"+{(step + 1) * 6}h" for step in range(means.shape[0])], rotation=45, ha="right")
axis.set_xlabel("forecast lead")
axis.set_title("Global mean of all 44 output channels")
fig.colorbar(image, ax=axis, label="area-weighted mean")
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
def _save_rmse(path, pred, truth, dpi):
plt = _plt()
rmse = np.sqrt(np.mean(np.square(pred - truth), axis=(0, 2, 3)))
fig, axis = plt.subplots(figsize=(15, 5), constrained_layout=True)
colors = ["#2b6cb0" if index < len(PROGNOSTIC_CHANNELS) else "#c05621" for index in range(len(CHANNELS))]
axis.bar(np.arange(len(CHANNELS)), rmse, color=colors)
axis.set_xticks(np.arange(len(CHANNELS)))
axis.set_xticklabels(CHANNELS, rotation=75, ha="right", fontsize=7)
axis.set_ylabel("RMSE")
axis.set_title("Forecast RMSE by output channel")
axis.grid(axis="y", alpha=0.25)
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
return rmse
def main() -> int:
args = parse_args()
import yaml
with args.config.open("r", encoding="utf-8") as handle:
config = yaml.safe_load(handle) or {}
prediction_path = args.prediction_path or configured_path(config, "infer_path", INFER_PATH)
output_dir = args.output_dir or configured_path(config, "pic_dir", PIC_DIR)
if not prediction_path.exists():
raise SystemExit(f"prediction not found: {prediction_path}; run 'python scripts/inference.py' first")
pred_data = np.load(prediction_path)
if "predictions" not in pred_data:
raise KeyError("inference NPZ must contain predictions")
truth_raw, truth_data = _truth(pred_data, args.truth_path, config)
prediction = _rollout(pred_data["predictions"], "prediction")
truth = _rollout(truth_raw, "truth")
if truth.shape[0] == prediction.shape[0]:
eval_pred, eval_truth = prediction, truth
else:
eval_pred, eval_truth = prediction[:1], truth[:1]
height, width = prediction.shape[-2:]
lat, lon = _coordinates(pred_data, truth_data, height=height, width=width)
area = _area_grid(args.area_weights, lat, width)
metrics = forecast_metrics(torch.from_numpy(eval_pred).float(), torch.from_numpy(eval_truth).float(), torch.from_numpy(area).float())
means = _global_mean(prediction, area)
truth_means = _global_mean(truth, area) if truth.shape[0] == prediction.shape[0] else None
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
_save_maps(output_dir / "rollout_maps.png", prediction, lat, lon, args.dpi)
_save_comparison(output_dir / "first_step_comparison.png", eval_pred, eval_truth, lat, lon, args.dpi)
_save_series(output_dir / "global_mean_timeseries.png", means, truth_means, args.dpi)
_save_heatmap(output_dir / "all_channel_global_means.png", means, args.dpi)
rmse = _save_rmse(output_dir / "channel_rmse.png", eval_pred, eval_truth, args.dpi)
manifest = {"prediction_path": str(prediction_path), "shape": list(prediction.shape), "channels": list(CHANNELS), "figures": ["rollout_maps.png", "first_step_comparison.png", "global_mean_timeseries.png", "all_channel_global_means.png", "channel_rmse.png"]}
(output_dir / "visualization_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(json.dumps({"status": "success", "metrics": metrics, "output_dir": str(output_dir), "figures": 5, "shape": list(prediction.shape), "max_channel_rmse": float(rmse.max())}))
return 0
if __name__ == "__main__":
raise SystemExit(main())