File size: 22,326 Bytes
bf0a67c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
"""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()