e3smv3-lr-hist-samudrace / scripts /build_forcing.py
mahf708's picture
E3SMv3 LR historical coupled emulators, rc1
bf0a67c
Raw History Blame Contribute Delete
22.3 kB
"""Build the forcing package for the E3SMv3 LR historical SamudrACE-style emulators.
One package serves all three flavors; each checkpoint reads only the names it needs.
Per year YYYY it writes, into OUT_DIR:
atmosphere-forcing-YYYY.nc 6-hourly (1460 steps, noleap), from the E3SM eam.h0 files:
SOLIN (time, lat, lon); global_mean_co2 (time) = h0 co2vmr;
PHIS, LANDFRAC (lat, lon), static in time;
ak_0..8, bk_0..8, P0 (vertical coordinate, scalars).
aerosol/<window>/aerosol-YYYY.nc (window = 60mo, 12mo, 01mo for methods clim5yr, run12mo, year)
6-hourly, same time axis: aerindexall, colccn.3 replaced by a
smoothed version (three methods, below). Only the C1A1
flavor reads one of them.
ocean-forcing-YYYY.nc 5-daily time axis (73 steps, noleap), static data:
sea_surface_fraction (time, lat, lon; repeated, the loader
needs one such field), atmosphere_flux_fraction,
momentum_flux_fraction, LANDFRAC (lat, lon);
mask_0..18, mask_2d (lat, lon); idepth_0..19 (interface depths, m).
Aerosol methods (--aero-method, default: all three). Per cell and per 6-hourly step, linear
interpolation in time between values assigned to the mid-point of each month, plus a mean
diurnal composite (4 phases, deviation from the monthly mean); colccn.3 is clipped at 0:
clim5yr mid-month value = 5-year centred calendar-month mean (Y-2..Y+2, shifted to stay inside
the full years 1940-2064); diurnal composite = that 5-year window's mean. Identical to
the smooth part S of cft-diag/cpl_e3x/skill_shuf/scripts/build_overlays.py. Smears
volcanic years into their neighbours (1985 is 2-20% above the raw 1985 monthly means
because the window includes El Chichon, 1983).
year mid-month value = that month's own mean; diurnal composite = that month's own.
Keeps each month's own anomaly (the run's weather at monthly scale).
run12mo mid-month value = centred 12-month running mean of the monthly means (2x12 moving
average; the slow and volcanic part) + the 5-year centred mean seasonal anomaly of
that calendar month (monthly mean minus the year's mean, averaged over Y-2..Y+2);
diurnal composite = the 5-year window's mean. Windows are shifted to stay inside
1940-01..2064-12.
Per-month statistics (monthly mean and diurnal composite, float64 npz) are cached in
--stats-cache; directories in --stats-dirs (e.g. skill_shuf's _stats/, same format) are
searched first.
Usage:
python build_forcing.py --years 1985 2015 # selected years
python build_forcing.py --start 1950 --end 2064 --nproc 8 # full span, all kinds/methods
options: --out DIR, --kinds atmosphere aerosol ocean, --aero-method clim5yr year run12mo,
--stats-cache DIR, --stats-dirs DIR [DIR ...], --overwrite
"""
import argparse
import glob
import os
import sys
import time
from multiprocessing import Pool
import netCDF4 as nc
import numpy as np
RUN = "/pscratch/sd/m/mahf708/v3.LR.historical_0101.aigo/run"
CASE = "v3.LR.historical_0101.aigo"
INPUTS = "/pscratch/sd/m/mahf708/e3sm-hist-inputs"
FULL_YEAR_RANGE = (1940, 2064) # years with all 12 h0 months
AER = ("aerindexall", "colccn.3")
NDAYS = np.array([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31])
CUM = np.concatenate([[0], np.cumsum(NDAYS)[:-1]])
COMP = dict(zlib=True, complevel=4, shuffle=True)
PROVENANCE = (
"Built by build_forcing.py from the E3SMv3 v3.LR.historical_0101 simulation "
"(1 degree Gaussian grid remap of EAM/MPAS output)."
)
def h0(y, m):
return f"{RUN}/{CASE}.eam.h0.{y}-{m:02d}.nc"
# The run starts at 1940-01-01 00:00 and writes no h0 record for that instant, so January 1940 has 123
# 6-hourly records instead of 124. read_month() adds the missing first record: SOLIN from 1941-01-01 00:00
# (same calendar position) scaled by the 1940/1941 ratio of global-mean SOLIN at 06:00; co2vmr from 06:00;
# other fields NaN (monthly statistics skip it).
FIRST_RECORD_FILL = ("1940-01-01 00:00 is not in the run's output (the run starts there); this record was filled: "
"SOLIN = SOLIN(1941-01-01 00:00) x [global-mean SOLIN 1940-01-01 06:00 / 1941-01-01 06:00], "
"co2vmr = its 1940-01-01 06:00 value.")
def read_month(y, m, names):
"""Times, bounds and the named h0 fields for one month, always 4 records per day starting at 00:00."""
with nc.Dataset(h0(y, m)) as d:
d.set_auto_mask(False)
t, b = d["time"][:], d["time_bnds"][:]
f = {v: d[v][:] for v in names}
if len(t) == NDAYS[m - 1] * 4 - 1 and (y, m) == FULL_YEAR_RANGE[:1] + (1,):
t = np.concatenate([[t[0] - 0.25], t])
b = np.concatenate([[[b[0, 0] - 0.25, b[0, 0]]], b])
for v, a in f.items():
if v == "SOLIN":
with nc.Dataset(h0(y + 1, 1)) as d1:
d1.set_auto_mask(False)
s0, s6 = d1["SOLIN"][0], d1["SOLIN"][1]
w = np.cos(np.deg2rad(d1["lat"][:]))[:, None]
ratio = float((a[0] * w).sum() / (s6 * w).sum())
first = s0 * ratio
elif v == "co2vmr":
first = a[0]
else:
first = np.full_like(a[0], np.nan, dtype=np.float64)
f[v] = np.concatenate([np.asarray(first, dtype=np.float64)[None], a.astype(np.float64)])
return t, b, f
def _copy_attrs(src, dst, skip=("_FillValue",)):
for a in src.ncattrs():
if a not in skip:
dst.setncattr(a, src.getncattr(a))
def _new_file(path, ref, lat_lon_src, time_vals, time_attrs, time_bnds=None):
out = nc.Dataset(path + ".tmp", "w", format="NETCDF4")
out.createDimension("time", len(time_vals))
out.createDimension("lat", len(lat_lon_src["lat"]))
out.createDimension("lon", len(lat_lon_src["lon"]))
for c in ("lat", "lon"):
v = out.createVariable(c, "f8", (c,), fill_value=False)
_copy_attrs(lat_lon_src[c], v)
v[:] = lat_lon_src[c][:]
t = out.createVariable("time", "f8", ("time",), fill_value=False)
for k, val in time_attrs.items():
t.setncattr(k, val)
t[:] = time_vals
if time_bnds is not None:
out.createDimension("nbnd", 2)
tb = out.createVariable("time_bnds", "f8", ("time", "nbnd"), fill_value=False)
tb.setncattr("long_name", "time interval endpoints")
tb[:] = time_bnds
for a in ("case", "source", "source_id", "institution_id", "institution", "contact", "Conventions"):
if a in ref.ncattrs():
out.setncattr(a, ref.getncattr(a))
return out
def _finish(out, path):
out.close()
os.replace(path + ".tmp", path)
# ---------------------------------------------------------------- atmosphere
def build_atmosphere(y, out_dir):
path = f"{out_dir}/atmosphere-forcing-{y}.nc"
times, bnds, solin, co2 = [], [], [], []
for m in range(1, 13):
t, b, f = read_month(y, m, ("SOLIN", "co2vmr"))
times.append(t)
bnds.append(b)
solin.append(f["SOLIN"])
co2.append(f["co2vmr"])
times, bnds = np.concatenate(times), np.concatenate(bnds)
assert len(times) == 1460 and np.allclose(np.diff(times), 0.25), (y, len(times))
with nc.Dataset(h0(y, 1)) as d:
d.set_auto_mask(False)
tattrs = {a: d["time"].getncattr(a) for a in d["time"].ncattrs()}
out = _new_file(path, d, d.variables, times, tattrs, bnds)
v = out.createVariable("SOLIN", "f4", ("time", "lat", "lon"), chunksizes=(1, 180, 360), fill_value=False, **COMP)
_copy_attrs(d["SOLIN"], v)
v[:] = np.concatenate(solin)
v = out.createVariable("global_mean_co2", "f8", ("time",), fill_value=False)
_copy_attrs(d["co2vmr"], v)
v.setncattr("note", "E3SM co2vmr (volume mixing ratio), renamed to the name the emulator uses")
v[:] = np.concatenate(co2)
for name in ("PHIS", "LANDFRAC"):
v = out.createVariable(name, "f4", ("lat", "lon"), fill_value=False, **COMP)
_copy_attrs(d[name], v)
v.setncattr("note", "static in time in this simulation; first January record")
v[:] = d[name][0]
for name in [f"{p}_{k}" for k in range(9) for p in ("ak", "bk")] + ["P0"]:
v = out.createVariable(name, "f8", (), fill_value=False)
_copy_attrs(d[name], v)
v.assignValue(d[name][...])
out.title = f"Atmosphere forcing for {y} (6-hourly)"
out.history = PROVENANCE + " Variables copied unchanged except co2vmr->global_mean_co2 and PHIS/LANDFRAC stored once."
if y == FULL_YEAR_RANGE[0]:
out.first_record_note = FIRST_RECORD_FILL
_finish(out, path)
return path
# ---------------------------------------------------------------- aerosol climatology
class Stats:
def __init__(self, cache, extra_dirs):
self.cache, self.dirs, self.mem = cache, [cache] + list(extra_dirs), {}
def _compute(self, y, m):
out = f"{self.cache}/{y}-{m:02d}.npz"
_, _, f = read_month(y, m, AER)
r = np.stack([f[v] for v in AER]).astype(np.float64)
assert r.shape[1] == NDAYS[m - 1] * 4, (y, m, r.shape)
mm = np.nanmean(r, 1)
dc = np.stack([np.nanmean(r[:, h::4], 1) for h in range(4)], 1) - mm[:, None]
np.savez(out + ".tmp.npz", mm=mm, dc=dc)
os.replace(out + ".tmp.npz", out)
return out
def path(self, y, m, compute=True):
for dd in self.dirs:
p = f"{dd}/{y}-{m:02d}.npz"
if os.path.exists(p):
return p
return self._compute(y, m) if compute else None
def get(self, y, m):
if (y, m) not in self.mem:
z = np.load(self.path(y, m))
self.mem[(y, m)] = (z["mm"], z["dc"])
return self.mem[(y, m)]
def window(y):
lo, hi = FULL_YEAR_RANGE
start = min(max(y - 2, lo), hi - 4)
return range(start, start + 5)
def clim5(st, y, m):
yrs = window(y)
return (np.mean([st.get(yy, m)[0] for yy in yrs], 0), np.mean([st.get(yy, m)[1] for yy in yrs], 0))
# full months with h0 data: 1940-01 .. 2064-12 (2065-01 holds a single record)
FIRST_MONTH, LAST_MONTH = (1940, 1), (2064, 12)
def _mi(y, m):
return y * 12 + m - 1
def _ym(i):
return i // 12, i % 12 + 1
def _clamp_month(y, m):
i = min(max(_mi(y, m), _mi(*FIRST_MONTH)), _mi(*LAST_MONTH))
return _ym(i)
def run12_months(y, m):
"""Weights of the centred 2x12 running mean around (y, m): months -6 and +6 get 1/24,
months -5..+5 get 1/12. The 13-month window is shifted to stay inside FIRST..LAST_MONTH."""
c = _mi(y, m)
lo, hi = _mi(*FIRST_MONTH), _mi(*LAST_MONTH)
c = min(max(c, lo + 6), hi - 6)
return [(_ym(i), (0.5 if abs(i - c) == 6 else 1.0) / 12.0) for i in range(c - 6, c + 7)]
def seasonal_anomaly5(st, y, m):
"""5-year centred mean seasonal anomaly for calendar month m: monthly mean minus that
year's annual mean (mean of its 12 monthly means), averaged over window(y)."""
out = []
for yy in window(y):
annual = np.mean([st.get(yy, k)[0] for k in range(1, 13)], 0)
out.append(st.get(yy, m)[0] - annual)
return np.mean(out, 0)
def base_mean(method, st, y, m):
"""Smooth value assigned to the mid-point of month (y, m)."""
if method == "clim5yr":
return clim5(st, y, m)[0]
if method == "year":
return st.get(*_clamp_month(y, m))[0]
if method == "run12mo":
slow = sum(w * st.get(yy, mm)[0] for (yy, mm), w in run12_months(y, m))
return slow + seasonal_anomaly5(st, y, m)
raise ValueError(method)
def diurnal(method, st, y, m):
"""Mean diurnal composite (4 x 6-hourly phases, deviation from the monthly mean) for (y, m)."""
if method in ("clim5yr", "run12mo"):
return clim5(st, y, m)[1]
return st.get(*_clamp_month(y, m))[1] # year: that month's own composite
def center(y, m):
return (y - 1850) * 365 + CUM[m - 1] + NDAYS[m - 1] / 2.0
def prev_next(y, m):
return ((y, m - 1) if m > 1 else (y - 1, 12)), ((y, m + 1) if m < 12 else (y + 1, 1))
def smooth(st, y, m, times, method="clim5yr"):
c0, d0 = base_mean(method, st, y, m), diurnal(method, st, y, m)
(yp, mp), (yn, mn) = prev_next(y, m)
cp, cn = base_mean(method, st, yp, mp), base_mean(method, st, yn, mn)
tc, tp, tn = center(y, m), center(yp, mp), center(yn, mn)
o = np.empty((2, len(times)) + c0.shape[1:])
for i, t in enumerate(times):
if t < tc:
w = (t - tp) / (tc - tp)
base = (1 - w) * cp + w * c0
else:
w = (t - tc) / (tn - tc)
base = (1 - w) * c0 + w * cn
o[:, i] = base + d0[:, i % 4]
return o
class _Recorder:
"""Stand-in for Stats that records which (year, month) statistics a method needs."""
def __init__(self):
self.keys = set()
def get(self, y, m):
self.keys.add((y, m))
return np.zeros((2, 1)), np.zeros((2, 4, 1))
def needed_stat_months(y, method="clim5yr"):
rec = _Recorder()
for m in range(1, 13):
smooth(rec, y, m, [center(y, m)], method)
return sorted(rec.keys)
AERO_NOTES = {
"clim5yr": "linear interpolation between mid-month points of the 5-year centred calendar-month mean "
"(years Y-2..Y+2, shifted to stay within 1940-2064) plus that 5-year window's mean diurnal composite "
"for the month",
"year": "linear interpolation between mid-month points of this year's own monthly means (the adjacent "
"months of the neighbouring years at the year edges; Dec 2064 is held flat to the end) plus the "
"month's own mean diurnal composite",
"run12mo": "linear interpolation between mid-month points of [centred 12-month running mean of the "
"monthly means (2x12 moving average: months -6 and +6 weighted 1/24, months -5..+5 weighted 1/12; "
"window shifted to stay within 1940-01..2064-12)] + [5-year centred mean seasonal anomaly for the "
"calendar month (monthly mean minus that year's mean of 12 monthly means, averaged over Y-2..Y+2, "
"shifted to stay within 1940-2064)], plus the 5-year window's mean diurnal composite for the month",
}
AERO_METHODS = tuple(AERO_NOTES)
# output folder per method, named by the averaging window that sets the year-to-year values
AERO_DIRS = {"clim5yr": "60mo", "run12mo": "12mo", "year": "01mo"}
def aerosol_path(out_dir, method, y):
return f"{out_dir}/aerosol/{AERO_DIRS[method]}/aerosol-{y}.nc"
def build_aerosol(y, out_dir, st, method="clim5yr"):
path = aerosol_path(out_dir, method, y)
os.makedirs(os.path.dirname(path), exist_ok=True)
times, parts = [], []
for m in range(1, 13):
t, _, _ = read_month(y, m, ())
times.append(t)
parts.append(smooth(st, y, m, t, method))
times = np.concatenate(times)
x = np.concatenate(parts, axis=1)
nclip = int((x[1] < 0).sum())
x[1] = np.maximum(x[1], 0.0)
with nc.Dataset(h0(y, 1)) as d:
d.set_auto_mask(False)
tattrs = {a: d["time"].getncattr(a) for a in d["time"].ncattrs() if a != "bounds"}
out = _new_file(path, d, d.variables, times, tattrs)
for k, name in enumerate(AER):
v = out.createVariable(name, "f4", ("time", "lat", "lon"), chunksizes=(1, 180, 360), fill_value=False, **COMP)
_copy_attrs(d[name], v)
v.setncattr(
"note",
f"Smoothed version ({method}) of the simulation's field: " + AERO_NOTES[method]
+ ("; clipped at 0" if name == "colccn.3" else ""),
)
v[:] = x[k].astype(np.float32)
out.title = f"Smoothed aerosol diagnostics ({method}) for {y} (6-hourly)"
out.aerosol_method = method
out.history = PROVENANCE + f" Aerosol method {method}; colccn.3 values clipped at 0: {nclip}."
_finish(out, path)
return path, nclip
# ---------------------------------------------------------------- ocean
OCN = f"{RUN}/{CASE}.mpaso.hist.am"
def build_ocean(y, out_dir):
path = f"{out_dir}/ocean-forcing-{y}.nc"
times = (y - 1940) * 365 + 5.0 * np.arange(73)
src_dc = f"{OCN}.fmeDepthCoarsening5D.1985-01.remapped.nc"
src_df = f"{OCN}.fmeDerivedFields5D.1985-01.remapped.nc"
with nc.Dataset(src_dc) as d, nc.Dataset(src_df) as f, nc.Dataset(
f"{INPUTS}/landfrac5d/landfrac5d.{y}.nc"
) as lf, nc.Dataset(f"{INPUTS}/momfrac5d/momfrac5d.{y}.nc") as mf:
for x in (d, f, lf, mf):
x.set_auto_mask(False)
tattrs = {"long_name": "time", "units": "days since 1940-01-01 00:00:00", "calendar": "noleap", "axis": "T"}
out = _new_file(path, d, d.variables, times, tattrs)
for src, name in [(lf, "sea_surface_fraction"), (lf, "atmosphere_flux_fraction"), (mf, "momentum_flux_fraction"), (lf, "LANDFRAC")]:
a = src[name][:]
assert np.array_equal(a, np.broadcast_to(a[0], a.shape)), (y, name, "not static")
if name == "sea_surface_fraction":
# fme's loader takes the horizontal dims from the first (time, lat, lon)
# variable, so one field keeps its (repeated) time dimension.
v = out.createVariable(name, "f4", ("time", "lat", "lon"), chunksizes=(1, 180, 360), fill_value=False, **COMP)
v[:] = np.broadcast_to(a[0], (len(times),) + a[0].shape)
else:
v = out.createVariable(name, "f4", ("lat", "lon"), fill_value=False, **COMP)
v[:] = a[0]
_copy_attrs(src[name], v)
v.setncattr("note", "static in time in this simulation")
for k in range(19):
v = out.createVariable(f"mask_{k}", "f4", ("lat", "lon"), fill_value=False, **COMP)
_copy_attrs(d[f"mask_{k}"], v)
v[:] = d[f"mask_{k}"][:]
v = out.createVariable("mask_2d", "f4", ("lat", "lon"), fill_value=False, **COMP)
_copy_attrs(f["mask_2d"], v)
v[:] = f["mask_2d"][:]
for k in range(20): # 20 interfaces for the 19 layers
v = out.createVariable(f"idepth_{k}", "f8", (), fill_value=False)
_copy_attrs(d[f"idepth_{k}"], v)
v.assignValue(d[f"idepth_{k}"][...])
out.title = f"Ocean forcing (static fields on a 5-daily time axis) for {y}"
out.history = PROVENANCE + " Fields are static; the time axis only tells the loader which dates exist."
_finish(out, path)
return path
# ---------------------------------------------------------------- driver
def _out_path(kind, y, out_dir, method):
if kind == "aerosol":
return aerosol_path(out_dir, method, y)
return f"{out_dir}/{ {'atmosphere': 'atmosphere-forcing', 'ocean': 'ocean-forcing'}[kind] }-{y}.nc"
def _job(args):
kind, method, y, out_dir, cache, extra = args
t0 = time.time()
if os.path.exists(_out_path(kind, y, out_dir, method)):
return f"{kind}{'/' + method if method else ''} {y} exists, skipped"
if kind == "atmosphere":
p = build_atmosphere(y, out_dir)
info = ""
elif kind == "ocean":
p = build_ocean(y, out_dir)
info = ""
else:
p, nclip = build_aerosol(y, out_dir, Stats(cache, extra), method)
info = f" colccn.3 clipped={nclip}"
return f"{kind}{'/' + method if method else ''} {y} {os.path.getsize(p) / 1e6:.1f} MB {time.time() - t0:.0f}s{info}"
def _stat_job(args):
y, m, cache, extra = args
st = Stats(cache, extra)
return st.path(y, m)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--years", type=int, nargs="*")
ap.add_argument("--start", type=int)
ap.add_argument("--end", type=int)
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "forcing_data"))
ap.add_argument("--kinds", nargs="*", default=["atmosphere", "aerosol", "ocean"])
ap.add_argument("--aero-method", nargs="+", default=list(AERO_METHODS), choices=AERO_METHODS)
ap.add_argument("--stats-cache", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "_build", "aero_stats"))
ap.add_argument("--stats-dirs", nargs="*", default=[])
ap.add_argument("--nproc", type=int, default=4)
ap.add_argument("--overwrite", action="store_true")
a = ap.parse_args()
years = a.years or list(range(a.start, a.end + 1))
lo, hi = FULL_YEAR_RANGE
if min(years) < lo or max(years) > hi:
sys.exit(f"years must be within {lo}..{hi} (aerosol windows are shifted to stay inside these years)")
out = os.path.abspath(a.out)
cache = os.path.abspath(a.stats_cache)
os.makedirs(out, exist_ok=True)
os.makedirs(cache, exist_ok=True)
jobs = []
for y in years:
for k in a.kinds:
for method in (a.aero_method if k == "aerosol" else [None]):
jobs.append((k, method, y, out, cache, a.stats_dirs))
if a.overwrite:
for j in jobs:
f = _out_path(j[0], j[2], out, j[1])
if os.path.exists(f):
os.remove(f)
t0 = time.time()
with Pool(a.nproc) as p:
if "aerosol" in a.kinds:
need = sorted({ym for y in years for meth in a.aero_method for ym in needed_stat_months(y, meth)})
st = Stats(cache, a.stats_dirs)
todo = [(y, m, cache, a.stats_dirs) for (y, m) in need if st.path(y, m, compute=False) is None]
print(f"aerosol stats: {len(need)} months needed, {len(todo)} to compute", flush=True)
for i, _ in enumerate(p.imap_unordered(_stat_job, todo)):
if i % 60 == 0:
print(f" stats {i + 1}/{len(todo)} {time.time() - t0:.0f}s", flush=True)
for msg in p.imap_unordered(_job, jobs):
print(msg, f"(elapsed {time.time() - t0:.0f}s)", flush=True)
print(f"done {len(jobs)} files in {time.time() - t0:.0f}s", flush=True)
if __name__ == "__main__":
main()