File size: 12,043 Bytes
5c365c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | """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())
|