#!/usr/bin/env python3 """Build daily forecast Tx from 6-hourly mx2t6, aligned to (year, init, lead). mx2t6 at valid time t covers the interval (t-6h, t]. Forecast day `lead` therefore spans hours (24*lead, 24*lead+24], i.e. the four 6-hourly maxima at indices 4*lead .. 4*lead+3 of the 184-step record. Daily Tx is their maximum. The same layout is produced by the old 0.25 deg archive and by the new 1.5 deg `txtm` group, so one code path serves both. `--to-ec15` averages the result onto the true 1.5 deg MARS grid (100..113.5E, 6..24N), producing the proxy used to develop the downscaling method before the real 1.5 deg data has finished downloading. Averaging onto the real grid rather than 6x6 blocks matters: 6x6 blocks would silently drop the southern and eastern edge of the domain, and the code would then need rewriting. Usage: build_ec_tx.py --root --pattern --out [--to-ec15] [--jobs 10] """ import argparse import glob import os import re import sys from concurrent.futures import ProcessPoolExecutor import numpy as np import xarray as xr YEARS = list(range(2003, 2023)) NLEAD = 46 NSTEP_PER_DAY = 4 # the grid MARS actually returns for area [25,100,6,114] at 1.5 deg EC15_LON = 100.0 + 1.5 * np.arange(10) # 100.0 .. 113.5 EC15_LAT = 24.0 - 1.5 * np.arange(13) # 24.0 .. 6.0 (north to south) def ec15_weights(lat, lon): """Map fine (lat,lon) points onto their enclosing 1.5 deg cell.""" jl = np.abs(lat[:, None] - EC15_LAT[None, :]).argmin(axis=1) il = np.abs(lon[:, None] - EC15_LON[None, :]).argmin(axis=1) return jl, il def to_ec15(daily, jl, il): """Average a (lead, lat, lon) field onto the 1.5 deg grid.""" nl = daily.shape[0] out = np.full((nl, len(EC15_LAT), len(EC15_LON)), np.nan, dtype="float32") flat = jl[:, None] * len(EC15_LON) + il[None, :] fl = flat.ravel() n = np.bincount(fl, minlength=len(EC15_LAT) * len(EC15_LON)) for k in range(nl): s = np.bincount(fl, weights=daily[k].ravel().astype("float64"), minlength=len(EC15_LAT) * len(EC15_LON)) with np.errstate(invalid="ignore", divide="ignore"): out[k] = (s / np.maximum(n, 1)).reshape(len(EC15_LAT), len(EC15_LON)) out[k][n.reshape(out[k].shape) == 0] = np.nan return out def load_year(a): root, pattern, year, inits, to15 = a out = None for k, mmdd in enumerate(inits): matches = glob.glob(os.path.join(root, str(year), pattern.format(year=year, mmdd=mmdd))) if not matches: raise FileNotFoundError(f"{year}-{mmdd}: no file for pattern {pattern}") with xr.open_dataset(matches[0]) as d: v = d["mx2t6"] arr = v.values.astype("float32") # (184, lat, lon), Kelvin if arr.shape[0] < NLEAD * NSTEP_PER_DAY: raise ValueError(f"{matches[0]}: only {arr.shape[0]} steps") arr = arr[:NLEAD * NSTEP_PER_DAY] daily = arr.reshape(NLEAD, NSTEP_PER_DAY, arr.shape[1], arr.shape[2]).max(axis=1) daily = daily - 273.15 # -> deg C if to15: la = d.latitude.values if "latitude" in d.coords else d.lat.values lo = d.longitude.values if "longitude" in d.coords else d.lon.values daily = to_ec15(daily, *ec15_weights(la, lo)) if out is None: out = np.empty((len(inits),) + daily.shape, dtype="float32") out[k] = daily return year, out def main(): ap = argparse.ArgumentParser() ap.add_argument("--root", required=True) ap.add_argument("--pattern", required=True, help="glob inside //, may use {year} and {mmdd}") ap.add_argument("--out", required=True) ap.add_argument("--to-ec15", action="store_true", help="average onto the true 1.5 deg MARS grid") ap.add_argument("--jobs", type=int, default=10) ap.add_argument("--hdates", default="/home/tanpv/Data/QG.25.196/hdate_2023.txt") args = ap.parse_args() with open(args.hdates) as fh: inits = sorted({t for t in fh.read().split() if len(t) == 5 and t[2] == "-"}) # coordinates from the first file first = glob.glob(os.path.join(args.root, str(YEARS[0]), args.pattern.format(year=YEARS[0], mmdd=inits[0]))) if not first: print(f"no files under {args.root}", file=sys.stderr) return 1 with xr.open_dataset(first[0]) as d: lat0 = d.latitude.values if "latitude" in d.coords else d.lat.values lon0 = d.longitude.values if "longitude" in d.coords else d.lon.values if args.to_ec15: lat0, lon0 = EC15_LAT, EC15_LON print(f"{len(inits)} inits, {len(YEARS)} years, grid {len(lat0)}x{len(lon0)}, " f"to_ec15={args.to_ec15}", flush=True) data = np.empty((len(YEARS), len(inits), NLEAD, len(lat0), len(lon0)), dtype="float32") tasks = [(args.root, args.pattern, y, inits, args.to_ec15) for y in YEARS] with ProcessPoolExecutor(max_workers=args.jobs) as pool: for year, arr in pool.map(load_year, tasks): data[YEARS.index(year)] = arr print(f" {year} done", flush=True) ds = xr.Dataset( {"tx_ec": (("year", "init", "lead", "lat", "lon"), data, {"units": "C", "long_name": "forecast daily maximum 2m temperature", "note": "daily max of the four 6-hourly mx2t6 values of each lead day"})}, coords={"year": YEARS, "init": inits, "lead": np.arange(NLEAD), "lat": lat0, "lon": lon0}, ) ds.attrs["source_root"] = args.root ds.attrs["grid"] = "ec15" if args.to_ec15 else "native" os.makedirs(os.path.dirname(args.out), exist_ok=True) ds.to_netcdf(args.out, encoding={"tx_ec": {"zlib": True, "complevel": 4}}) print(f"wrote {args.out} ({os.path.getsize(args.out)/1e9:.2f} GB)") print(f" tx range {np.nanmin(data):.1f} .. {np.nanmax(data):.1f} C") return 0 if __name__ == "__main__": sys.exit(main())