|
| 1 | +# Copyright Iris contributors |
| 2 | +# |
| 3 | +# This file is part of Iris and is released under the BSD license. |
| 4 | +# See LICENSE in the root of the repository for full licensing details. |
| 5 | +"""Experimental module for using some GeoVista operations with Iris cubes.""" |
| 6 | + |
| 7 | +from geovista import Transform |
| 8 | +from geovista.common import VTK_CELL_IDS, VTK_POINT_IDS |
| 9 | + |
| 10 | +from iris.exceptions import CoordinateNotFoundError |
| 11 | +from iris.experimental.ugrid import Mesh |
| 12 | + |
| 13 | + |
| 14 | +def _get_coord(cube, axis): |
| 15 | + """Get the axis coordinates from the cube.""" |
| 16 | + try: |
| 17 | + coord = cube.coord(axis=axis, dim_coords=True) |
| 18 | + except CoordinateNotFoundError: |
| 19 | + coord = cube.coord(axis=axis) |
| 20 | + return coord |
| 21 | + |
| 22 | + |
| 23 | +def cube_to_polydata(cube, **kwargs): |
| 24 | + r"""Create a :class:`pyvista.PolyData` object from a :class:`~iris.cube.Cube`. |
| 25 | +
|
| 26 | + The resulting :class:`~pyvista.PolyData` object can be plotted using |
| 27 | + a :class:`geovista.geoplotter.GeoPlotter`. |
| 28 | +
|
| 29 | + Uses :class:`geovista.bridge.Transform` to parse the cube's information - one |
| 30 | + of: :meth:`~geovista.bridge.Transform.from_1d` / |
| 31 | + :meth:`~geovista.bridge.Transform.from_2d` / |
| 32 | + :meth:`~geovista.bridge.Transform.from_unstructured`. |
| 33 | +
|
| 34 | + Parameters |
| 35 | + ---------- |
| 36 | + cube : :class:`~iris.cube.Cube` |
| 37 | + The Cube containing the spatial information and data for creating the |
| 38 | + class:`~pyvista.PolyData`. |
| 39 | +
|
| 40 | + **kwargs : dict, optional |
| 41 | + Additional keyword arguments to be passed to the relevant |
| 42 | + :class:`~geovista.bridge.Transform` method (e.g ``zlevel``). |
| 43 | +
|
| 44 | + Returns |
| 45 | + ------- |
| 46 | + :class:`~pyvista.PolyData` |
| 47 | + The PolyData object representing the cube's spatial information and data. |
| 48 | +
|
| 49 | + Raises |
| 50 | + ------ |
| 51 | + NotImplementedError |
| 52 | + If a :class:`~iris.cube.Cube` with too many dimensions is passed. Only |
| 53 | + the horizontal data can be represented, meaning a 2D Cube, or 1D Cube |
| 54 | + if the horizontal space is described by |
| 55 | + :class:`~iris.experimental.ugrid.MeshCoord`\ s. |
| 56 | +
|
| 57 | + Examples |
| 58 | + -------- |
| 59 | + .. testsetup:: |
| 60 | +
|
| 61 | + from iris import load_cube, sample_data_path |
| 62 | + from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD |
| 63 | +
|
| 64 | + cube = load_cube(sample_data_path("air_temp.pp")) |
| 65 | + cube_w_time = load_cube(sample_data_path("A1B_north_america.nc")) |
| 66 | + with PARSE_UGRID_ON_LOAD.context(): |
| 67 | + cube_mesh = load_cube(sample_data_path("mesh_C4_synthetic_float.nc")) |
| 68 | +
|
| 69 | + >>> from iris.experimental.geovista import cube_to_polydata |
| 70 | +
|
| 71 | + Converting a standard 2-dimensional :class:`~iris.cube.Cube` with |
| 72 | + 1-dimensional coordinates: |
| 73 | +
|
| 74 | + >>> print(cube.summary(shorten=True)) |
| 75 | + air_temperature / (K) (latitude: 73; longitude: 96) |
| 76 | + >>> print(cube_to_polydata(cube)) |
| 77 | + PolyData (... |
| 78 | + N Cells: 7008 |
| 79 | + N Points: 7178 |
| 80 | + N Strips: 0 |
| 81 | + X Bounds: -9.992e-01, 9.992e-01 |
| 82 | + Y Bounds: -9.992e-01, 9.992e-01 |
| 83 | + Z Bounds: -1.000e+00, 1.000e+00 |
| 84 | + N Arrays: 4 |
| 85 | +
|
| 86 | + Configure the conversion by passing additional keyword arguments: |
| 87 | +
|
| 88 | + >>> print(cube_to_polydata(cube, radius=2)) |
| 89 | + PolyData (... |
| 90 | + N Cells: 7008 |
| 91 | + N Points: 7178 |
| 92 | + N Strips: 0 |
| 93 | + X Bounds: -1.998e+00, 1.998e+00 |
| 94 | + Y Bounds: -1.998e+00, 1.998e+00 |
| 95 | + Z Bounds: -2.000e+00, 2.000e+00 |
| 96 | + N Arrays: 4 |
| 97 | +
|
| 98 | + Converting a :class:`~iris.cube.Cube` that has a |
| 99 | + :attr:`~iris.cube.Cube.mesh` describing its horizontal space: |
| 100 | +
|
| 101 | + >>> print(cube_mesh.summary(shorten=True)) |
| 102 | + synthetic / (1) (-- : 96) |
| 103 | + >>> print(cube_to_polydata(cube_mesh)) |
| 104 | + PolyData (... |
| 105 | + N Cells: 96 |
| 106 | + N Points: 98 |
| 107 | + N Strips: 0 |
| 108 | + X Bounds: -1.000e+00, 1.000e+00 |
| 109 | + Y Bounds: -1.000e+00, 1.000e+00 |
| 110 | + Z Bounds: -1.000e+00, 1.000e+00 |
| 111 | + N Arrays: 4 |
| 112 | +
|
| 113 | + Remember to reduce the dimensionality of your :class:`~iris.cube.Cube` to |
| 114 | + just be the horizontal space: |
| 115 | +
|
| 116 | + >>> print(cube_w_time.summary(shorten=True)) |
| 117 | + air_temperature / (K) (time: 240; latitude: 37; longitude: 49) |
| 118 | + >>> print(cube_to_polydata(cube_w_time[0, :, :])) |
| 119 | + PolyData (... |
| 120 | + N Cells: 1813 |
| 121 | + N Points: 1900 |
| 122 | + N Strips: 0 |
| 123 | + X Bounds: -6.961e-01, 6.961e-01 |
| 124 | + Y Bounds: -9.686e-01, -3.411e-01 |
| 125 | + Z Bounds: 2.483e-01, 8.714e-01 |
| 126 | + N Arrays: 4 |
| 127 | +
|
| 128 | + """ |
| 129 | + if cube.mesh: |
| 130 | + if cube.ndim != 1: |
| 131 | + raise NotImplementedError("Cubes with a mesh must be one dimensional") |
| 132 | + lons, lats = cube.mesh.node_coords |
| 133 | + face_node = cube.mesh.face_node_connectivity |
| 134 | + indices = face_node.indices_by_location() |
| 135 | + |
| 136 | + polydata = Transform.from_unstructured( |
| 137 | + xs=lons.points, |
| 138 | + ys=lats.points, |
| 139 | + connectivity=indices, |
| 140 | + data=cube.data, |
| 141 | + name=f"{cube.name()} / ({cube.units})", |
| 142 | + start_index=face_node.start_index, |
| 143 | + **kwargs, |
| 144 | + ) |
| 145 | + # TODO: Add support for point clouds |
| 146 | + elif cube.ndim == 2: |
| 147 | + x_coord = _get_coord(cube, "X") |
| 148 | + y_coord = _get_coord(cube, "Y") |
| 149 | + transform_kwargs = dict( |
| 150 | + xs=x_coord.contiguous_bounds(), |
| 151 | + ys=y_coord.contiguous_bounds(), |
| 152 | + data=cube.data, |
| 153 | + name=f"{cube.name()} / ({cube.units})", |
| 154 | + **kwargs, |
| 155 | + ) |
| 156 | + coord_system = cube.coord_system() |
| 157 | + if coord_system: |
| 158 | + transform_kwargs["crs"] = coord_system.as_cartopy_crs().proj4_init |
| 159 | + |
| 160 | + if x_coord.ndim == 2 and y_coord.ndim == 2: |
| 161 | + polydata = Transform.from_2d(**transform_kwargs) |
| 162 | + |
| 163 | + elif x_coord.ndim == 1 and y_coord.ndim == 1: |
| 164 | + polydata = Transform.from_1d(**transform_kwargs) |
| 165 | + |
| 166 | + else: |
| 167 | + raise NotImplementedError("Only 1D and 2D coordinates are supported") |
| 168 | + else: |
| 169 | + raise NotImplementedError("Cube must have a mesh or have 2 dimensions") |
| 170 | + |
| 171 | + return polydata |
| 172 | + |
| 173 | + |
| 174 | +def extract_unstructured_region(cube, polydata, region, **kwargs): |
| 175 | + """Index a :class:`~iris.cube.Cube` with a :attr:`~iris.cube.Cube.mesh` to a specific region. |
| 176 | +
|
| 177 | + Uses :meth:`geovista.geodesic.BBox.enclosed` to identify the `cube` indices |
| 178 | + that are within the specified region (`region` being a |
| 179 | + :class:`~geovista.geodesic.BBox` class). |
| 180 | +
|
| 181 | + Parameters |
| 182 | + ---------- |
| 183 | + cube : :class:`~iris.cube.Cube` |
| 184 | + The cube to be indexed (must have a :attr:`~iris.cube.Cube.mesh`). |
| 185 | + polydata : :class:`pyvista.PolyData` |
| 186 | + A :class:`~pyvista.PolyData` representing the same horizontal space as |
| 187 | + `cube`. The region extraction is first applied to `polydata`, with the |
| 188 | + resulting indices then applied to `cube`. In many cases `polydata` can |
| 189 | + be created by applying :func:`cube_to_polydata` to `cube`. |
| 190 | + region : :class:`geovista.geodesic.BBox` |
| 191 | + A :class:`~geovista.geodesic.BBox` representing the region to be |
| 192 | + extracted. |
| 193 | + **kwargs : dict, optional |
| 194 | + Additional keyword arguments to be passed to the |
| 195 | + :meth:`geovista.geodesic.BBox.enclosed` method (e.g ``preference``). |
| 196 | +
|
| 197 | + Returns |
| 198 | + ------- |
| 199 | + :class:`~iris.cube.Cube` |
| 200 | + The region extracted cube. |
| 201 | +
|
| 202 | + Raises |
| 203 | + ------ |
| 204 | + ValueError |
| 205 | + If `polydata` and the :attr:`~iris.cube.Cube.mesh` on `cube` do not |
| 206 | + have the same shape. |
| 207 | +
|
| 208 | + Examples |
| 209 | + -------- |
| 210 | + .. testsetup:: |
| 211 | +
|
| 212 | + from iris import load_cube, sample_data_path |
| 213 | + from iris.coords import AuxCoord |
| 214 | + from iris.cube import CubeList |
| 215 | + from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD |
| 216 | +
|
| 217 | + file_path = sample_data_path("mesh_C4_synthetic_float.nc") |
| 218 | + with PARSE_UGRID_ON_LOAD.context(): |
| 219 | + cube_w_mesh = load_cube(file_path) |
| 220 | +
|
| 221 | + level_cubes = CubeList() |
| 222 | + for height_level in range(72): |
| 223 | + height_coord = AuxCoord([height_level], standard_name="height") |
| 224 | + level_cube = cube_w_mesh.copy() |
| 225 | + level_cube.add_aux_coord(height_coord) |
| 226 | + level_cubes.append(level_cube) |
| 227 | +
|
| 228 | + cube_w_mesh = level_cubes.merge_cube() |
| 229 | + other_cube_w_mesh = cube_w_mesh[:20, :] |
| 230 | +
|
| 231 | + The parameters of :func:`extract_unstructured_region` have been designed with |
| 232 | + flexibility and reuse in mind. This is demonstrated below. |
| 233 | +
|
| 234 | + >>> from geovista import BBox |
| 235 | + >>> from iris.experimental.geovista import cube_to_polydata, extract_unstructured_region |
| 236 | + >>> print(cube_w_mesh.shape) |
| 237 | + (72, 96) |
| 238 | + >>> # The mesh dimension represents the horizontal space of the cube. |
| 239 | + >>> print(cube_w_mesh.shape[cube_w_mesh.mesh_dim()]) |
| 240 | + 96 |
| 241 | + >>> cube_polydata = cube_to_polydata(cube_w_mesh[0, :]) |
| 242 | + >>> extracted_cube = extract_unstructured_region( |
| 243 | + ... cube=cube_w_mesh, |
| 244 | + ... polydata=cube_polydata, |
| 245 | + ... region=BBox(lons=[0, 70, 70, 0], lats=[-25, -25, 45, 45]), |
| 246 | + ... ) |
| 247 | + >>> print(extracted_cube.shape) |
| 248 | + (72, 11) |
| 249 | +
|
| 250 | + Now reuse the same `cube` and `polydata` to extract a different region: |
| 251 | +
|
| 252 | + >>> new_region = BBox(lons=[0, 35, 35, 0], lats=[-25, -25, 45, 45]) |
| 253 | + >>> extracted_cube = extract_unstructured_region( |
| 254 | + ... cube=cube_w_mesh, |
| 255 | + ... polydata=cube_polydata, |
| 256 | + ... region=new_region, |
| 257 | + ... ) |
| 258 | + >>> print(extracted_cube.shape) |
| 259 | + (72, 6) |
| 260 | +
|
| 261 | + Now apply the same region extraction to a different `cube` that has the |
| 262 | + same horizontal shape: |
| 263 | +
|
| 264 | + >>> print(other_cube_w_mesh.shape) |
| 265 | + (20, 96) |
| 266 | + >>> extracted_cube = extract_unstructured_region( |
| 267 | + ... cube=other_cube_w_mesh, |
| 268 | + ... polydata=cube_polydata, |
| 269 | + ... region=new_region, |
| 270 | + ... ) |
| 271 | + >>> print(extracted_cube.shape) |
| 272 | + (20, 6) |
| 273 | +
|
| 274 | + Arbitrary keywords can be passed down to |
| 275 | + :meth:`geovista.geodesic.BBox.enclosed` (``outside`` in this example): |
| 276 | +
|
| 277 | + >>> extracted_cube = extract_unstructured_region( |
| 278 | + ... cube=other_cube_w_mesh, |
| 279 | + ... polydata=cube_polydata, |
| 280 | + ... region=new_region, |
| 281 | + ... outside=True, |
| 282 | + ... ) |
| 283 | + >>> print(extracted_cube.shape) |
| 284 | + (20, 90) |
| 285 | +
|
| 286 | + """ |
| 287 | + if cube.mesh: |
| 288 | + # Find what dimension the mesh is in on the cube |
| 289 | + mesh_dim = cube.mesh_dim() |
| 290 | + recreate_mesh = False |
| 291 | + |
| 292 | + if cube.location == "face": |
| 293 | + polydata_length = polydata.GetNumberOfCells() |
| 294 | + indices_key = VTK_CELL_IDS |
| 295 | + recreate_mesh = True |
| 296 | + elif cube.location == "node": |
| 297 | + polydata_length = polydata.GetNumberOfPoints() |
| 298 | + indices_key = VTK_POINT_IDS |
| 299 | + else: |
| 300 | + raise NotImplementedError( |
| 301 | + f"cube.location must be `face` or `node`. Found: {cube.location}." |
| 302 | + ) |
| 303 | + |
| 304 | + if cube.shape[mesh_dim] != polydata_length: |
| 305 | + raise ValueError( |
| 306 | + f"The mesh on the cube and the polydata" |
| 307 | + f"must have the same shape." |
| 308 | + f" Found Mesh: {cube.shape[mesh_dim]}," |
| 309 | + f" Polydata: {polydata_length}." |
| 310 | + ) |
| 311 | + |
| 312 | + region_polydata = region.enclosed(polydata, **kwargs) |
| 313 | + indices = region_polydata[indices_key] |
| 314 | + if len(indices) == 0: |
| 315 | + raise IndexError("No part of `polydata` falls within `region`.") |
| 316 | + |
| 317 | + my_tuple = tuple( |
| 318 | + [slice(None) if i != mesh_dim else indices for i in range(cube.ndim)] |
| 319 | + ) |
| 320 | + |
| 321 | + region_cube = cube[my_tuple] |
| 322 | + |
| 323 | + if recreate_mesh: |
| 324 | + coords_on_mesh_dim = region_cube.coords(dimensions=mesh_dim) |
| 325 | + new_mesh = Mesh.from_coords( |
| 326 | + *[c for c in coords_on_mesh_dim if c.has_bounds()] |
| 327 | + ) |
| 328 | + |
| 329 | + new_mesh_coords = new_mesh.to_MeshCoords(cube.location) |
| 330 | + |
| 331 | + for coord in new_mesh_coords: |
| 332 | + region_cube.remove_coord(coord.name()) |
| 333 | + region_cube.add_aux_coord(coord, mesh_dim) |
| 334 | + |
| 335 | + # TODO: Support unstructured point based data without a mesh |
| 336 | + else: |
| 337 | + raise ValueError("Cube must have a mesh") |
| 338 | + |
| 339 | + return region_cube |
0 commit comments