Skip to content

Commit fa0a6f2

Browse files
committed
Add mesh fix for CMIP6 ICON-ESM-LR regridding
1 parent d756912 commit fa0a6f2

2 files changed

Lines changed: 200 additions & 1 deletion

File tree

esmvalcore/cmor/_fixes/cmip6/icon_esm_lr.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,147 @@
11
"""Fixes for ICON-ESM-LR model."""
22

3+
import numpy as np
4+
from iris.coords import AuxCoord
5+
from iris.mesh import Connectivity, MeshXY
6+
37
from esmvalcore.cmor._fixes.fix import Fix
8+
from esmvalcore.iris_helpers import has_unstructured_grid
9+
10+
# deduplicate decimals - round cartesian coords to merge
11+
# identical vertices (especially pole vertices)
12+
CARTESIAN_COORDINATE_DECIMALS = 12
413

514

615
class AllVars(Fix):
7-
"""Fixes for all variables."""
16+
"""Adapt the native ICON mesh fix for ICON-ESM-LR outputs.
17+
18+
Like the native ICON fix (`esmvalcore.cmor._fixes.icon._base_fixes`).
19+
this avoids ``MeshXY.from_coords`` because shared
20+
polygon vertices would be duplicated. Since CMIP6 ICON-ESM-LR files don't
21+
have ``vertex_of_cell``, recreate the connectivity from coordinate
22+
bounds.
23+
"""
24+
25+
@staticmethod
26+
def _can_create_mesh(cube):
27+
# check if mesh is there
28+
if cube.mesh is not None:
29+
return False
30+
# unstructured?
31+
if not has_unstructured_grid(cube):
32+
return False
33+
34+
lat = cube.coord("latitude")
35+
lon = cube.coord("longitude")
36+
37+
# check bounds
38+
if not lat.has_bounds() or not lon.has_bounds():
39+
return False
40+
if lat.bounds.shape != lon.bounds.shape:
41+
return False
42+
return lat.bounds.ndim == 2
43+
44+
@staticmethod
45+
def _get_node_coords_and_connectivity(lat_bounds, lon_bounds):
46+
"""Build unique mesh nodes and face-node connectivity.
47+
48+
Cell vertices are converted to cartesian coordinates to
49+
identify shared nodes. Duplicate vertices are
50+
removed and a face-node connectivity array is generated.
51+
"""
52+
lat_rad = np.deg2rad(lat_bounds)
53+
lon_rad = np.deg2rad(lon_bounds)
54+
55+
cartesian = np.stack(
56+
[
57+
np.cos(lat_rad) * np.cos(lon_rad),
58+
np.cos(lat_rad) * np.sin(lon_rad),
59+
np.sin(lat_rad),
60+
],
61+
axis=-1,
62+
)
63+
64+
# round coords to avoid floating point diffs
65+
rounded = np.round(
66+
cartesian.reshape(-1, 3),
67+
decimals=CARTESIAN_COORDINATE_DECIMALS,
68+
)
69+
# unique mesh nodes
70+
unique_nodes, inverse = np.unique(
71+
rounded,
72+
axis=0,
73+
return_inverse=True,
74+
)
75+
76+
# we create face-node connectivity and back to lon/lat
77+
connectivity = inverse.reshape(lat_bounds.shape)
78+
norm = np.linalg.norm(unique_nodes, axis=1)
79+
unit_nodes = unique_nodes / norm[:, np.newaxis]
80+
node_lat = np.rad2deg(np.arcsin(unit_nodes[:, 2]))
81+
node_lon = (
82+
np.rad2deg(np.arctan2(unit_nodes[:, 1], unit_nodes[:, 0])) % 360.0
83+
)
84+
85+
return node_lat, node_lon, connectivity
86+
87+
def _fix_unstructured_mesh(self, cube):
88+
"""Create and attach the Iris mesh.
89+
90+
Constructs node coordinates and face-node connectivity
91+
from latitude longitude bounds and replaces the original
92+
coordinate representation with Iris mesh coordinates.
93+
"""
94+
if not self._can_create_mesh(cube):
95+
return
96+
97+
lat = cube.coord("latitude")
98+
lon = cube.coord("longitude")
99+
mesh_dim = cube.coord_dims(lat)
100+
101+
# construct the face_node_connectivity from bounds
102+
node_lat_points, node_lon_points, face_node_connectivity = (
103+
self._get_node_coords_and_connectivity(lat.bounds, lon.bounds)
104+
)
105+
106+
node_lat = AuxCoord(
107+
node_lat_points,
108+
standard_name="latitude",
109+
long_name="node latitude",
110+
var_name="nlat",
111+
units=lat.units,
112+
)
113+
node_lon = AuxCoord(
114+
node_lon_points,
115+
standard_name="longitude",
116+
long_name="node longitude",
117+
var_name="nlon",
118+
units=lon.units,
119+
)
120+
121+
face_lat = lat.copy()
122+
face_lon = lon.copy()
123+
# Update face bounds using the deduplicated node coords.
124+
face_lat.bounds = node_lat.points[face_node_connectivity]
125+
face_lon.bounds = node_lon.points[face_node_connectivity]
126+
127+
# Same create mesh logic with native ICON fix (Iris mesh object).
128+
connectivity = Connectivity(
129+
indices=face_node_connectivity,
130+
cf_role="face_node_connectivity",
131+
start_index=0,
132+
location_axis=0,
133+
)
134+
mesh = MeshXY(
135+
topology_dimension=2,
136+
node_coords_and_axes=[(node_lat, "y"), (node_lon, "x")],
137+
face_coords_and_axes=[(face_lat, "y"), (face_lon, "x")],
138+
connectivities=[connectivity],
139+
)
140+
141+
cube.remove_coord("latitude")
142+
cube.remove_coord("longitude")
143+
for mesh_coord in mesh.to_MeshCoords("face"):
144+
cube.add_aux_coord(mesh_coord, mesh_dim)
8145

