Skip to content

Commit 5316acf

Browse files
ESM1.6 style output filenames (#6)
* Rough draft and tests for esm1.6 style output filenames * Fixed bug in tests * Need to add time values to one more cdl file * Removed unnecessary double space * Removed an excess newline * Moved a TODO * Added a sub-daily datestamp format * Added cases to the frequency identification, explicit 4 digit years, tests * Filename timestamp creation now uses time_bnds if possible. open_dataset time handling tweaked. Ommitted test case now passes, another now fails * Updated ToDo for yearly/subhourly files * Added command line arg for file-freq. Commented out previously failing test * Tweaked handling of commandline options so that required ones can be specified again. * Ice timestep files now fail to parse since freq is unknown. Updated test to expect failure in this case * Updated comments to indicate no sub-hourly data expected * Default arguments for esm1p6_filename now match default commandline option * Updated test to match comment * Tweaked handling of frequency parsing to support Xhr/day/mon for ice files + tests * Extracted ESM1.6 filename fucntionality to a separate file * Instantaneous files now use "snap" for filename, added tests and better detection of instantaneous files * Tweaked _build_cell_methods to make it more reliable and consistent * Attempting to move to src-layout since there are multiple modules now. Also added commandline script * Github runner is struggling with diskspace - so cleaning up ncfiles in tests * Py3.10 doesn't have exception.add_note, missing f for str * Removed unused import * Updated readme with new commanline options and commandline script * Added case for cell_method to catch instantaneous atmospheric fields * Corrected test case that covers instantaneous atmos field
1 parent 122c9fe commit 5316acf

21 files changed

Lines changed: 31268 additions & 26 deletions

splitnc/README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Newline characters in the file will be treated as whitespace, i.e. newlines can
2828

2929
For example to replicate this command line,
3030
```
31-
python splitnc.py --verbose --overwrite --output-dir /output/directory --shared-vars latitude_longitude --rename-regex "(?P<newname>.+)_\d+" /input/directory/*.nc
31+
splitnc --verbose --overwrite --output-dir /output/directory --shared-vars latitude_longitude --rename-regex "(?P<newname>.+)_\d+" /input/directory/*.nc
3232
```
3333
the following file could be used;
3434
```
@@ -68,6 +68,16 @@ options:
6868
--rename-regex REGEX Look for duplicated coordinate names that match the given regex and rename them to the first
6969
"newname" capture group in the regex. E.g. "(?P<newname>.*)_\d+" will match "time_0" and rename
7070
it to "time".
71+
--use-esm1p6-filenames
72+
Use the ESM1.6 filename pattern for the output files:
73+
access-esm1p6.{component}.{dimensions}.{field}.{freq}.{time_cell_method}.{datestamp}.nc
74+
splitnc will attempt to deduce all the components of the filename. If this option is not given
75+
{field}_{original_filename} will be used.
76+
--file-freq FILE_FREQ
77+
Specify the frequency of the files (not the data), e.g. if each file contains a month of data
78+
then the file-frequency is '1mon'. Used to determine the resolution of the timestamp for ESM1.6
79+
filenames. Follows the ACCESS frequency vocabulary (e.g. '1yr', '1mon', '1day', '1hr'), any
80+
unrecognised frequency will use the full timestamp. Defaults to '1yr'.
7181
--output-dir OUTPUT_DIR
7282
Output directory for the processed files. If not given output files will be placed in the same
7383
directory as the original file.
@@ -89,7 +99,7 @@ Alternatively create a new python environment and install `xarray` and `netCDF4`
8999
### Atmosphere
90100
To use this script for split multi-field atmosphere files from ACCESS-ESM1.6:
91101
```bash
92-
python split-nc.py --shared-vars latitude_longitude --rename-regex "(?P<newname>.+)_\\d+" $INPUT_DIR/*.nc
102+
splitnc --shared-vars latitude_longitude --rename-regex "(?P<newname>.+)_\\d+" $INPUT_DIR/*.nc
93103
```
94104

95105
`splitnc` will automatically determine which variables are fields by looking at which variables depend on other variables.
@@ -105,7 +115,7 @@ included in all files even though none of the field variable depend on it.
105115
### Ice
106116
To use this script for split multi-field ice files from ACCESS-ESM1.6:
107117
```bash
108-
python split-nc.py --shared-vars uarea,tmask,tarea --excluded-vars VGRD. $INPUT_DIR/*.nc
118+
splitnc --shared-vars uarea,tmask,tarea --excluded-vars VGRD. $INPUT_DIR/*.nc
109119
```
110120

111121
In comparison to the atmosphere files, ice files have different shared-vars and there are no duplicated variables that require renaming.

splitnc/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ dependencies = [
2222
"xarray>2025.1.2",
2323
]
2424

25+
[project.scripts]
26+
splitnc = "splitnc:main"
27+
2528
[build-system]
2629
build-backend = "setuptools.build_meta"
2730
requires = [
@@ -30,4 +33,4 @@ requires = [
3033

3134
[tool.pytest.ini_options]
3235
testpaths = "test"
33-
pythonpath = "."
36+
pythonpath = "src"

splitnc/src/splitnc/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .splitnc import *

splitnc/src/splitnc/esm1p6.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import logging
2+
import re
3+
4+
5+
def _build_model():
6+
# Model is always access-esm1p6
7+
return "access-esm1p6"
8+
9+
10+
def _build_component(ds):
11+
# Component: either CICE5 or UM7.3
12+
source = ds.attrs["source"]
13+
if "Los Alamos Sea Ice Model (CICE) Version 5" in source:
14+
return "cice5"
15+
elif "Data from Met Office Unified Model" in source and \
16+
ds.attrs['um_version'] == "7.3":
17+
return "um7p3"
18+
else:
19+
raise ValueError(f"Unknown source, {source}")
20+
21+
22+
def _build_dimensions(ds, field_name):
23+
# Dimensions: Don't count time when seeing if field is 2d or 3d
24+
ndims = len([d for d in ds[field_name].dims if d!='time'])
25+
if ndims == 2:
26+
return "2d"
27+
elif ndims == 3:
28+
return "3d"
29+
else:
30+
raise ValueError(f"Unexpected number for dimensions, {ndims}")
31+
32+
33+
def _build_frequency(ds, field_name, input_filepath):
34+
# Frequency: use fx if no time dim
35+
if 'time' not in ds[field_name].dims:
36+
return "fx"
37+
38+
# Attempt to parse from expected filenames
39+
filename = input_filepath.name
40+
41+
# Define the expected ice filenames
42+
# e.g. iceh-2hourly-mean_0272.nc, iceh-1yearly-mean_0272.nc
43+
ice_regex = r"iceh-(?P<num>\d+)(?P<unit>yearly|monthly|daily|hourly)-"
44+
ice_unit_mapping = {
45+
"yearly": "yr",
46+
"monthly": "mon",
47+
"daily": "day",
48+
"hourly": "hr"
49+
}
50+
51+
if match:=re.match(ice_regex, filename):
52+
# Extract the frequency number and units for ice files
53+
return f"{match['num']}{ice_unit_mapping[match['unit']]}"
54+
elif "_mon.nc" in filename:
55+
# Match the monthly pattern for atmosphere files
56+
return "1mon"
57+
elif "_dai.nc" in filename:
58+
# Match the daily pattern for atmosphere files
59+
return "1day"
60+
elif match:=re.match(r".+_(\d+hr).nc", filename):
61+
# Get the frequency from the atmosphere regex match for Xhr
62+
return match[1]
63+
elif "aiihca.pc" in filename:
64+
# Match another pattern for hourly atmosphere files
65+
return "1hr"
66+
67+
# No sub-hourly frequency data expected
68+
raise ValueError("Unable to deduce frequency from filename")
69+
70+
71+
def _build_cell_method(ds, field_name):
72+
attrs = ds[field_name].attrs
73+
74+
try:
75+
if attrs['time_rep'] == "instantaneous":
76+
# ice files sometimes have time_rep = instantaneous but not
77+
# cell_methods = time: point
78+
return ".snap"
79+
except KeyError:
80+
# Continue if 'time_rep' not in attrs
81+
pass
82+
83+
# Time cell_method: Should be able to deduce from the cell_method
84+
cell_method_regx = r"time: (\w+)"
85+
try:
86+
if m:= re.search(cell_method_regx, attrs["cell_methods"]):
87+
method = m[1]
88+
if method == "point":
89+
method = "snap"
90+
91+
# Since this element is optional add the . here
92+
return "." + method
93+
except KeyError:
94+
# Continue if 'cell_methods' not in attrs
95+
pass
96+
97+
# If there's time but no time_bnds and no time cell_method then assume snap
98+
# This case is intended to catch instantaneous atmospheric fields from um2nc
99+
if "time" in ds and "bounds" not in ds["time"].attrs:
100+
return ".snap"
101+
102+
# Otherwise omit this element from the filename
103+
return ""
104+
105+
106+
def _build_datestamp(ds, field_name, file_freq):
107+
if 'time' not in ds[field_name].dims:
108+
# No datetime for fixed files
109+
return ""
110+
111+
# Truncate average time val by output file frequency
112+
# datetimes do not correctly zero-pad so need to use %4Y
113+
if re.match(r'\d+(yr|dec)', file_freq):
114+
fmt = '%4Y'
115+
elif re.match(r'\d+mon', file_freq):
116+
fmt = '%4Y-%m'
117+
elif re.match(r'\d+day', file_freq):
118+
fmt = '%4Y-%m-%d'
119+
else:
120+
fmt = '%4Y-%m-%dT%H:%M:%S'
121+
122+
# Get the appropriately truncated datetime for the average time
123+
try:
124+
# Try the time bounds
125+
time_arr = ds[ds['time'].attrs["bounds"]]
126+
logging.debug("Using time bounds to calculate filename timestamp")
127+
except KeyError:
128+
# If there are no time bounds just use time
129+
logging.debug("Unable to find time bounds, using time to calculate filename timestamp")
130+
time_arr = ds['time']
131+
132+
# Calculate the middle point
133+
first, last = time_arr.min(), time_arr.max()
134+
datestamp_dt = (first + (last - first) / 2).dt
135+
136+
return "." + datestamp_dt.strftime(fmt).data.flatten()[0]
137+
138+
139+
def build_esm1p6_filename(ds, field_name, input_filepath, esm1p6_filename=False, file_freq="1yr"):
140+
template = "{model}.{component}.{dimensions}.{field}.{freq}{time_cell_method}{datestamp}.nc"
141+
142+
# Model is always access-esm1p6
143+
try:
144+
d = {
145+
"model": _build_model(),
146+
"component": _build_component(ds),
147+
"dimensions": _build_dimensions(ds, field_name),
148+
"field": field_name,
149+
"freq": _build_frequency(ds, field_name, input_filepath),
150+
"time_cell_method": _build_cell_method(ds, field_name),
151+
"datestamp": _build_datestamp(ds, field_name, file_freq),
152+
}
153+
except ValueError as e:
154+
# Reraise the exception with some extra information
155+
e.args = (*e.args, f"While building output filename for field {field_name} and {input_filepath}")
156+
raise
157+
158+
return template.format(**d)
Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import xarray as xr
1212

13+
from splitnc.esm1p6 import build_esm1p6_filename
14+
1315

1416
def determine_field_vars(ds):
1517
"""
@@ -232,6 +234,25 @@ def update_history_attr(ds, new_history):
232234
ds.attrs["history"] = old_history + new_history
233235

234236

237+
def build_filename(ds, field_name, input_filepath, esm1p6_filename=False, file_freq="1yr"):
238+
"""
239+
Build the filename used for the output.
240+
241+
If esm1p6_filename=False then <field_name>_<orginal_file_name> will be used.
242+
243+
Otherwise a filename that follows the ESM1.6 naming scheme will be used:
244+
{model}.{component}.{dimensions}.{field}.{freq}.{time_cell_method}.{datestamp}.nc
245+
More info here: https://access-om3-configs.access-hive.org.au/configurations/Ocean_diagnostics/
246+
Elements of this schema will be deduced from the Dataset, the original filename,
247+
and the given output file frequency.
248+
"""
249+
if esm1p6_filename:
250+
return build_esm1p6_filename(ds, field_name, input_filepath,
251+
esm1p6_filename=esm1p6_filename, file_freq=file_freq)
252+
else:
253+
return f"{field_name}_{input_filepath.name}"
254+
255+
235256
def process_file(
236257
filepath,
237258
field_vars=None,
@@ -241,12 +262,14 @@ def process_file(
241262
output_dir=None,
242263
overwrite=False,
243264
update_history=True,
265+
esm1p6_filename=False,
266+
file_freq="1yr",
244267
):
245268
logging.debug(f"Processing {filepath}")
246269
filepath = Path(filepath)
247270

248271
# Use cftime to suppress warnings
249-
decoder = xr.coders.CFDatetimeCoder(use_cftime=True)
272+
decoder = xr.coders.CFDatetimeCoder(time_unit='us')
250273
with xr.open_dataset(filepath, decode_times=decoder) as ds:
251274
# Resolve any regex in the excluded_vars list
252275
if excluded_vars:
@@ -342,22 +365,36 @@ def process_file(
342365
else:
343366
output_dir = filepath.parent
344367

345-
output_filename = output_dir / f"{v}_{filepath.name}"
346-
logging.debug(f"Output filepath is {output_filename}")
347-
348-
if not overwrite and output_filename.exists():
349-
logging.error(f"Output file already exists - {output_filename}")
368+
# Build the output filepath
369+
filename = build_filename(
370+
ds=ds_v,
371+
field_name=v,
372+
input_filepath=filepath,
373+
esm1p6_filename=esm1p6_filename,
374+
file_freq=file_freq,
375+
)
376+
output_filepath = output_dir / filename
377+
logging.debug(f"Output filepath is {output_filepath}")
378+
379+
# Write to file
380+
if not overwrite and output_filepath.exists():
381+
logging.error(f"Output file already exists - {output_filepath}")
350382
logging.error("Use --overwrite to overwrite existing files")
351383

352-
raise FileExistsError(f"{output_filename} already exists")
384+
raise FileExistsError(f"{output_filepath} already exists")
353385

354386
logging.debug("Creating parent directory and writing to output file")
355-
output_filename.parent.mkdir(parents=True, exist_ok=True)
356-
ds_v.to_netcdf(output_filename)
387+
output_filepath.parent.mkdir(parents=True, exist_ok=True)
388+
ds_v.to_netcdf(output_filepath)
357389

358390

359391
#### Main
360392
def arg_parse(cmdline_args=None):
393+
# If -c/--command-line-file is being used then all other args are ignored
394+
# This affects which are "required" (or nargs for filepaths)
395+
args = sys.argv if cmdline_args is None else cmdline_args
396+
cmd_file_arg_present = "-c" in args or "--command-line-file" in args
397+
361398
parser = argparse.ArgumentParser(
362399
prog="splitnc",
363400
description="Splits a multi-field netCDF file into separate one-field files",
@@ -384,7 +421,7 @@ def globbable_string_list(string_list):
384421
# required and --cmd-line-file can be used on it's own
385422
parser.add_argument(
386423
"filepaths",
387-
nargs="*",
424+
nargs="*" if cmd_file_arg_present else "+",
388425
default=[],
389426
type=globbable_string_list,
390427
help="One or more filepaths to process",
@@ -425,6 +462,24 @@ def globbable_string_list(string_list):
425462
'regex. E.g. "(?P<newname>.*)_\\d+" will match "time_0" and '
426463
'rename it to "time".',
427464
)
465+
parser.add_argument(
466+
"--use-esm1p6-filenames",
467+
action="store_true",
468+
help="Use the ESM1.6 filename pattern for the output files: "
469+
"access-esm1p6.{component}.{dimensions}.{field}.{freq}.{time_cell_method}.{datestamp}.nc"
470+
" splitnc will attempt to deduce all the components of the filename. "
471+
"If this option is not given {field}_{original_filename} will be used."
472+
)
473+
parser.add_argument(
474+
"--file-freq",
475+
default="1yr",
476+
help="Specify the frequency of the files (not the data), e.g. if each "
477+
"file contains a month of data then the file-frequency is '1mon'. Used "
478+
"to determine the resolution of the timestamp for ESM1.6 filenames. "
479+
"Follows the ACCESS frequency vocabulary (e.g. '1yr', '1mon', '1day', "
480+
"'1hr'), any unrecognised frequency will use the full timestamp. "
481+
"Defaults to '1yr'."
482+
)
428483
parser.add_argument(
429484
"--output-dir",
430485
help="Output directory for the processed files. If not given output "
@@ -496,6 +551,8 @@ def main():
496551
output_dir=args.output_dir,
497552
overwrite=args.overwrite,
498553
update_history=not args.dont_update_history,
554+
esm1p6_filename=args.use_esm1p6_filenames,
555+
file_freq=args.file_freq,
499556
)
500557

501558

splitnc/test/common.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
from pathlib import Path
3-
import pytest
43
import shlex
54
import subprocess
65

@@ -18,8 +17,9 @@ def runcmd(cmd, wd=None, env=None):
1817
)
1918

2019

21-
def make_nc(tmp_path, cdl_file, filename="test.nc"):
22-
filepath = f"{tmp_path}/{filename}"
20+
def make_nc(tmp_path, cdl_file):
21+
nc_filename = Path(cdl_file).with_suffix(".nc").name
22+
filepath = f"{tmp_path}/{nc_filename}"
2323
cmd = f"ncgen -o {filepath} {cdl_file}"
2424

2525
runcmd(cmd)

splitnc/test/data/aiihca.pa-234501_mon.cdl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,6 +1890,16 @@ variables:
18901890
fld_s33i002:cell_methods = "time: mean" ;
18911891
fld_s33i002:grid_mapping = "latitude_longitude" ;
18921892
fld_s33i002:coordinates = "sigma_theta surface_altitude theta_level_height" ;
1893+
float fld_artificial(time, model_theta_level_number, lat, lon) ;
1894+
fld_artificial:_FillValue = 1.e+20f ;
1895+
fld_artificial:long_name = "ATM TRACER 2 AFTER TS" ;
1896+
fld_artificial:um_stash_source = "m01s33i002" ;
1897+
fld_artificial:missing_value = 1.e+20f ;
1898+
fld_artificial:cell_methods = "time: point" ;
1899+
fld_artificial:grid_mapping = "latitude_longitude" ;
1900+
fld_artificial:coordinates = "sigma_theta surface_altitude theta_level_height" ;
1901+
fld_artificial:notes = "this variable was added manually to test 'time: point' filenames" ;
1902+
18931903

18941904
// global attributes:
18951905
:history = "File /scratch/p66/jxs599/access-esm/archive/Nov25-NewNitrogen-Nov25-NewNitrogen-6a5acd30/output200/atmosphere/aiihca.pai5jan converted with /g/data/vk83/apps/base_conda/envs/payu-1.2.0/lib/python3.10/site-packages/um2nc/um2netcdf.py 1.1.0 at 2025-12-04 20:03:16" ;

0 commit comments

Comments
 (0)