Skip to content

Commit 0a54ccb

Browse files
authored
Merge pull request #4039 from Tejas7007/GH-4038-ilamb-multimodel
Add multi-model CMIP6/TRENDY benchmarking to inst/ilamb (#4038)
2 parents b96f0f0 + a08bffc commit 0a54ccb

11 files changed

Lines changed: 975 additions & 0 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ For more information about this file see also [Keep a Changelog](http://keepacha
1313
with units attached as an attribute.
1414
- New package `PEcAn.RothC` runs the RothC soil carbon model.
1515
- Added `inst/ilamb/` pipeline in PEcAn.benchmark to convert downscaled SDA reanalysis GeoTIFFs into ILAMB-compatible CF netCDF for carbon-cycle benchmarking (#4019).
16+
- Added multi-model benchmarking scripts to `inst/ilamb/` in PEcAn.benchmark: build the CMIP6 and TRENDY ensembles, score the PEcAn reanalysis and its individual SDA members against observational benchmarks over two evaluation windows, and summarize ensemble spread (#4038).
1617
- Added PEcAn.PEPRMT model, including a demo run with example data
1718
- Add `format_try_for_ma()` and `try_trait_mapping()` to `PEcAn.data.remote` to convert trait data from the external TRY database into the tabular format required by the PEcAn meta-analysis module (#3717).
1819
- Add function `qsub_sda()` for submitting SDA batch jobs by splitting a large number of sites into multiple small groups of sites (#3634).

modules/benchmark/inst/ilamb/README.md

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,196 @@ loading. Set `ILAMB_OUTPUT_DIR` to point the tests at your output directory.
103103
- A known structural discontinuity exists in the underlying LandTrendr input
104104
around 2017-2018 (see the ORNL DAAC documentation); it is preserved as-is in
105105
the converted output rather than adjusted here.
106+
107+
---
108+
109+
# Multi-Model Benchmarking (CMIP6 and TRENDY)
110+
111+
Building on the conversion pipeline above, these scripts benchmark the PEcAn
112+
reanalysis against two major model intercomparison ensembles, CMIP6 and TRENDY
113+
(Global Carbon Budget), using ILAMB, and score the individual PEcAn ensemble
114+
members so that PEcAn's skill spread can be compared directly with the model
115+
ensembles' spread.
116+
117+
Comparison is over North America for three state variables with established
118+
observational benchmarks: vegetation carbon (`cVeg`), soil carbon (`cSoil`),
119+
and leaf area index (`lai`). Soil moisture is supported by the conversion step
120+
but is not yet included in the multi-model scoring (see Scope below).
121+
122+
## Pipeline
123+
124+
Run in order; each step writes inputs for the next. The first script is the
125+
conversion tool documented above.
126+
127+
| Step | Script | Purpose |
128+
|------|--------|---------|
129+
| 1 | `convert_geotiff_to_ilamb.py` | PEcAn GeoTIFF ensemble maps to CF netCDF |
130+
| 2 | `build_cmip6_ensemble.py` | Download + regrid CMIP6 historical fields |
131+
| 3 | `build_cmip6_ssp.py` | Splice CMIP6 historical + ssp245 for a longer record |
132+
| 4 | `build_trendy_ensemble.py` | Download + regrid TRENDY (GCB) fields |
133+
| 5 | `build_window_ensembles.py` | Slice all models to an evaluation window; build ensemble means |
134+
| 6 | `build_pecan_members.py` | Per-member PEcAn fields for spread analysis |
135+
| 7 | `make_spread_figures.py` | Spread figure from ILAMB scores |
136+
137+
All model fields are regridded onto the same 0.5 degree North American grid the
138+
conversion step produces, so PEcAn, the observational benchmarks, and every
139+
model share one grid. LAI is reduced to its July value throughout, to match the
140+
July snapshot of the PEcAn product. The TRENDY ensemble mean is built only from
141+
members that pass a per-variable physical-plausibility screen; the members used
142+
are recorded in `trendy_ensemble_manifest.json`.
143+
144+
## Evaluation windows
145+
146+
The PEcAn reanalysis and the models are compared over two windows, which answer
147+
different questions:
148+
149+
- **2012-2014**: a mean-state snapshot using the full set of CMIP6 historical
150+
models (25), the broadest representative model sample.
151+
- **2015-2023**: a longer record for interannual variability and trends, using
152+
the CMIP6 models that provide a continuous historical + ssp245 land-carbon
153+
record (14).
154+
155+
Reporting both is deliberate: the longer window supports variability and trend
156+
analysis that three years cannot, while the 2012-2014 window retains the full
157+
model sample. The two windows also differ in CMIP6 composition, and that
158+
difference is itself informative; see the note on soil carbon below.
159+
160+
## ILAMB configuration
161+
162+
`pecan_ilamb.cfg` defines three confrontations:
163+
164+
| Variable | Benchmark | Notes |
165+
|----------|-----------|-------|
166+
| Biomass (cVeg) | Xu & Saatchi 2021 | RMSE skipped; mass-weighted |
167+
| Leaf Area Index | GIMMS LAI4g | seasonal-cycle and RMSE skipped |
168+
| Soil Carbon | HWSD2 | RMSE skipped; mass-weighted |
169+
170+
The configuration applies no time or region restriction itself; the evaluation
171+
window is set entirely by the windowed input files (step 5), and the North
172+
American extent is set by the grid.
173+
174+
## Results
175+
176+
ILAMB overall scores (0-1, higher is a closer match to the benchmark). PEcAn is
177+
the reanalysis ensemble mean; CMIP6 and TRENDY are ensemble means.
178+
179+
**2012-2014 (25 CMIP6 models)**
180+
181+
| Variable | PEcAn | CMIP6 | TRENDY |
182+
|----------|-------|-------|--------|
183+
| Biomass | 0.483 | 0.477 | 0.410 |
184+
| Leaf Area Index | 0.510 | 0.448 | 0.453 |
185+
| Soil Carbon | 0.625 | 0.634 | 0.478 |
186+
187+
**2015-2023 (14 CMIP6 models)**
188+
189+
| Variable | PEcAn | CMIP6 | TRENDY |
190+
|----------|-------|-------|--------|
191+
| Biomass | 0.464 | 0.473 | 0.409 |
192+
| Leaf Area Index | 0.513 | 0.448 | 0.454 |
193+
| Soil Carbon | 0.648 | 0.715 | 0.478 |
194+
195+
PEcAn scores above both model ensembles on leaf area index in every window, and
196+
above the TRENDY ensemble on all three variables. On biomass and soil carbon it
197+
is comparable to the CMIP6 ensemble.
198+
199+
The higher CMIP6 soil-carbon score in the 2015-2023 window (0.715) is a
200+
composition effect, not a change in the benchmark: the 14 models with a
201+
continuous ssp245 record happen to be stronger soil-carbon performers. The full
202+
25-model window (0.634) is the representative figure, where PEcAn and CMIP6 are
203+
close.
204+
205+
## Ensemble spread
206+
207+
Scoring the 100 individual PEcAn members alongside the individual CMIP6 and
208+
TRENDY models shows that the PEcAn members vary far less in skill than the
209+
models do. Standard deviation of member scores:
210+
211+
| Variable | PEcAn (100) | CMIP6 | TRENDY |
212+
|----------|-------------|-------|--------|
213+
| Biomass (2012-2014) | 0.003 | 0.057 | 0.086 |
214+
| LAI (2012-2014) | 0.004 | 0.082 | 0.145 |
215+
| Soil Carbon (2012-2014) | 0.003 | 0.166 | 0.137 |
216+
| Biomass (2015-2023) | 0.014 | 0.040 | 0.086 |
217+
| LAI (2015-2023) | 0.002 | 0.085 | 0.147 |
218+
| Soil Carbon (2015-2023) | 0.001 | 0.146 | 0.137 |
219+
220+
The narrow PEcAn spread reflects skill, not identical members: the members
221+
differ in their spatial carbon fields (for soil carbon, on the order of one
222+
percent of the field, member to member) yet match the benchmark about equally
223+
well. Whether that spread is appropriately sized relative to the error, that is,
224+
whether the ensemble is well-calibrated, is a separate question, addressed by
225+
probabilistic scoring in a later contribution.
226+
227+
`fig_spread_clouds.png` shows the per-member scores for all three ensembles
228+
across both windows.
229+
230+
## Scope and caveats
231+
232+
- **North America only.** The comparison uses the North American PEcAn grid.
233+
- **Biomass pool mismatch.** PEcAn `cVeg` is above-ground wood carbon, while the
234+
Xu & Saatchi benchmark is total live biomass (including roots). PEcAn is
235+
therefore expected to read somewhat low on biomass by construction.
236+
- **Soil-carbon depth mismatch.** PEcAn soil carbon integrates 0-200 cm, deeper
237+
than the benchmark, so PEcAn reads higher in deep-carbon regions.
238+
- **Soil moisture deferred.** The conversion step handles soil moisture, but
239+
multi-model soil-moisture scoring is not yet included.
240+
- **Probabilistic scoring is future work.** The spread results above motivate
241+
ensemble calibration metrics (rank histograms, reliability), which are a
242+
planned follow-on rather than part of this contribution.
243+
244+
## Reproducing the figure
245+
246+
`make_spread_figures.py` reads ILAMB `scores.csv` output for the two windows.
247+
Its paths refer to the analysis working tree where the scoring runs were
248+
produced; point them at your own ILAMB build directories to regenerate the
249+
figure.
250+
251+
## Data sources
252+
253+
- CMIP6: ESGF, via `intake-esgf` (historical and ssp245, r1i1p1f1).
254+
- TRENDY: Global Carbon Budget 2024, public download index.
255+
- Benchmarks: Xu & Saatchi 2021 (biomass), GIMMS LAI4g (leaf area index),
256+
HWSD2 (soil carbon).
257+
258+
## Customizing the benchmark
259+
260+
The benchmark is designed to stay usable as models and datasets evolve. Three
261+
common changes: updating or adding models, adding benchmark datasets, and
262+
adjusting the scoring weights. The per-field meaning of every configuration
263+
option is documented inline in `pecan_ilamb.cfg`.
264+
265+
### Updating or adding a model
266+
267+
Each model is just a directory under the model root (for example
268+
`ilamb_models_2012_2014/`), containing `cVeg.nc`, `cSoil.nc`, and `lai.nc` on
269+
the analysis grid. ILAMB scores every model directory it finds there, so:
270+
271+
- To add a model, place its directory (with the three variable files, regridded
272+
to the analysis grid) under the model root and re-run `ilamb-run`.
273+
- To update a model to a newer version (for example a new CMIP6 or TRENDY
274+
release), regenerate its files with the relevant build script and overwrite
275+
that model's directory. The build scripts take the source and output paths as
276+
arguments, so pointing them at a new data release does not require code edits.
277+
278+
The ensemble means (CMIP6, TRENDY, PEcAn) are rebuilt from the individual
279+
members by `build_window_ensembles.py`, so re-run it after changing the set of
280+
member models so the means reflect the new set.
281+
282+
### Adding a benchmark dataset
283+
284+
Benchmarks are defined in `pecan_ilamb.cfg`. To add one for an existing
285+
variable, copy the dataset block under the relevant `[h2: ...]` variable, rename
286+
it, and point its `source` at the new benchmark netCDF (placed under
287+
`ILAMB_ROOT/DATA/...`). When more than one benchmark is listed under a variable,
288+
their `weight` values set how they combine. To score a new variable entirely,
289+
add a new `[h2: ...]` block and make sure the model files contain that variable.
290+
291+
### Adjusting weights
292+
293+
Scoring weights live in `pecan_ilamb.cfg` at two levels: the `weight` on each
294+
`[h2: ...]` variable sets how much that variable counts toward the overall
295+
score relative to the other variables, and the `weight` on each dataset block
296+
sets how much that dataset counts within its variable. Both are relative
297+
weights, not percentages. See the inline comments in the config for the full
298+
per-field reference.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Download and regrid CMIP6 historical land-carbon fields for benchmarking.
3+
4+
Retrieves cVeg, cSoil, and lai for a set of CMIP6 models (one realization each,
5+
r1i1p1f1) from the ESGF archive via intake-esgf, then regrids every field onto
6+
the PEcAn analysis grid so all models, observations, and the reanalysis share a
7+
common grid for ILAMB scoring. Downloads are cached, so re-runs resume rather
8+
than refetch.
9+
10+
LAI is reduced to the July value to match the growing-season convention used on
11+
the reanalysis side; carbon pools use the full monthly series. Longitudes are
12+
converted to the [-180, 180] convention where needed before interpolation.
13+
14+
Output: one directory per model, each containing cVeg.nc / cSoil.nc / lai.nc.
15+
The full historical span is written here; restriction to an evaluation window
16+
is handled downstream by build_window_ensembles.py.
17+
18+
Paths default to the values used for this analysis; override with the flags
19+
below to run elsewhere.
20+
"""
21+
import argparse
22+
import os
23+
import glob
24+
import xarray as xr
25+
import intake_esgf
26+
from intake_esgf import ESGFCatalog
27+
28+
MODELS = ["ACCESS-ESM1-5","AWI-ESM-1-1-LR","BCC-CSM2-MR","BCC-ESM1","CESM2",
29+
"CESM2-FV2","CESM2-WACCM","CESM2-WACCM-FV2","CMCC-CM2-SR5","CMCC-ESM2",
30+
"CanESM5","CanESM5-1","EC-Earth3-CC","EC-Earth3-Veg","EC-Earth3-Veg-LR",
31+
"GFDL-ESM4","IPSL-CM5A2-INCA","IPSL-CM6A-LR","IPSL-CM6A-LR-INCA","KIOST-ESM",
32+
"MPI-ESM-1-2-HAM","MPI-ESM1-2-LR","NorESM2-LM","NorESM2-MM","SAM0-UNICON","TaiESM1"]
33+
34+
ap = argparse.ArgumentParser(description=__doc__)
35+
ap.add_argument("--cache", default="/projectnb/dietzelab/tdahiya/cmip6_cache")
36+
ap.add_argument("--outroot", default="/projectnb/dietzelab/tdahiya/ilamb_models_multi")
37+
ap.add_argument("--pecan-grid", dest="pec",
38+
default="/projectnb/dietzelab/tdahiya/ilamb_models/PEcAn/cVeg.nc",
39+
help="A PEcAn netCDF whose lat/lon define the target grid.")
40+
args = ap.parse_args()
41+
CACHE, OUTROOT, PEC = args.cache, args.outroot, args.pec
42+
43+
intake_esgf.conf.set(local_cache=[CACHE])
44+
45+
print("=== Download (resumes from cache) ===", flush=True)
46+
cat = ESGFCatalog()
47+
cat.search(project="CMIP6", experiment_id="historical", source_id=MODELS,
48+
variable_id=["cVeg","cSoil","lai"], table_id=["Lmon","Emon"], variant_label="r1i1p1f1")
49+
cat.to_path_dict()
50+
print("Download done.\n=== Processing ===", flush=True)
51+
52+
pec = xr.open_dataset(PEC, decode_times=False)
53+
tgt_lat, tgt_lon = pec.lat.values, pec.lon.values
54+
55+
def proc(model, var):
56+
files = sorted(glob.glob(f"{CACHE}/**/{model}/**/{var}_*.nc", recursive=True))
57+
if not files: return None
58+
da = xr.open_mfdataset(files, decode_times=True, combine="by_coords")[var]
59+
units = da.attrs.get("units","")
60+
da = da.sel(time=slice("2000-01-01","2014-12-31"))
61+
if var == "lai":
62+
da = da.sel(time=da.time.dt.month == 7)
63+
if float(da.lon.max()) > 180 and tgt_lon.min() < 0:
64+
da = da.assign_coords(lon=(((da.lon+180)%360)-180)).sortby("lon")
65+
da = da.interp(lat=tgt_lat, lon=tgt_lon, method="linear").load()
66+
da.attrs["units"]=units; da.name=var
67+
da["time"].attrs.pop("bounds",None); da["time"].encoding.pop("bounds",None)
68+
da["lat"].attrs.update({"units":"degrees_north","standard_name":"latitude"})
69+
da["lon"].attrs.update({"units":"degrees_east","standard_name":"longitude"})
70+
return da
71+
72+
ok=[]
73+
for m in MODELS:
74+
try:
75+
od=f"{OUTROOT}/{m}"; os.makedirs(od, exist_ok=True); n=0
76+
for var in ["cVeg","cSoil","lai"]:
77+
da=proc(m,var)
78+
if da is not None:
79+
xr.Dataset({var:da}).to_netcdf(f"{od}/{var}.nc"); n+=1
80+
print(f" {m}: {'OK' if n==3 else 'PARTIAL'} ({n}/3)", flush=True)
81+
if n==3: ok.append(m)
82+
except Exception as e:
83+
print(f" {m}: FAILED - {str(e)[:90]}", flush=True)
84+
85+
print(f"\nDone. {len(ok)}/{len(MODELS)} fully processed:", ok)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Build a continuous CMIP6 record by splicing historical and ssp245 runs.
3+
4+
Same models, variables, and regridding as the historical builder, but joins the
5+
end of each model's historical run to the start of its ssp245 scenario so the
6+
record extends past 2014. Only models that provide both pieces with land-carbon
7+
output are kept; the rest are reported as having no usable scenario run.
8+
9+
This spliced record is the source for the longer evaluation window, where
10+
interannual variability and trends can be assessed. LAI is again reduced to July.
11+
12+
Output: one directory per model with cVeg.nc / cSoil.nc / lai.nc.
13+
14+
Paths default to the values used for this analysis; override with the flags
15+
below to run elsewhere.
16+
"""
17+
import argparse
18+
import os
19+
import glob
20+
import xarray as xr
21+
import intake_esgf
22+
from intake_esgf import ESGFCatalog
23+
24+
MODELS = ["ACCESS-ESM1-5","AWI-ESM-1-1-LR","BCC-CSM2-MR","BCC-ESM1","CESM2",
25+
"CESM2-FV2","CESM2-WACCM","CESM2-WACCM-FV2","CMCC-CM2-SR5","CMCC-ESM2",
26+
"CanESM5","CanESM5-1","EC-Earth3-CC","EC-Earth3-Veg","EC-Earth3-Veg-LR",
27+
"GFDL-ESM4","IPSL-CM5A2-INCA","IPSL-CM6A-LR","IPSL-CM6A-LR-INCA","KIOST-ESM",
28+
"MPI-ESM-1-2-HAM","MPI-ESM1-2-LR","NorESM2-LM","NorESM2-MM","SAM0-UNICON","TaiESM1"]
29+
30+
ap = argparse.ArgumentParser(description=__doc__)
31+
ap.add_argument("--cache", default="/projectnb/dietzelab/tdahiya/cmip6_cache")
32+
ap.add_argument("--outroot", default="/projectnb/dietzelab/tdahiya/ilamb_models_2012_2023")
33+
ap.add_argument("--pecan-grid", dest="pec",
34+
default="/projectnb/dietzelab/tdahiya/ilamb_models/PEcAn/cVeg.nc",
35+
help="A PEcAn netCDF whose lat/lon define the target grid.")
36+
args = ap.parse_args()
37+
CACHE, OUTROOT, PEC = args.cache, args.outroot, args.pec
38+
39+
intake_esgf.conf.set(local_cache=[CACHE])
40+
41+
print("=== Download ssp245 (historical already cached) ===", flush=True)
42+
cat = ESGFCatalog()
43+
cat.search(project="CMIP6", experiment_id="ssp245", source_id=MODELS,
44+
variable_id=["cVeg","cSoil","lai"], table_id=["Lmon","Emon"], variant_label="r1i1p1f1")
45+
cat.to_path_dict()
46+
print("Download done.\n=== Splice hist(2012-2014)+ssp245(2015-2023) ===", flush=True)
47+
48+
pec = xr.open_dataset(PEC, decode_times=False)
49+
tgt_lat, tgt_lon = pec.lat.values, pec.lon.values
50+
51+
def load_exp(model, var, exp, t0, t1):
52+
files = sorted(glob.glob(f"{CACHE}/**/{model}/**/{var}_*.nc", recursive=True))
53+
files = [f for f in files if exp in os.path.basename(f)]
54+
if not files: return None
55+
da = xr.open_mfdataset(files, decode_times=True, combine="by_coords")[var]
56+
return da.sel(time=slice(t0, t1))
57+
58+
def proc(model, var):
59+
hist = load_exp(model, var, "historical", "2012-01-01", "2014-12-31")
60+
ssp = load_exp(model, var, "ssp245", "2015-01-01", "2023-12-31")
61+
if hist is None or ssp is None: return None
62+
units = hist.attrs.get("units","")
63+
da = xr.concat([hist, ssp], dim="time").sortby("time")
64+
if var == "lai":
65+
da = da.sel(time=da.time.dt.month == 7)
66+
if float(da.lon.max()) > 180 and tgt_lon.min() < 0:
67+
da = da.assign_coords(lon=(((da.lon+180)%360)-180)).sortby("lon")
68+
da = da.interp(lat=tgt_lat, lon=tgt_lon, method="linear").load()
69+
da.attrs["units"]=units; da.name=var
70+
da["time"].attrs.pop("bounds",None); da["time"].encoding.pop("bounds",None)
71+
da["lat"].attrs.update({"units":"degrees_north","standard_name":"latitude"})
72+
da["lon"].attrs.update({"units":"degrees_east","standard_name":"longitude"})
73+
return da
74+
75+
ok=[]; no_ssp=[]
76+
for m in MODELS:
77+
try:
78+
od=f"{OUTROOT}/{m}"; os.makedirs(od, exist_ok=True); n=0
79+
for var in ["cVeg","cSoil","lai"]:
80+
da=proc(m,var)
81+
if da is not None:
82+
xr.Dataset({var:da}).to_netcdf(f"{od}/{var}.nc"); n+=1
83+
print(f" {m}: {'OK' if n==3 else 'PARTIAL'} ({n}/3)", flush=True)
84+
if n==3: ok.append(m)
85+
elif n==0: no_ssp.append(m)
86+
except Exception as e:
87+
print(f" {m}: FAILED - {str(e)[:90]}", flush=True)
88+
print(f"\nDone. {len(ok)}/{len(MODELS)} full splice: {ok}", flush=True)
89+
print(f"No usable ssp245 (likely no scenario run): {no_ssp}", flush=True)

0 commit comments

Comments
 (0)