Skip to content

Commit 113a63e

Browse files
authored
Merge pull request #3397 from samsrabin/query-paramfile
New tools: query_paramfile and set_paramfile
2 parents fb8550e + 73303fb commit 113a63e

22 files changed

+2723
-0
lines changed

.git-blame-ignore-revs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,8 @@ cdf40d265cc82775607a1bf25f5f527bacc97405
7070
4ad46f46de7dde753b4653c15f05326f55116b73
7171
75db098206b064b8b7b2a0604d3f0bf8fdb950cc
7272
84609494b54ea9732f64add43b2f1dd035632b4c
73+
7eb17f3ef0b9829fb55e0e3d7f02e157b0e41cfb
74+
62d7711506a0fb9a3ad138ceceffbac1b79a6caa
75+
49ad0f7ebe0b07459abc00a5c33c55a646f1e7e0
7376
ac03492012837799b7111607188acff9f739044a
7477
d858665d799690d73b56bcb961684382551193f4
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
CTSM-specific test that first runs the set_paramfile tool and then ensures that CTSM does not fail
3+
using the just-generated parameter file
4+
"""
5+
6+
import os
7+
import sys
8+
import logging
9+
import re
10+
from CIME.SystemTests.system_tests_common import SystemTestsCommon
11+
12+
# In case we need to import set_paramfile later
13+
_CTSM_PYTHON = os.path.join(
14+
os.path.dirname(os.path.realpath(__file__)), os.pardir, os.pardir, "python"
15+
)
16+
sys.path.insert(1, _CTSM_PYTHON)
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class SETPARAMFILE(SystemTestsCommon):
22+
def __init__(self, case):
23+
"""
24+
initialize an object interface to the SMS system test
25+
"""
26+
SystemTestsCommon.__init__(self, case)
27+
28+
# Create out-of-the-box lnd_in to obtain paramfile
29+
case.create_namelists(component="lnd")
30+
31+
# Find the paramfile to modify
32+
lnd_in_path = os.path.join(self._get_caseroot(), "CaseDocs", "lnd_in")
33+
self._paramfile_in = None
34+
with open(lnd_in_path, "r", encoding="utf-8") as lnd_in:
35+
for line in lnd_in:
36+
paramfile_in = re.match(r" *paramfile *= *'(.*)'", line)
37+
if paramfile_in:
38+
self._paramfile_in = paramfile_in.group(1)
39+
break
40+
if not self._paramfile_in:
41+
raise RuntimeError(f"paramfile not found in {lnd_in_path}")
42+
43+
# Get the output file
44+
self.paramfile_out = os.path.join(self._get_caseroot(), "paramfile.nc")
45+
46+
# Define set_paramfile command
47+
self.set_paramfile_cmd = [
48+
"set_paramfile",
49+
"-i",
50+
self._paramfile_in,
51+
"-o",
52+
self.paramfile_out,
53+
# Change two parameters for one PFT
54+
"-p",
55+
"needleleaf_deciduous_boreal_tree",
56+
"rswf_min=0.35",
57+
"rswf_max=0.7",
58+
]
59+
60+
def build_phase(self, sharedlib_only=False, model_only=False):
61+
"""
62+
Run set_paramfile and then build the model
63+
"""
64+
65+
# Run set_paramfile.
66+
# build_phase gets called twice:
67+
# - once with sharedlib_only = True and
68+
# - once with model_only = True
69+
# Because we only need set_paramfile run once, we only do it for the sharedlib_only call.
70+
# We could also check for the existence of the set_paramfile outputs, but that might lead to
71+
# a situation where the user expects set_paramfile to be called but it's not. Better to run
72+
# unnecessarily (e.g., if you fixed some FORTRAN code and just need to rebuild).
73+
if sharedlib_only:
74+
self._run_set_paramfile()
75+
76+
# Do the build
77+
self.build_indv(sharedlib_only=sharedlib_only, model_only=model_only)
78+
79+
def _run_set_paramfile(self):
80+
"""
81+
Run set_paramfile
82+
"""
83+
# Import set_paramfile. Do it here rather than at top because otherwise the import will
84+
# be attempted even during RUN phase.
85+
# pylint: disable=wrong-import-position,import-outside-toplevel
86+
from ctsm.param_utils.set_paramfile import main as set_paramfile
87+
88+
# Run set_paramfile
89+
sys.argv = self.set_paramfile_cmd
90+
set_paramfile()
91+
92+
# Append
93+
user_nl_clm_path = os.path.join(self._get_caseroot(), "user_nl_clm")
94+
with open(user_nl_clm_path, "a", encoding="utf-8") as user_nl_clm:
95+
user_nl_clm.write(f"paramfile = '{self.paramfile_out}'\n")

cime_config/config_tests.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ This defines various CTSM-specific system tests
155155
<HIST_N>$STOP_N</HIST_N>
156156
</test>
157157

158+
<test NAME="SETPARAMFILE">
159+
<DESC>Modify a copy of the paramfile and run with it.</DESC>
160+
<INFO_DBUG>1</INFO_DBUG>
161+
<DOUT_S>FALSE</DOUT_S>
162+
<CONTINUE_RUN>FALSE</CONTINUE_RUN>
163+
<REST_OPTION>never</REST_OPTION>
164+
<HIST_OPTION>$STOP_OPTION</HIST_OPTION>
165+
<HIST_N>$STOP_N</HIST_N>
166+
</test>
167+
158168
<!--
159169
SSP smoke CLM spinup test (only valid for CLM compsets with CLM45)
160170
do an initial spin test (setting CLM_ACCELERATED_SPINUP to on)

cime_config/testdefs/testlist_clm.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4495,5 +4495,15 @@
44954495
</options>
44964496
</test>
44974497

4498+
<test name="SETPARAMFILE_Ld5" grid="f10_f10_mg37" compset="I1850Clm60BgcCrujra" testmods="clm/default">
4499+
<machines>
4500+
<machine name="derecho" compiler="gnu" category="aux_clm"/>
4501+
<machine name="derecho" compiler="gnu" category="clm_pymods"/>
4502+
</machines>
4503+
<options>
4504+
<option name="wallclock">00:20:00</option>
4505+
</options>
4506+
</test>
4507+
44984508

44994509
</testlist>

doc/source/users_guide/using-clm-tools/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ Using CLM tools
2222
creating-domain-files.rst
2323
observational-sites-datasets.rst
2424
cprnc.rst
25+
paramfile-tools.md
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
2+
# Tools for working with parameter files
3+
4+
This guide describes the features and usage of the `query_paramfile` and `set_paramfile` tools, located in `tools/param_utils/`. These utilities help users inspect and modify CLM parameter files.
5+
6+
Note that you need to have the `ctsm_pylib` conda environment activated to use these tools. See Sect. :numref:`using-ctsm-pylib` for more information.
7+
8+
## `query_paramfile`
9+
**Purpose:** Print the values of one or more parameters from a CTSM parameter file (NetCDF format).
10+
11+
**Features:**
12+
- Print values for specified parameters or all.
13+
- Optionally filter output by Plant Functional Types (PFTs) for PFT-specific parameters.
14+
15+
For more information, do `tools/param_utils/query_paramfile --help`.
16+
17+
18+
### Example usage
19+
20+
Print all variables in a parameter file:
21+
```bash
22+
tools/param_utils/query_paramfile -i paramfile.nc
23+
```
24+
25+
Print specific variables:
26+
```bash
27+
tools/param_utils/query_paramfile -i paramfile.nc jmaxha jmaxhd
28+
```
29+
30+
Print values for specific PFTs:
31+
```bash
32+
tools/param_utils/query_paramfile -i paramfile.nc -p needleleaf_evergreen_temperate_tree,c4_grass medlynintercept medlynslope
33+
```
34+
35+
## `set_paramfile`
36+
**Purpose:** Change values of one or more parameters in a CTSM parameter file (NetCDF format).
37+
38+
**Features:**
39+
- Modify parameter values for all or selected PFTs.
40+
- Optionally drop PFTs not specified.
41+
- Set parameter values to fill (missing) values using `nan`.
42+
- Ensures safe file handling and checks for argument validity.
43+
44+
Note that the output file must not already exist.
45+
46+
For more information, do `tools/param_utils/set_paramfile --help`.
47+
48+
### Example usage
49+
50+
Change a scalar parameter:
51+
```bash
52+
tools/param_utils/set_paramfile -i paramfile.nc -o output.nc jmaxha=51000
53+
```
54+
55+
Change a one-dimensional parameter (`mimics_fmet` has the `segment` dimension, length 4):
56+
```bash
57+
tools/param_utils/set_paramfile -i paramfile.nc -o output.nc mimics_fmet=0.1,0.2,0.3,0.4
58+
```
59+
60+
Change a parameter for specific PFTs:
61+
```bash
62+
tools/param_utils/set_paramfile -i paramfile.nc -o output.nc -p needleleaf_evergreen_temperate_tree,c4_grass medlynintercept=99.9,100.1 medlynslope=2.99,1.99
63+
```
64+
65+
Set a parameter to the fill value:
66+
```bash
67+
tools/param_utils/set_paramfile -i paramfile.nc -o output.nc -p needleleaf_evergreen_temperate_tree,c4_grass fleafcn=nan,nan
68+
```

python/ctsm/args_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,12 @@ def plon_type(plon):
4646
"ERROR: Longitude should be between 0 and 360 or -180 and 180."
4747
)
4848
return plon_float
49+
50+
51+
def comma_separated_list(value):
52+
"""
53+
Helper function for argparse to split comma-separated strings into a list.
54+
"""
55+
if value is None:
56+
return None
57+
return [v.strip() for v in value.split(",")]

python/ctsm/netcdf_utils.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
Helper functions for working with netCDF files
3+
"""
4+
5+
import numpy as np
6+
import xarray as xr
7+
from netCDF4 import Dataset # pylint: disable=no-name-in-module
8+
9+
10+
def _is_dtype_nan_capable(ndarray: np.ndarray):
11+
"""
12+
Given a numpy array, return True if it's capable of taking a NaN
13+
"""
14+
try:
15+
np.isnan(ndarray)
16+
return True
17+
except TypeError:
18+
return False
19+
20+
21+
def _are_dicts_identical_nansequal(dict0: dict, dict1: dict, keys_to_ignore=None):
22+
"""
23+
Compare two dictionaries, considering NaNs to be equal. Don't be strict here about types; if
24+
they can be coerced to comparable types and then they match, return True.
25+
"""
26+
# pylint: disable=too-many-return-statements
27+
28+
if keys_to_ignore is None:
29+
keys_to_ignore = []
30+
keys_to_ignore = np.array(keys_to_ignore)
31+
32+
if len(dict0) != len(dict1):
33+
return False
34+
for key, value0 in dict0.items():
35+
if key in keys_to_ignore:
36+
continue
37+
if key not in dict1:
38+
return False
39+
value1 = dict1[key]
40+
41+
# Coerce to numpy arrays to simplify comparison code
42+
value0 = np.array(value0)
43+
value1 = np.array(value1)
44+
45+
# Compare, only asking to check equal NaNs if both are capable of taking NaN values
46+
both_are_nan_capable = _is_dtype_nan_capable(value0) and _is_dtype_nan_capable(value1)
47+
if not np.array_equal(value0, value1, equal_nan=both_are_nan_capable):
48+
return False
49+
50+
return True
51+
52+
53+
def get_netcdf_format(file_path):
54+
"""
55+
Get format of netCDF file
56+
"""
57+
with Dataset(file_path, "r") as netcdf_file:
58+
netcdf_format = netcdf_file.data_model
59+
return netcdf_format
60+
61+
62+
def _is_dataarray_metadata_identical(da0: xr.DataArray, da1: xr.DataArray, keys_to_ignore=None):
63+
"""
64+
Check whether two DataArrays have identical-enough metadata
65+
"""
66+
67+
# Check data type
68+
if da0.dtype != da1.dtype:
69+
return False
70+
71+
# Check encoding
72+
if not _are_dicts_identical_nansequal(
73+
da0.encoding, da1.encoding, keys_to_ignore=keys_to_ignore
74+
):
75+
return False
76+
77+
# Check attributes
78+
if not _are_dicts_identical_nansequal(da0.attrs, da1.attrs):
79+
return False
80+
81+
# Check name
82+
if da0.name != da1.name:
83+
return False
84+
85+
# Check dims
86+
if da0.dims != da1.dims:
87+
return False
88+
89+
return True
90+
91+
92+
def _is_dataarray_data_identical(da0: xr.DataArray, da1: xr.DataArray):
93+
"""
94+
Check whether two DataArrays have identical data
95+
"""
96+
# pylint: disable=too-many-return-statements
97+
98+
# Check sizes
99+
if da0.sizes != da1.sizes:
100+
return False
101+
102+
# Check coordinates
103+
if bool(da0.coords) or bool(da1.coords):
104+
if not bool(da0.coords) or not bool(da1.coords):
105+
return False
106+
if not da0.coords.equals(da1.coords):
107+
return False
108+
109+
# Check values ("The array's data converted to numpy.ndarray")
110+
if not np.array_equal(da0.values, da1.values):
111+
# Try-except to avoid TypeError from putting NaN-incapable dtypes through
112+
# np.array_equal(..., equal_nan=True)
113+
try:
114+
if not np.array_equal(da0.values, da1.values, equal_nan=True):
115+
return False
116+
except TypeError:
117+
return False
118+
119+
# Check data ("The DataArray's data as an array. The underlying array type (e.g. dask, sparse,
120+
# pint) is preserved.")
121+
da0_data_type = type(da0.data)
122+
if not isinstance(da1.data, da0_data_type):
123+
return False
124+
if not isinstance(da0.data, np.ndarray):
125+
raise NotImplementedError(f"Add support for comparing two objects of type {da0_data_type}")
126+
127+
return True
128+
129+
130+
def are_xr_dataarrays_identical(da0: xr.DataArray, da1: xr.DataArray, keys_to_ignore=None):
131+
"""
132+
Comprehensively check whether two DataArrays are identical
133+
"""
134+
if not _is_dataarray_metadata_identical(da0, da1, keys_to_ignore=keys_to_ignore):
135+
return False
136+
137+
if not _is_dataarray_data_identical(da0, da1):
138+
return False
139+
140+
# Fallback to however xarray defines equality, in case we missed something above
141+
return da0.equals(da1)

python/ctsm/param_utils/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)