|
| 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