Skip to content

Commit c2ee6c0

Browse files
jjokellaclaude
andauthored
Introduce create_ensemble_namelists.py with documentation (#5)
Creates per-ensemble-member namelist files for eCLM-PDAF Data Assimilation experiments. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b92d1ea commit c2ee6c0

4 files changed

Lines changed: 353 additions & 0 deletions

File tree

create_ensemble_namelists.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""Create per-ensemble-member namelist files for eCLM-PDAF runs.
4+
5+
For each ensemble member 1..N, writes ensemble-specific copies of:
6+
- <mod>_modelio.nml_NNNN (atm, esp, glc, ice, lnd, ocn, rof, wav, cpl)
7+
- datm_in_NNNN
8+
- lnd_in_NNNN
9+
- mosart_in_NNNN
10+
- datm.streams_NNNN.* (XML stream description files)
11+
12+
Usage
13+
-----
14+
python create_ensemble_namelists.py [OPTIONS]
15+
16+
Options
17+
-------
18+
-r, --rundir DIR Run directory containing the base namelists (default: .)
19+
-n, --num_ensemble INT Number of ensemble members (default: 96)
20+
-b, --backend BACKEND Namelist backend: 're' (default) or 'f90nml'
21+
--suffix-fsurdat Add ensemble suffix to clm_inparm:fsurdat in lnd_in
22+
"""
23+
import argparse
24+
import os
25+
import sys
26+
27+
import f90nml
28+
from lxml import etree
29+
from io import BytesIO
30+
31+
from namelist_utils import _re_add_ens_suffix, _re_get_streams_filenames
32+
33+
34+
def adapt_modelio(mod, iens, backend="re"):
35+
"""Write <mod>_modelio.nml_NNNN from <mod>_modelio.nml.
36+
37+
Changes: logfile gets an ensemble suffix (_NNNN) inserted before the
38+
file extension, e.g. 'lnd.log' -> 'lnd_0003.log'.
39+
"""
40+
if backend == "f90nml":
41+
nml = f90nml.read(mod + "_modelio.nml")
42+
nml.indent = 1
43+
44+
nml["modelio"]["logfile"] = nml["modelio"]["logfile"].replace(
45+
".log", "_" + str(iens).zfill(4) + ".log")
46+
47+
nml.write(mod + "_modelio.nml_" + str(iens).zfill(4))
48+
49+
elif backend == "re":
50+
with open(mod + "_modelio.nml", "r") as fh:
51+
content = fh.read()
52+
53+
content = _re_add_ens_suffix(content, "logfile", iens)
54+
55+
with open(mod + "_modelio.nml_" + str(iens).zfill(4), "w") as fh:
56+
fh.write(content)
57+
58+
59+
def adapt_mosart_in(iens, backend="re"):
60+
"""Write mosart_in_NNNN from mosart_in (unchanged copy)."""
61+
if backend == "f90nml":
62+
nml = f90nml.read("mosart_in")
63+
nml.indent = 1
64+
nml.write("mosart_in_" + str(iens).zfill(4))
65+
66+
elif backend == "re":
67+
with open("mosart_in", "r") as fh:
68+
content = fh.read()
69+
with open("mosart_in_" + str(iens).zfill(4), "w") as fh:
70+
fh.write(content)
71+
72+
73+
def adapt_datm_in(iens, backend="re"):
74+
"""Write datm_in_NNNN from datm_in.
75+
76+
Changes:
77+
78+
Each entry in shr_strdata_nml:streams gets an ensemble suffix
79+
inserted before the first '.txt' in the filename, e.g.
80+
'datm.streams.txt.CLMCRUNCEPv7.Solar' ->
81+
'datm.streams_0003.txt.CLMCRUNCEPv7.Solar'.
82+
83+
"""
84+
if backend == "f90nml":
85+
nml = f90nml.read("datm_in")
86+
nml.indent = 1
87+
88+
for i, streamfile in enumerate(nml["shr_strdata_nml"]["streams"]):
89+
nml["shr_strdata_nml"]["streams"][i] = streamfile.replace(
90+
".txt", "_" + str(iens).zfill(4) + ".txt")
91+
92+
nml.write("datm_in_" + str(iens).zfill(4))
93+
94+
elif backend == "re":
95+
with open("datm_in", "r") as fh:
96+
content = fh.read()
97+
98+
for sf in _re_get_streams_filenames("datm_in"):
99+
ens_sf = sf.replace(".txt", "_" + str(iens).zfill(4) + ".txt", 1)
100+
content = content.replace(sf, ens_sf)
101+
102+
with open("datm_in_" + str(iens).zfill(4), "w") as fh:
103+
fh.write(content)
104+
105+
106+
def adapt_lnd_in(iens, suffix_fsurdat=False, backend="re"):
107+
"""Write lnd_in_NNNN from lnd_in.
108+
109+
If suffix_fsurdat is True, clm_inparm:fsurdat gets an ensemble suffix
110+
inserted before the file extension, e.g. 'surfdata.nc' -> 'surfdata_00003.nc'
111+
(zero-padded to 5 digits). Otherwise lnd_in_NNNN is an unchanged copy.
112+
"""
113+
if backend == "f90nml":
114+
nml = f90nml.read("lnd_in")
115+
nml.indent = 1
116+
117+
if suffix_fsurdat:
118+
nml["clm_inparm"]["fsurdat"] = nml["clm_inparm"]["fsurdat"].replace(
119+
".nc", "_" + str(iens).zfill(5) + ".nc")
120+
121+
nml.write("lnd_in_" + str(iens).zfill(4))
122+
123+
elif backend == "re":
124+
with open("lnd_in", "r") as fh:
125+
content = fh.read()
126+
if suffix_fsurdat:
127+
content = _re_add_ens_suffix(content, "fsurdat", iens, pad=5)
128+
with open("lnd_in_" + str(iens).zfill(4), "w") as fh:
129+
fh.write(content)
130+
131+
132+
def adapt_stream_files(iens, backend="re"):
133+
"""Write per-ensemble XML stream description files.
134+
135+
Must be called after adapt_datm_in, which determines the ensemble
136+
stream filenames.
137+
138+
For each stream file listed in datm_in, reads the original XML and
139+
writes an ensemble-specific copy (named as in datm_in_NNNN), with
140+
the fieldInfo/filePath entries under './forcings' redirected to
141+
'./forcings/real_NNNNN'.
142+
143+
"""
144+
# Must be called after datm update
145+
146+
if backend == "f90nml":
147+
# Read original stream files and new stream files
148+
nml_datm = f90nml.read("datm_in")
149+
nml_datm_ens = f90nml.read("datm_in_" + str(iens).zfill(4))
150+
151+
orig_streamfiles = nml_datm["shr_strdata_nml"]["streams"]
152+
ens_streamfiles = nml_datm_ens["shr_strdata_nml"]["streams"]
153+
154+
elif backend == "re":
155+
orig_streamfiles = _re_get_streams_filenames("datm_in")
156+
ens_streamfiles = _re_get_streams_filenames("datm_in_" + str(iens).zfill(4))
157+
158+
for orig, ens_sf in zip(orig_streamfiles, ens_streamfiles):
159+
orig_path = orig.split()[0] if backend == "f90nml" else orig
160+
ens_path = ens_sf.split()[0] if backend == "f90nml" else ens_sf
161+
162+
tree = etree.parse(orig_path)
163+
root = tree.getroot()
164+
165+
# Extract element "filePath" with parent element "fieldInfo"
166+
for filePath in root.iter("filePath"):
167+
# Check parent
168+
if filePath.getparent().tag == "fieldInfo":
169+
# print(filePath.getparent().tag, filePath.tag)
170+
if filePath.text.find("./forcings") > -1:
171+
filePath.text = filePath.text.replace(
172+
"./forcings", "./forcings/real_" + str(iens).zfill(5)
173+
)
174+
175+
# Write XML streamfile
176+
# --------------------
177+
178+
# Use buffer first
179+
fbuffer = BytesIO()
180+
tree.write(fbuffer, xml_declaration=True, encoding="ASCII")
181+
fstr = fbuffer.getvalue().decode("ASCII")
182+
183+
# Replace XML declaration to match original
184+
fstr = fstr.replace("version='1.0'", 'version="1.0"')
185+
fstr = fstr.replace(" encoding='ASCII'", "")
186+
187+
# Write to file
188+
with open(ens_path, "w+") as f:
189+
f.write(fstr)
190+
191+
192+
def create_ensemble_namelists(iens, suffix_fsurdat=False, backend="re"):
193+
"""Create all per-ensemble namelist files for ensemble member iens.
194+
195+
Calls:
196+
197+
- adapt_modelio for all nine CIME components (atm, esp, glc, ice,
198+
lnd, ocn, rof, wav, cpl)
199+
- adapt_datm_in
200+
- adapt_lnd_in
201+
- adapt_mosart_in
202+
- adapt_stream_files
203+
"""
204+
for m in ["atm", "esp", "glc", "ice", "lnd", "ocn", "rof", "wav", "cpl"]:
205+
adapt_modelio(m, iens, backend=backend)
206+
207+
adapt_datm_in(iens, backend=backend)
208+
209+
adapt_lnd_in(iens, suffix_fsurdat=suffix_fsurdat, backend=backend)
210+
211+
adapt_mosart_in(iens, backend=backend)
212+
213+
adapt_stream_files(iens, backend=backend)
214+
215+
216+
if __name__ == "__main__":
217+
parser = argparse.ArgumentParser(
218+
description="Create ensemble namelists for eCLM-PDAF runs"
219+
)
220+
parser.add_argument(
221+
"-r", "--rundir",
222+
default=".",
223+
help="Run directory containing the base namelists (default: current directory)",
224+
)
225+
parser.add_argument(
226+
"-n", "--num_ensemble",
227+
type=int,
228+
default=96,
229+
help="Number of ensemble members (default: 96)",
230+
)
231+
parser.add_argument(
232+
"-b", "--backend",
233+
choices=["f90nml", "re"],
234+
default="re",
235+
help="Backend for reading/writing namelists: 're' (default, regexp) or 'f90nml'",
236+
)
237+
parser.add_argument(
238+
"--suffix-fsurdat",
239+
action="store_true",
240+
default=False,
241+
help="Add ensemble suffix to clm_inparm:fsurdat in lnd_in (default: off)",
242+
)
243+
244+
args = parser.parse_args()
245+
246+
os.chdir(args.rundir)
247+
248+
for ens in range(1, args.num_ensemble + 1):
249+
create_ensemble_namelists(ens, suffix_fsurdat=args.suffix_fsurdat, backend=args.backend)
250+
sys.stdout.write("\r[%s] " % ("Done with ensemble member: " + str(ens)))
251+
sys.stdout.flush()
252+
253+
sys.stdout.write("\n")
254+
sys.stdout.flush()

docs/_toc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ parts:
1313
- file: users_guide/normalize_stream_files
1414
- file: users_guide/modify_stream_files
1515
- file: users_guide/modify_case_namelists
16+
- file: users_guide/create_ensemble_namelists

docs/users_guide/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ generation and manipulation.
1515

1616
- [modify_case_namelists.py](modify_case_namelists.md): Modifies
1717
individual key-value entries across eCLM case namelist files.
18+
19+
- [create_ensemble_namelists.py](create_ensemble_namelists.md):
20+
Creates per-ensemble-member namelist files for eCLM-PDAF Data
21+
Assimilation experiments.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# create_ensemble_namelists.py
2+
3+
> **Data Assimilation only**: this script is exclusively needed for
4+
> eCLM-PDAF experiments. It produces the per-member namelist files
5+
> that PDAF expects to find for each ensemble member. It is not
6+
> required for standard (single-instance) eCLM runs.
7+
8+
Creates per-ensemble-member copies of all eCLM namelist and stream
9+
files from a single set of base namelists.
10+
11+
## What it does
12+
13+
Starting from the base namelist files in the run directory, the script
14+
writes one set of ensemble-specific files for every member `1 .. N`.
15+
Each output filename carries a four-digit zero-padded member index as
16+
a suffix (`_NNNN`).
17+
18+
The following modifications are applied per file type:
19+
20+
| Output file | Source | Modification |
21+
|--------------------------|---------------------|---------------------------------------------------------------------------------------|
22+
| `{mod}_modelio.nml_NNNN` | `{mod}_modelio.nml` | `logfile` gets `_NNNN` inserted before `.log` |
23+
| `datm_in_NNNN` | `datm_in` | Each stream filename in `shr_strdata_nml:streams` gets `_NNNN` inserted before `.txt` |
24+
| `lnd_in_NNNN` | `lnd_in` | Unchanged copy; optionally `clm_inparm:fsurdat` gets a five-digit suffix |
25+
| `mosart_in_NNNN` | `mosart_in` | Unchanged copy |
26+
| `datm.streams_NNNN.*` | `datm.streams.*` | `fieldInfo/filePath` is redirected from `./forcings` to `./forcings/real_NNNNN` |
27+
28+
The `{mod}` components processed for modelio files are:
29+
`atm`, `esp`, `glc`, `ice`, `lnd`, `ocn`, `rof`, `wav`, `cpl`.
30+
31+
## Usage
32+
33+
```bash
34+
# Create namelists for 96 ensemble members (default) in the current directory
35+
python3 create_ensemble_namelists.py
36+
37+
# Specify run directory and ensemble size
38+
python3 create_ensemble_namelists.py \
39+
--rundir /path/to/rundir \
40+
--num_ensemble 50
41+
42+
# Also add per-member suffix to the surface data file path
43+
python3 create_ensemble_namelists.py \
44+
--rundir /path/to/rundir \
45+
--num_ensemble 50 \
46+
--suffix-fsurdat
47+
```
48+
49+
### Options
50+
51+
| Option | Description |
52+
|------------------------|------------------------------------------------------------------------------|
53+
| `-r`, `--rundir` | Directory containing the base namelist files. Defaults to `.` |
54+
| `-n`, `--num_ensemble` | Number of ensemble members to generate. Defaults to `96` |
55+
| `-b`, `--backend` | Namelist backend: `re` (default, regexp-based) or `f90nml` |
56+
| `--suffix-fsurdat` | Also add a five-digit member suffix to `clm_inparm:fsurdat` in `lnd_in_NNNN` |
57+
58+
## Expected run directory layout
59+
60+
The script reads the following base files from `--rundir` and must be
61+
called **after** the base namelists have been set up (e.g. with
62+
`modify_case_namelists.py`):
63+
64+
```
65+
rundir/
66+
atm_modelio.nml cpl_modelio.nml esp_modelio.nml
67+
glc_modelio.nml ice_modelio.nml lnd_modelio.nml
68+
ocn_modelio.nml rof_modelio.nml wav_modelio.nml
69+
datm_in
70+
lnd_in
71+
mosart_in
72+
datm.streams.txt.* (one file per DATM stream)
73+
```
74+
75+
## Output
76+
77+
The script prints a running status line and exits after all members are
78+
written:
79+
80+
```
81+
[Done with ensemble member: 50]
82+
```
83+
84+
Output files are written into the same directory as the base namelists:
85+
86+
```
87+
rundir/
88+
lnd_modelio.nml_0001 lnd_modelio.nml_0002 ... lnd_modelio.nml_0050
89+
datm_in_0001 datm_in_0002 ... datm_in_0050
90+
lnd_in_0001 lnd_in_0002 ... lnd_in_0050
91+
mosart_in_0001 mosart_in_0002 ... mosart_in_0050
92+
datm.streams_0001.txt.* datm.streams_0002.txt.* ...
93+
...
94+
```

0 commit comments

Comments
 (0)