Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Postprocessing: history files to timeseries

CESM writes history files: one file per output interval, containing every variable. Analysis almost always wants the transpose — timeseries files: one file per variable, spanning many dates.

History.gen_ts() does that conversion. It splits each history file into per-variable pieces with ncks, concatenates them along time with ncrcat, and moves the results into a CESM-style output tree, parallelised over MPI ranks and worker processes.

Requirements

This notebook actually runs the conversion, which needs two things beyond x4c:

  • NCO (ncks, ncrcat) on PATH. On NCAR machines: module load nco.

  • mpi4py. gen_ts is written for MPI but runs correctly in a single process, which is what happens here — one rank, one worker.

The cell below checks both and reports what it finds.

import os
import numpy as np
import xarray as xr
import matplotlib.pyplot as plt
import nc_time_axis  # registers the cftime axis converter for matplotlib
import x4c

x4c.set_style('journal')

# after set_style: it resets rcParams from matplotlibrc defaults, which includes the
# backend, so assert the inline backend last or figures are never captured
%matplotlib inline

# The tutorial runs against a reduced copy of a real CESM case. It is published as a
# GitHub Release asset rather than committed, so the first call downloads it into
# ~/.cache/x4c (override with $X4C_CACHE_DIR) and later calls reuse it. Set
# $X4C_SAMPLE_DIR to point at a copy you already have.
case_dir = x4c.fetch_sample_data(case='cesm1', verbose=False)
casename = os.path.basename(case_dir)
print('x4c', x4c.__version__)
x4c 2026.6.11
import shutil, subprocess

for tool in ['ncks', 'ncrcat']:
    path = shutil.which(tool)
    print(f'{tool:8s} {path or "NOT FOUND -- run `module load nco`"}')

try:
    from mpi4py import MPI
    print(f'mpi4py   ok (rank {MPI.COMM_WORLD.Get_rank()} of '
          f'{MPI.COMM_WORLD.Get_size()})')
except ImportError as e:
    print('mpi4py   NOT FOUND:', e)
ncks     /glade/u/apps/casper/25.10/spack/opt/spack/nco/5.3.4/gcc/12.5.0/3oyj/bin/ncks
ncrcat   /glade/u/apps/casper/25.10/spack/opt/spack/nco/5.3.4/gcc/12.5.0/3oyj/bin/ncrcat
mpi4py   ok (rank 0 of 1)

The bundled history files

The sample ships twelve monthly atmosphere history files for year 1. Note the layout: <comp>/hist/<case>.<hstr>.<date>.nc, which is what History expects — different from the <comp>/proc/tseries/<freq>/ layout that Timeseries reads.

hist_dir = os.path.join(case_dir, 'atm', 'hist')
for f in sorted(os.listdir(hist_dir))[:4]:
    mb = os.path.getsize(os.path.join(hist_dir, f)) / 1e6
    print(f'  {mb:5.1f} MB  {f}')
print(f'  ... {len(os.listdir(hist_dir))} files total')
    5.3 MB  b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-01.nc
    5.3 MB  b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-02.nc
    5.3 MB  b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-03.nc
    5.3 MB  b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-04.nc
  ... 12 files total

Opening the archive

History scans for history files, works out which history streams exist from the filenames, and inspects the first file of each stream to list the time-varying variables it holds.

hcase = x4c.History(case_dir, comps=['atm'], casename=casename)
>>> case.root_dir: /glade/u/home/fengzhu/.cache/x4c/sample_data/cesm1_sample_data/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005
>>> case.casename: b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005
>>> case.paths["atm"]["cam.h0"] created
>>> case.vns["atm"]["cam.h0"] created
print('streams found:', hcase.comps_info['atm'])
print()
vns = hcase.vns['atm']['cam.h0']
print(f'{len(vns)} time-varying variables in cam.h0:')
print(', '.join(vns))
streams found: ['cam.h0']

14 time-varying variables in cam.h0:
FLNT, FSNT, ICEFRAC, LANDFRAC, LWCF, PRECC, PRECL, PS, Q, SWCF, T, TS, U, V

avoid_list filters the file search; once is excluded by default, because CESM writes a *.once.* file of time-invariant fields that has nothing to concatenate.

Running the conversion

timestep and timestep_unit set how much time goes into each output file. Here one year of monthly data becomes a single one-year file per variable.

staging_dirpath is where the intermediate per-variable pieces are written. On a real HPC run you point it at scratch and output_dirpath at campaign storage; gen_ts moves the finished files across and cleans up after itself.

import tempfile

work = tempfile.mkdtemp(prefix='x4c-gen_ts-')
out_dir = os.path.join(work, 'timeseries')
staging = os.path.join(work, 'staging')
print('output :', out_dir)
print('staging:', staging)
output : /glade/derecho/scratch/fengzhu/tmp/x4c-gen_ts-8cwngydw/timeseries
staging: /glade/derecho/scratch/fengzhu/tmp/x4c-gen_ts-8cwngydw/staging
import contextlib, io, time

# gen_ts writes tqdm progress bars to stderr. They are useful interactively but add
# hundreds of carriage-return fragments to a rendered notebook, so capture them here
# and report just the summary. Drop the redirect to watch it live.
buf = io.StringIO()
t0 = time.time()
with contextlib.redirect_stderr(buf):
    hcase.gen_ts(
        output_dirpath=out_dir,
        staging_dirpath=staging,
        comps=['atm'],
        timespan=(1, 1),          # year 1 only
        timestep=1,
        timestep_unit='year',
        nproc=1,
        overwrite=True,
    )
print(f'gen_ts finished in {time.time() - t0:.0f} s')