9146
def fix_metadata(self, cubes):
10147
"""Rename ``var_name`` of latitude and longitude.
@@ -29,5 +166,6 @@ def fix_metadata(self, cubes):
29166
for std_name, var_name in varnames_to_change.items():
30167
if cube.coords(std_name):
31168
cube.coord(std_name).var_name = var_name
169+
self._fix_unstructured_mesh(cube)
32170

33171
return cubes

tests/integration/cmor/_fixes/cmip6/test_icon_esm_lr.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the fixes of ICON-ESM-LR."""
22

3+
import numpy as np
34
import pytest
45
from iris.coords import AuxCoord
56
from iris.cube import Cube, CubeList
@@ -95,3 +96,63 @@ def test_allvars_fix_metadata_no_lat_lon(cubes):
9596
fix = AllVars(None)
9697
out_cubes = fix.fix_metadata(cubes)
9798
assert cubes is out_cubes
99+
100+
101+
def test_allvars_fix_metadata_adds_mesh():
102+
lat = AuxCoord(
103+
[0.5, 0.5],
104+
standard_name="latitude",
105+
var_name="latitude",
106+
units="degrees_north",
107+
bounds=[[0.0, 0.0, 1.0], [0.0, 1.0, 1.0]],
108+
)
109+
lon = AuxCoord(
110+
[0.5, 0.5],
111+
standard_name="longitude",
112+
var_name="longitude",
113+
units="degrees_east",
114+
bounds=[[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]],
115+
)
116+
cube = Cube(
117+
np.array([1.0, 2.0]),
118+
var_name="tas",
119+
aux_coords_and_dims=[(lat, 0), (lon, 0)],
120+
)
121+
122+
AllVars(None).fix_metadata(CubeList([cube]))
123+
124+
assert cube.mesh is not None
125+
assert cube.coords("latitude", mesh_coords=True)
126+
assert cube.coords("longitude", mesh_coords=True)
127+
assert cube.mesh.connectivity().shape == (2, 3)
128+
129+
130+
def test_allvars_fix_metadata_merges_pole_vertices():
131+
"""Ensure pole vertices are merged into a single mesh node.
132+
133+
there must be 4 nodes: North Pole, (0,0), (0,120), (0,240)
134+
"""
135+
lat = AuxCoord(
136+
[60.0, 60.0],
137+
standard_name="latitude",
138+
var_name="latitude",
139+
units="degrees_north",
140+
bounds=[[90.0, 0.0, 0.0], [90.0, 0.0, 0.0]],
141+
)
142+
lon = AuxCoord(
143+
[60.0, 180.0],
144+
standard_name="longitude",
145+
var_name="longitude",
146+
units="degrees_east",
147+
bounds=[[0.0, 0.0, 120.0], [240.0, 120.0, 240.0]],
148+
)
149+
cube = Cube(
150+
np.array([1.0, 2.0]),
151+
var_name="tas",
152+
aux_coords_and_dims=[(lat, 0), (lon, 0)],
153+
)
154+
155+
AllVars(None).fix_metadata(CubeList([cube]))
156+
157+
node_lat = cube.mesh.coord(location="node", axis="y")
158+
assert len(node_lat.points) == 4

0 commit comments

Comments
 (0)