Skip to content

Commit 3d0a777

Browse files
authored
Merge pull request #4069 from Tejas7007/GH-ilamb-regional
Add regional calibration diagnostics (land cover and ecoregion breakdown)
2 parents d38bea7 + c0440a1 commit 3d0a777

8 files changed

Lines changed: 670 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ For more information about this file see also [Keep a Changelog](http://keepacha
1010

1111
### Added
1212
- Added ensemble calibration diagnostics to `inst/ilamb/` in PEcAn.benchmark (rank histogram, spread-skill ratio, coverage, reliability) for assessing whether an ensemble's spread is well calibrated against observations, complementing the ensemble-mean benchmarking.
13+
- Added regional calibration diagnostics to `inst/ilamb/` in PEcAn.benchmark: break the ensemble calibration assessment down by land cover class and by EPA/CEC ecoregion, reusing the ensemble calibration diagnostics, with figures.
1314
- Added an ESA CCI biomass benchmark extension to `inst/ilamb/` in PEcAn.benchmark: a converter for the ESACCI Biomass v7.0 product (2015-2024, with per-pixel uncertainty) to ILAMB-compatible netCDF, plus an observation-error test showing the ensemble overconfidence persists against a modern benchmark and after accounting for observation uncertainty.
1415
- Added `make_scorecard.sh` and documentation to `inst/ilamb/` in PEcAn.benchmark for generating and serving the ILAMB HTML scorecard.
1516
- New function `PEcAn.utils::netcdf2df()` flattens all dims and vars of a netCDF into a dataframe,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.png
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Regional Calibration Diagnostics
2+
3+
Breaks the ensemble calibration assessment of a carbon reanalysis down by region,
4+
rather than reporting a single domain-wide number. It answers a question the
5+
domain-wide diagnostics cannot: does the trustworthiness of the ensemble spread
6+
vary by biome and by ecoregion?
7+
8+
This builds directly on the ensemble calibration diagnostics in
9+
`ensemble_calibration.py`, computing the same spread-to-error
10+
ratio and coverage, but on subsets of sites grouped two ways: by land cover class
11+
and by EPA/CEC ecoregion.
12+
13+
## What it does
14+
15+
At a set of point locations (here, the sites of a state data assimilation
16+
reanalysis), the ensemble and an observational benchmark are sampled, and the
17+
calibration diagnostics are computed per group:
18+
19+
- **`regional_diagnostics.py`** samples the per-member downscaled maps and the
20+
benchmark at each site and computes calibration per land cover class. Its
21+
`stratified_calibration(members, obs, groups, labels)` engine is grouping
22+
agnostic and is reused for the ecoregion breakdown.
23+
- **`ecoregion_join.py`** assigns each site an EPA/CEC North American ecoregion
24+
(Level 1 and Level 2) by point-in-polygon, then runs the same engine on the
25+
ecoregion grouping.
26+
- **`regional_figures.py`** renders choropleth maps of the ecoregions shaded by
27+
calibration, bar charts by land cover and by ecoregion, and a map of the sites
28+
coloured by land cover class.
29+
30+
## Interpretation
31+
32+
The metrics are the same as in the calibration module: a spread-to-error ratio
33+
near 1 indicates a well-sized ensemble, well below 1 indicates overconfidence,
34+
and coverage is the fraction of observations inside the ensemble 90 percent band
35+
(near 0.90 when calibrated). Reporting these per region reveals structure that
36+
the single domain-wide number hides, for example whether the ensemble is
37+
overconfident uniformly or only in particular biomes.
38+
39+
## Dependencies and data
40+
41+
The stratification engine is pure NumPy plus the calibration module. The
42+
ecoregion join additionally requires `geopandas` and the figures additionally
43+
require `matplotlib`.
44+
45+
The ecoregion breakdown needs the CEC "Ecoregions of North America" Level 1 and
46+
Level 2 shapefiles, a free download from the EPA
47+
(https://www.epa.gov/eco-research/ecoregions-north-america, files
48+
`na_cec_eco_l1.zip` and `na_cec_eco_l2.zip`). Use the full North American files
49+
rather than the CONUS-only versions bundled with some tools, so that the boreal
50+
and Arctic north is covered. Unzip them and point `ECO_DIR` in
51+
`ecoregion_join.py` at the folder containing `NA_CEC_Eco_Level1.shp` and
52+
`NA_CEC_Eco_Level2.shp`.
53+
54+
## Notes
55+
56+
The paths in these modules (the SDA output directory, the downscaled-map layout,
57+
the benchmark locations, and `ECO_DIR`) reflect one particular setup and are
58+
illustrative; adapt them to your own data. The land cover class labels in
59+
`LANDCOVER_LABELS` are the MODIS MCD12Q1 PFT classification used in the North
60+
American carbon reanalysis (Zhang et al.), with the class integers 1-8 following
61+
the standard MODIS PFT coding, confirmed against the site geography. The labels
62+
are display strings only and do not affect the computed numbers.
63+
64+
## Testing
65+
66+
`test_regional_diagnostics.py` checks the `stratified_calibration` engine on
67+
synthetic data with known per-group calibration, without touching any real data
68+
files. Run with:
69+
70+
```
71+
python test_regional_diagnostics.py
72+
```
-71.2 KB
Binary file not shown.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Assign each site an EPA/CEC North American ecoregion (Level 1 and Level 2) by
3+
point-in-polygon, then run the same stratified calibration engine on the
4+
ecoregion grouping.
5+
6+
The ecoregion polygons and projection follow PEcAn's EPA_ecoregion_finder. The
7+
shapefiles are the CEC "Ecoregions of North America" Level 1 and Level 2 files,
8+
which cover all of North America (the CONUS-only versions bundled with some
9+
tools drop the boreal and Arctic north). They are a free download from the EPA:
10+
11+
https://www.epa.gov/eco-research/ecoregions-north-america
12+
(na_cec_eco_l1.zip and na_cec_eco_l2.zip)
13+
14+
Unzip them and point ECO_DIR below at the folder containing
15+
NA_CEC_Eco_Level1.shp and NA_CEC_Eco_Level2.shp. Requires geopandas.
16+
17+
The WATER category is treated as unmatched. Sites that fall outside every
18+
polygon (open ocean, a few coastal points) are also left unmatched.
19+
"""
20+
21+
import os
22+
import numpy as np
23+
import geopandas as gpd
24+
from shapely.geometry import Point
25+
26+
import ensemble_calibration as ec
27+
from regional_diagnostics import (
28+
load_sites, sample_ensemble, sample_benchmark,
29+
stratified_calibration, VARIABLES, VAR_LABEL,
30+
)
31+
32+
# Folder holding NA_CEC_Eco_Level1.shp and NA_CEC_Eco_Level2.shp (adapt this).
33+
ECO_DIR = "ecoregions_na"
34+
LEVELS = {
35+
"L1": ("NA_CEC_Eco_Level1.shp", "NA_L1NAME"),
36+
"L2": ("NA_CEC_Eco_Level2.shp", "NA_L2NAME"),
37+
}
38+
EXCLUDE = {"WATER"} # not a real ecoregion
39+
40+
41+
def isnull(arr):
42+
"""Elementwise None-or-NaN test for an object array of region names."""
43+
return np.array([(x is None) or (isinstance(x, float) and np.isnan(x))
44+
for x in arr])
45+
46+
47+
def assign_ecoregions(lon, lat):
48+
"""Return {level: name_array} assigning each site an ecoregion by polygon."""
49+
pts = gpd.GeoDataFrame(geometry=[Point(x, y) for x, y in zip(lon, lat)],
50+
crs="EPSG:4326")
51+
out = {}
52+
for level, (fname, name_col) in LEVELS.items():
53+
poly = gpd.read_file(os.path.join(ECO_DIR, fname))
54+
# the shapefile carries its own equal-area CRS; reproject to lon/lat
55+
poly = poly.to_crs("EPSG:4326")[[name_col, "geometry"]].copy()
56+
joined = gpd.sjoin(pts, poly, how="left", predicate="within")
57+
# a point can fall in overlapping polygon parts; keep the first per point
58+
joined = joined[~joined.index.duplicated(keep="first")].sort_index()
59+
names = joined[name_col].values.astype(object)
60+
names = np.array([None if (n in EXCLUDE) else n for n in names],
61+
dtype=object)
62+
out[level] = names
63+
return out
64+
65+
66+
def _groups_from_names(names):
67+
"""Map region names to integer ids and a label dict for the engine."""
68+
uniq = sorted(set(names[~isnull(names)]))
69+
name_to_id = {n: i for i, n in enumerate(uniq)}
70+
labels = {i: n for n, i in name_to_id.items()}
71+
groups = np.array([name_to_id.get(n, -1) for n in names])
72+
return groups, labels
73+
74+
75+
def run_level(level_name, names, ens_cache, min_sites=30):
76+
"""Print the stratified calibration by ecoregion at one level."""
77+
groups, labels = _groups_from_names(names)
78+
for var, (map_var, bglob, bvar, scale) in VARIABLES.items():
79+
members, obs = ens_cache[var]
80+
print(f"\n{VAR_LABEL[var]} ({var}) by {level_name} ecoregion")
81+
print(f" {'ecoregion':45s} {'n':>5s} {'ratio':>6s} {'cov90':>6s}")
82+
rows = sorted(stratified_calibration(members, obs, groups, labels),
83+
key=lambda r: -r["n"])
84+
for row in rows:
85+
if row["n"] < min_sites:
86+
continue
87+
lab = row["label"][:44]
88+
if np.isnan(row["ratio"]):
89+
print(f" {lab:45s} {row['n']:5d} (too few)")
90+
else:
91+
print(f" {lab:45s} {row['n']:5d} {row['ratio']:6.3f} {row['cov90']:6.3f}")
92+
93+
94+
def main():
95+
lon, lat, lc = load_sites()
96+
eco = assign_ecoregions(lon, lat)
97+
l1, l2 = eco["L1"], eco["L2"]
98+
n_l1 = len(set(l1[~isnull(l1)]))
99+
n_l2 = len(set(l2[~isnull(l2)]))
100+
unmatched = int(isnull(l1).sum())
101+
print(f"assigned ecoregions to {len(lon)} sites: "
102+
f"{n_l1} L1 regions, {n_l2} L2 regions, {unmatched} sites unmatched\n")
103+
104+
ens_cache = {}
105+
for var, (map_var, bglob, bvar, scale) in VARIABLES.items():
106+
ens_cache[var] = (sample_ensemble(map_var, lon, lat),
107+
sample_benchmark(bglob, bvar, scale, lon, lat))
108+
109+
print("=" * 72); print("LEVEL 1 ECOREGIONS"); print("=" * 72)
110+
run_level("L1", l1, ens_cache)
111+
print("\n" + "=" * 72); print("LEVEL 2 ECOREGIONS"); print("=" * 72)
112+
run_level("L2", l2, ens_cache)
113+
114+
115+
if __name__ == "__main__":
116+
main()

0 commit comments

Comments
 (0)