# last line of the captured progress output, as a sanity check
tail = [ln for ln in buf.getvalue().replace(chr(13), chr(10)).splitlines() if ln.strip()]
print('last progress line:', tail[-1][:100] if tail else '(none)')
>>> Processing component: atm
>>> Processing hstr: cam.h0
>>> Processing timespan: ('0001', '0001')
[Rank 0] Removed /glade/derecho/scratch/fengzhu/tmp/x4c-gen_ts-8cwngydw/staging/.bigbang_atm.cam.h0.0001-0001
gen_ts finished in 395 s
last progress line: [Rank 0] Moving files from /glade/derecho/scratch/fengzhu/tmp/x4c-gen_ts-8cwngydw/staging/atm/proc/t

What came out

One file per variable, named with the span it covers — the timeseries layout.

produced = []
for root, _, files in os.walk(out_dir):
    for f in sorted(files):
        if f.endswith('.nc'):
            produced.append(os.path.join(root, f))

print(f'{len(produced)} timeseries files produced\n')
print('relative to the output root:')
for p in produced[:6]:
    mb = os.path.getsize(p) / 1e6
    print(f'  {mb:5.1f} MB  {os.path.relpath(p, out_dir)}')
if len(produced) > 6:
    print(f'  ... and {len(produced) - 6} more')
14 timeseries files produced

relative to the output root:
    0.7 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.FLNT.000101-000112.nc
    0.7 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.FSNT.000101-000112.nc
    0.2 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.ICEFRAC.000101-000112.nc
    0.3 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.LANDFRAC.000101-000112.nc
    0.7 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.LWCF.000101-000112.nc
    0.7 MB  atm/proc/tseries/cam.h0/b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.PRECC.000101-000112.nc
  ... and 8 more

Checking the result

These history files were themselves built by regrouping the bundled timeseries, so the conversion should return what we started from. That makes a genuine round-trip test: compare gen_ts output against the shipped TS timeseries for the same months.

ts_new = [p for p in produced if '.TS.' in os.path.basename(p)][0]
print('generated:', os.path.basename(ts_new))

new = x4c.open_dataset(ts_new, comp='atm', grid='ne16np4', vn='TS')

ref_path = os.path.join(case_dir, 'atm', 'proc', 'tseries', 'month_1',
                        f'{casename}.cam.h0.TS.000101-000512.nc')
ref = x4c.open_dataset(ref_path, comp='atm', grid='ne16np4', vn='TS')
ref12 = ref.x.da.isel(time=slice(0, 12))

print()
print('generated:', dict(new.x.da.sizes))
print('reference:', dict(ref12.sizes))
print()
diff = float(np.abs(new.x.da.values - ref12.values).max())
print(f'max absolute difference: {diff:.3e} K')
print('round-trip exact:' , diff == 0.0)
generated: b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.TS.000101-000112.nc

generated: {'time': 12, 'ncol': 13826}
reference: {'time': 12, 'ncol': 13826}

max absolute difference: 0.000e+00 K
round-trip exact: True

The pieces underneath

gen_ts is a thin orchestration over two steps you can call directly, which is useful when a run fails part-way and you want to redo only one stage:

  • bigbang() — split history files into per-variable files (ncks)

  • bigcrunch() — concatenate those along time (ncrcat)

get_paths() filters the history files by timespan, and get_ts_vns() is what listed the variables above.

paths = hcase.get_paths('atm', 'cam.h0', timespan=('0001-01', '0001-03'))
print(f'{len(paths)} history files in 0001-01..0001-03:')
for p in paths:
    print('  ', os.path.basename(p))

print()
print('which stream holds TS?', hcase.get_hstr_based_on_vn('TS'))
3 history files in 0001-01..0001-03:
   b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-01.nc
   b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-02.nc
   b.e13.B1850C5.ne16_g16.icesm131_d18O_fixer.Miocene.3xCO2.005.cam.h0.0001-03.nc

which stream holds TS? cam.h0

Cleaning up an archive

find_timespan_files() lists the history files in a year range, and rm_timespan() deletes them — with rehearsal=True by default, which only reports what would go. This is the one destructive operation in x4c, so it resolves the file list in Python and deletes with os.remove rather than shelling out to rm.

found = hcase.find_timespan_files((1, 1), comps=['atm'])
print(f'{len(found)} files would be matched for year 1')

# rehearsal=True (the default) prints the list and deletes nothing
_ = hcase.rm_timespan((9000, 9000), comps=['atm'])
12 files would be matched for year 1
>>> No history files found for timespan (9000, 9000) in ['atm'].
import shutil as _sh
_sh.rmtree(work)
print('removed the temporary output tree')
removed the temporary output tree

At scale

For a real multi-century case you would drive this from a batch job rather than a notebook. docsrc/scripts/gen_ts_cesm*.zsh in the repository generate the Python driver plus a PBS script; the driver is just:

import x4c

case = x4c.History(
    '/glade/derecho/scratch/.../archive/<case>',
    comps=['atm', 'ice', 'lnd'],
    comps_info={'atm': ['cam.h0a', 'cam.h1a'], 'ice': ['cice.h']},
    casename='<case>',
)
case.gen_ts(
    comps=['atm', 'ice', 'lnd'],
    output_dirpath='<campaign>/timeseries/<case>',
    staging_dirpath='<scratch>/x4c/gen_ts/<case>',
    timespan=('0001', '0100'),
    timestep=10,
    timestep_unit='year',
    nproc=64,
    overwrite=True,
)

and is launched with mpiexec -n <ranks> python <driver>.py. Each rank takes a slice of the (file, variable) work, so the split stage scales close to linearly; nproc adds worker processes within each rank.