diff --git a/docs/src/userguide/plotting_examples/masking_brazil_plot.py b/docs/src/userguide/plotting_examples/masking_brazil_plot.py new file mode 100644 index 0000000000..3dc521d451 --- /dev/null +++ b/docs/src/userguide/plotting_examples/masking_brazil_plot.py @@ -0,0 +1,24 @@ +"""Global cube masked to Brazil and plotted with quickplot.""" +import cartopy.io.shapereader as shpreader +import matplotlib.pyplot as plt + +import iris +import iris.quickplot as qplt +from iris.util import mask_cube_from_shapefile + +country_shp_reader = shpreader.Reader( + shpreader.natural_earth( + resolution="110m", category="cultural", name="admin_0_countries" + ) +) +brazil_shp = [ + country.geometry + for country in country_shp_reader.records() + if "Brazil" in country.attributes["NAME_LONG"] +][0] + +cube = iris.load_cube(iris.sample_data_path("air_temp.pp")) +brazil_cube = mask_cube_from_shapefile(cube, brazil_shp) + +qplt.pcolormesh(brazil_cube) +plt.show() diff --git a/docs/src/userguide/subsetting_a_cube.rst b/docs/src/userguide/subsetting_a_cube.rst index 019982ad6d..27a223042e 100644 --- a/docs/src/userguide/subsetting_a_cube.rst +++ b/docs/src/userguide/subsetting_a_cube.rst @@ -5,7 +5,8 @@ Subsetting a Cube ================= The :doc:`loading_iris_cubes` section of the user guide showed how to load data into multidimensional Iris cubes. -However it is often necessary to reduce the dimensionality of a cube down to something more appropriate and/or manageable. +However it is often necessary to reduce the dimensionality of a cube down to something more appropriate and/or manageable, +or only examine and analyse a subset of data in a dimension. Iris provides several ways of reducing both the amount of data and/or the number of dimensions in your cube depending on the circumstance. In all cases **the subset of a valid cube is itself a valid cube**. @@ -329,6 +330,36 @@ on bounds can be done in the following way:: The above example constrains to cells where either the upper or lower bound occur after 1st January 2008. +Cube Masking +-------------- + +.. _masking-from-shapefile: + +Masking from a shapefile +^^^^^^^^^^^^^^^^^^^^^^^^ + +Often we want to perform so kind of analysis over a complex geographical feature - only over land points or sea points: +or over a continent, a country, a river watershed or administrative region. These geographical features can often be described by shapefiles. +Shapefiles are a file format first developed for GIS software in the 1990s, and now `Natural Earth`_ maintain a large freely usable database of shapefiles of many geographical and poltical divisions, +accessible via cartopy. Users may also provide their own custom shapefiles. + +These shapefiles can be used to mask an iris cube, so that any data outside the bounds of the shapefile is hidden from further analysis or plotting. + +First, we load the correct shapefile from NaturalEarth via the `Cartopy`_ instructions. Here we get one for Brazil. +The `.geometry` attribute of the records in the reader contain the shapely polygon we're interested in - once we have those we just need to provide them to +the :class:`iris.util.mask_cube_from_shapefile` function. Once plotted, we can see that only our area of interest remains in the data. + + +.. plot:: userguide/plotting_examples/masking_brazil_plot.py + :include-source: + +We can see that the dimensions of the cube haven't changed - the plot is still global. But only the data over Brazil is plotted - the rest is masked. + +.. note:: + While Iris will try to dynamically adjust the shapefile to mask cubes of different projections, it can struggle with rotated pole projections and cubes with Meridians not at 0° + Converting your Cube's coordinate system may help if you get a fully masked cube from this function. + + Cube Iteration -------------- It is not possible to directly iterate over an Iris cube. That is, you cannot use code such as @@ -440,3 +471,7 @@ Similarly, Iris cubes have indexing capability:: # Get the second element of the first dimension and all of the second dimension # in reverse, by steps of two. print(cube[1, ::-2]) + + +.. _Cartopy: https://scitools.org.uk/cartopy/docs/latest/tutorials/using_the_shapereader.html#id1 +.. _Natural Earth: https://www.naturalearthdata.com/ diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index a02caba68d..d2a3d27236 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -67,6 +67,12 @@ This document explains the changes made to Iris for this release NetCDF chunking with the use of the :data:`iris.fileformats.netcdf.loader.CHUNK_CONTROL` context manager. (:pull:`5588`) +#. `@acchamber`_ and `@trexfeathers`_ (reviewer) added + :func:`iris.util.mask_cube_from_shapefile`. This builds on the original work + of `@ckmo`_, `@david-bentley`_, `@jmendesmetoffice`_, `@evyve`_ and + `@pelson`_ for the UK Met Office **ASCEND** library. See + :ref:`masking-from-shapefile` for documentation. (:pull:`5470`) + 🐛 Bugs Fixed ============= @@ -110,7 +116,7 @@ This document explains the changes made to Iris for this release #. `@bouweandela`_ changed :func:`iris.coords.Coord.cell` so it does not realize all coordinate data and only loads a single cell instead. (:pull:`5693`) -#. `@rcomer`_ and `@trexfeathers`_ (reviewer) modified +#. `@rcomer`_ and `@trexfeathers`_ (reviewer) modified :func:`~iris.analysis.stats.pearsonr` so it preserves lazy data in all cases and also runs a little faster. (:pull:`5638`) @@ -242,6 +248,10 @@ This document explains the changes made to Iris for this release .. _@scottrobinson02: https://github.com/scottrobinson02 .. _@acchamber: https://github.com/acchamber .. _@fazledyn-or: https://github.com/fazledyn-or +.. _@ckmo: https://github.com/ckmo +.. _@david-bentley: https://github.com/david-bentley +.. _@jmendesmetoffice: https://github.com/jmendesmetoffice +.. _@evyve: https://github.com/evyve .. comment diff --git a/lib/iris/_shapefiles.py b/lib/iris/_shapefiles.py new file mode 100644 index 0000000000..6213128cf6 --- /dev/null +++ b/lib/iris/_shapefiles.py @@ -0,0 +1,243 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. + +# Much of this code is originally based off the ASCEND library, developed in +# the Met Office by Chris Kent, Emilie Vanvyve, David Bentley, Joana Mendes +# many thanks to them. Converted to iris by Alex Chamberlain-Clay + + +from itertools import product +import warnings + +import numpy as np +import shapely +import shapely.errors +import shapely.geometry as sgeom +import shapely.ops + +from iris.exceptions import IrisDefaultingWarning, IrisUserWarning + + +def create_shapefile_mask( + geometry, + cube, + minimum_weight=0.0, +): + """Make a mask for a cube from a shape. + + Get the mask of the intersection between the + given shapely geometry and cube with x/y DimCoords. + Can take a minimum weight and evaluate area overlaps instead + + Parameters + ---------- + geometry : :class:`shapely.Geometry` + cube : :class:`iris.cube.Cube` + A :class:`~iris.cube.Cube` which has 1d x and y coordinates + minimum_weight : float, default 0.0 + A float between 0 and 1 determining what % of a cell + a shape must cover for the cell to remain unmasked. + eg: 0.1 means that at least 10% of the shape overlaps the cell + to be unmasked. + Requires geometry to be a Polygon or MultiPolygon + Defaults to 0.0 (eg only test intersection) + + Returns + ------- + :class:`np.array` + An array of the shape of the x & y coordinates of the cube, with points + to mask equal to True + + """ + from iris.cube import Cube, CubeList + + try: + msg = "Geometry is not a valid Shapely object" + if not shapely.is_valid(geometry): + raise TypeError(msg) + except Exception: + raise TypeError(msg) + if not isinstance(cube, Cube): + if isinstance(cube, CubeList): + msg = "Received CubeList object rather than Cube - \ + to mask a CubeList iterate over each Cube" + raise TypeError(msg) + else: + msg = "Received non-Cube object where a Cube is expected" + raise TypeError(msg) + if minimum_weight > 0.0 and isinstance( + geometry, + ( + sgeom.Point, + sgeom.LineString, + sgeom.LinearRing, + sgeom.MultiPoint, + sgeom.MultiLineString, + ), + ): + minimum_weight = 0.0 + warnings.warn( + """Shape is of invalid type for minimum weight masking, + must use a Polygon rather than Line shape.\n + Masking based off intersection instead. """, + category=IrisDefaultingWarning, + ) + + # prepare 2D cube + y_name, x_name = _cube_primary_xy_coord_names(cube) + cube_2d = cube.slices([y_name, x_name]).next() + for coord in cube_2d.dim_coords: + if not coord.has_bounds(): + coord.guess_bounds() + trans_geo = _transform_coord_system(geometry, cube_2d) + + y_coord, x_coord = [cube_2d.coord(n) for n in (y_name, x_name)] + x_bounds = _get_mod_rebased_coord_bounds(x_coord) + y_bounds = _get_mod_rebased_coord_bounds(y_coord) + # prepare array for dark + box_template = [ + sgeom.box(x[0], y[0], x[1], y[1]) for x, y in product(x_bounds, y_bounds) + ] + # shapely can do lazy evaluation of intersections if it's given a list of grid box shapes + # delayed lets us do it in parallel + intersect_template = shapely.intersects(trans_geo, box_template) + # we want areas not under shapefile to be True (to mask) + intersect_template = np.invert(intersect_template) + # now calc area overlaps if doing weights and adjust mask + if minimum_weight > 0.0: + intersections = np.array(box_template)[~intersect_template] + intersect_template[~intersect_template] = [ + trans_geo.intersection(box).area / box.area <= minimum_weight + for box in intersections + ] + mask_template = np.reshape(intersect_template, cube_2d.shape[::-1]).T + return mask_template + + +def _transform_coord_system(geometry, cube, geometry_system=None): + """Project the shape onto another coordinate system. + + Parameters + ---------- + geometry: :class:`shapely.Geometry` + cube: :class:`iris.cube.Cube` + :class:`~iris.cube.Cube` with the coord_system to be projected to and + a x coordinate + geometry_system: :class:`iris.coord_systems`, optional + A :class:`~iris.coord_systems` object describing + the coord_system of the shapefile. Defaults to None, + which is treated as GeogCS + + Returns + ------- + :class:`shapely.Geometry` + A transformed copy of the provided :class:`shapely.Geometry` + + """ + y_name, x_name = _cube_primary_xy_coord_names(cube) + import iris.analysis.cartography + + DEFAULT_CS = iris.coord_systems.GeogCS( + iris.analysis.cartography.DEFAULT_SPHERICAL_EARTH_RADIUS + ) + target_system = cube.coord_system() + if not target_system: + warnings.warn( + "Cube has no coord_system; using default GeogCS lat/lon", + category=IrisDefaultingWarning, + ) + target_system = DEFAULT_CS + if geometry_system is None: + geometry_system = DEFAULT_CS + target_proj = target_system.as_cartopy_projection() + source_proj = geometry_system.as_cartopy_projection() + + trans_geometry = target_proj.project_geometry(geometry, source_proj) + # A GeogCS in iris can be either -180 to 180 or 0 to 360. If cube is 0-360, shift geom to match + if ( + isinstance(target_system, iris.coord_systems.GeogCS) + and cube.coord(x_name).points[-1] > 180 + ): + # chop geom at 0 degree line very finely then transform + prime_meridian_line = shapely.LineString([(0, 90), (0, -90)]) + trans_geometry = trans_geometry.difference(prime_meridian_line.buffer(0.00001)) + trans_geometry = shapely.transform(trans_geometry, _trans_func) + + if (not isinstance(target_system, iris.coord_systems.GeogCS)) and cube.coord( + x_name + ).points[-1] > 180: + # this may lead to incorrect masking or not depending on projection type so warn user + warnings.warn( + """Cube has x-coordinates over 180E and a non-standard projection type.\n + This may lead to incorrect masking. \n + If the result is not as expected, you might want to transform the x coordinate points of your cube to -180-180 """, + category=IrisUserWarning, + ) + return trans_geometry + + +def _trans_func(geometry): + """Pocket function for transforming the x coord of a geometry from -180 to 180 to 0-360.""" + for point in geometry: + if point[0] < 0: + point[0] = 360 - np.abs(point[0]) + return geometry + + +def _cube_primary_xy_coord_names(cube): + """Return the primary latitude and longitude coordinate names, or long names, from a cube. + + Parameters + ---------- + cube : :class:`iris.cube.Cube` + + Returns + ------- + tuple of str + The names of the primary latitude and longitude coordinates + + """ + latc = ( + cube.coords(axis="y", dim_coords=True)[0] + if cube.coords(axis="y", dim_coords=True) + else -1 + ) + lonc = ( + cube.coords(axis="x", dim_coords=True)[0] + if cube.coords(axis="x", dim_coords=True) + else -1 + ) + + if -1 in (latc, lonc): + msg = "Error retrieving 1d xy coordinates in cube: {!r}" + raise ValueError(msg.format(cube)) + + latitude = latc.name() + longitude = lonc.name() + return latitude, longitude + + +def _get_mod_rebased_coord_bounds(coord): + """Take in a coord and returns a array of the bounds of that coord rebased to the modulus. + + Parameters + ---------- + coord : :class:`iris.coords.Coord` + An Iris coordinate with a modulus + + Returns + ------- + :class:`np.array` + A 1d Numpy array of [start,end] pairs for bounds of the coord + + """ + modulus = coord.units.modulus + # Force realisation (rather than core_bounds) - more efficient for the + # repeated indexing happening downstream. + result = np.array(coord.bounds) + if modulus: + result[result < 0.0] = (np.abs(result[result < 0.0]) % modulus) * -1 + result[np.isclose(result, modulus, 1e-10)] = 0.0 + return result diff --git a/lib/iris/tests/integration/test_mask_cube_from_shapefile.py b/lib/iris/tests/integration/test_mask_cube_from_shapefile.py new file mode 100644 index 0000000000..59f3e3a72a --- /dev/null +++ b/lib/iris/tests/integration/test_mask_cube_from_shapefile.py @@ -0,0 +1,109 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Integration tests for :func:`iris.util.mask_cube_from_shapefile`.""" + +import math + +import cartopy.io.shapereader as shpreader +import numpy as np + +import iris +import iris.tests as tests +from iris.util import mask_cube_from_shapefile + + +@tests.skip_data +class TestCubeMasking(tests.IrisTest): + """integration tests of mask_cube_from_shapefile + using different projections in iris_test_data - + values are the KGO calculated using ASCEND. + """ + + def setUp(self): + ne_countries = shpreader.natural_earth( + resolution="10m", category="cultural", name="admin_0_countries" + ) + self.reader = shpreader.Reader(ne_countries) + + def test_global_proj_russia(self): + path = tests.get_data_path( + ["NetCDF", "global", "xyt", "SMALL_hires_wind_u_for_ipcc4.nc"] + ) + test_global = iris.load_cube(path) + ne_russia = [ + country.geometry + for country in self.reader.records() + if "Russia" in country.attributes["NAME_LONG"] + ][0] + masked_test = mask_cube_from_shapefile(test_global, ne_russia) + print(np.sum(masked_test.data)) + assert math.isclose( + np.sum(masked_test.data), 76845.37, rel_tol=0.001 + ), "Global data with Russia mask failed test" + + def test_rotated_pole_proj_germany(self): + path = tests.get_data_path( + ["NetCDF", "rotated", "xy", "rotPole_landAreaFraction.nc"] + ) + test_rotated = iris.load_cube(path) + ne_germany = [ + country.geometry + for country in self.reader.records() + if "Germany" in country.attributes["NAME_LONG"] + ][0] + masked_test = mask_cube_from_shapefile(test_rotated, ne_germany) + assert math.isclose( + np.sum(masked_test.data), 179.46872, rel_tol=0.001 + ), "rotated europe data with German mask failed test" + + def test_transverse_mercator_proj_uk(self): + path = tests.get_data_path( + ["NetCDF", "transverse_mercator", "tmean_1910_1910.nc"] + ) + test_transverse = iris.load_cube(path) + ne_uk = [ + country.geometry + for country in self.reader.records() + if "United Kingdom" in country.attributes["NAME_LONG"] + ][0] + masked_test = mask_cube_from_shapefile(test_transverse, ne_uk) + assert math.isclose( + np.sum(masked_test.data), 90740.25, rel_tol=0.001 + ), "transverse mercator UK data with UK mask failed test" + + def test_rotated_pole_proj_germany_weighted_area(self): + path = tests.get_data_path( + ["NetCDF", "rotated", "xy", "rotPole_landAreaFraction.nc"] + ) + test_rotated = iris.load_cube(path) + ne_germany = [ + country.geometry + for country in self.reader.records() + if "Germany" in country.attributes["NAME_LONG"] + ][0] + masked_test = mask_cube_from_shapefile( + test_rotated, ne_germany, minimum_weight=0.9 + ) + assert math.isclose( + np.sum(masked_test.data), 125.60199, rel_tol=0.001 + ), "rotated europe data with 0.9 weight germany mask failed test" + + def test_4d_global_proj_brazil(self): + path = tests.get_data_path(["NetCDF", "global", "xyz_t", "GEMS_CO2_Apr2006.nc"]) + test_4d_brazil = iris.load_cube(path, "Carbon Dioxide") + ne_brazil = [ + country.geometry + for country in self.reader.records() + if "Brazil" in country.attributes["NAME_LONG"] + ][0] + masked_test = mask_cube_from_shapefile( + test_4d_brazil, + ne_brazil, + ) + print(np.sum(masked_test.data)) + # breakpoint() + assert math.isclose( + np.sum(masked_test.data), 18616921.2, rel_tol=0.001 + ), "4d data with brazil mask failed test" diff --git a/lib/iris/tests/unit/util/test_mask_cube_from_shapefile.py b/lib/iris/tests/unit/util/test_mask_cube_from_shapefile.py new file mode 100644 index 0000000000..bde8007ee2 --- /dev/null +++ b/lib/iris/tests/unit/util/test_mask_cube_from_shapefile.py @@ -0,0 +1,121 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for :func:`iris.util.mask_cube_from_shapefile`.""" + +import numpy as np +import pytest +import shapely + +from iris.coord_systems import RotatedGeogCS +from iris.coords import DimCoord +import iris.cube +from iris.exceptions import IrisUserWarning +import iris.tests as tests +from iris.util import mask_cube_from_shapefile + + +class TestBasicCubeMasking(tests.IrisTest): + """Unit tests for mask_cube_from_shapefile function.""" + + def setUp(self): + basic_data = np.array([[1, 2, 3], [4, 8, 12]]) + self.basic_cube = iris.cube.Cube(basic_data) + coord = DimCoord( + np.array([0, 1.0]), + standard_name="projection_y_coordinate", + bounds=[[0, 0.5], [0.5, 1]], + units="1", + ) + self.basic_cube.add_dim_coord(coord, 0) + coord = DimCoord( + np.array([0, 1.0, 1.5]), + standard_name="projection_x_coordinate", + bounds=[[0, 0.5], [0.5, 1], [1, 1.5]], + units="1", + ) + self.basic_cube.add_dim_coord(coord, 1) + + def test_basic_cube_intersect(self): + shape = shapely.geometry.box(0.6, 0.6, 0.9, 0.9) + masked_cube = mask_cube_from_shapefile(self.basic_cube, shape) + assert ( + np.sum(masked_cube.data) == 8 + ), f"basic cube masking failed test - expected 8 got {np.sum(masked_cube.data)}" + + def test_basic_cube_intersect_in_place(self): + shape = shapely.geometry.box(0.6, 0.6, 0.9, 0.9) + cube = self.basic_cube.copy() + mask_cube_from_shapefile(cube, shape, in_place=True) + assert ( + np.sum(cube.data) == 8 + ), f"basic cube masking failed test - expected 8 got {np.sum(cube.data)}" + + def test_basic_cube_intersect_low_weight(self): + shape = shapely.geometry.box(0.1, 0.6, 1, 1) + masked_cube = mask_cube_from_shapefile( + self.basic_cube, shape, minimum_weight=0.2 + ) + assert ( + np.sum(masked_cube.data) == 12 + ), f"basic cube masking weighting failed test - expected 12 got {np.sum(masked_cube.data)}" + + def test_basic_cube_intersect_high_weight(self): + shape = shapely.geometry.box(0.1, 0.6, 1, 1) + masked_cube = mask_cube_from_shapefile( + self.basic_cube, shape, minimum_weight=0.7 + ) + assert ( + np.sum(masked_cube.data) == 8 + ), f"basic cube masking weighting failed test- expected 8 got {np.sum(masked_cube.data)}" + + def test_cube_list_error(self): + cubelist = iris.cube.CubeList([self.basic_cube]) + shape = shapely.geometry.box(1, 1, 2, 2) + with pytest.raises(TypeError, match="CubeList object rather than Cube"): + mask_cube_from_shapefile(cubelist, shape) + + def test_non_cube_error(self): + fake = None + shape = shapely.geometry.box(1, 1, 2, 2) + with pytest.raises(TypeError, match="Received non-Cube object"): + mask_cube_from_shapefile(fake, shape) + + def test_line_shape_warning(self): + shape = shapely.geometry.LineString([(0, 0.75), (2, 0.75)]) + with pytest.warns(IrisUserWarning, match="invalid type"): + masked_cube = mask_cube_from_shapefile( + self.basic_cube, shape, minimum_weight=0.1 + ) + assert ( + np.sum(masked_cube.data) == 24 + ), f"basic cube masking against line failed test - expected 24 got {np.sum(masked_cube.data)}" + + def test_cube_coord_mismatch_warning(self): + shape = shapely.geometry.box(0.6, 0.6, 0.9, 0.9) + cube = self.basic_cube + cube.coord("projection_x_coordinate").points = [180, 360, 540] + cube.coord("projection_x_coordinate").coord_system = RotatedGeogCS(30, 30) + with pytest.warns(IrisUserWarning, match="masking"): + mask_cube_from_shapefile( + cube, + shape, + ) + + def test_missing_xy_coord(self): + shape = shapely.geometry.box(0.6, 0.6, 0.9, 0.9) + cube = self.basic_cube + cube.remove_coord("projection_x_coordinate") + with pytest.raises(ValueError, match="1d xy coordinates"): + mask_cube_from_shapefile(cube, shape) + + def test_shape_not_shape(self): + shape = [5, 6, 7, 8] # random array + with pytest.raises(TypeError, match="valid Shapely"): + mask_cube_from_shapefile(self.basic_cube, shape) + + def test_shape_invalid(self): + shape = shapely.box(0, 1, 1, 1) + with pytest.raises(TypeError, match="valid Shapely"): + mask_cube_from_shapefile(self.basic_cube, shape) diff --git a/lib/iris/util.py b/lib/iris/util.py index 3b649946eb..6f42229aa9 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -20,6 +20,7 @@ from iris._deprecation import warn_deprecated from iris._lazy_data import is_lazy_data, is_lazy_masked_data +from iris._shapefiles import create_shapefile_mask from iris.common import SERVICES from iris.common.lenient import _lenient_client import iris.exceptions @@ -2086,3 +2087,63 @@ def _strip_metadata_from_dims(cube, dims): reduced_cube.remove_cell_measure(cm) return reduced_cube + + +def mask_cube_from_shapefile(cube, shape, minimum_weight=0.0, in_place=False): + """Take a shape object and masks all points not touching it in a cube. + + Finds the overlap between the `shape` and the `cube` in 2D xy space and + masks out any cells with less % overlap with shape than set. + Default behaviour is to count any overlap between shape and cell as valid + + Parameters + ---------- + shape : Shapely.Geometry object + A single `shape` of the area to remain unmasked on the `cube`. + If it a line object of some kind then minimum_weight will be ignored, + because you cannot compare the area of a 1D line and 2D Cell + cube : :class:`~iris.cube.Cube` object + The `Cube` object to masked. Must be singular, rather than a `CubeList` + minimum_weight : float , default=0.0 + A number between 0-1 describing what % of a cube cell area must + the shape overlap to include it. + in_place : bool, default=False + Whether to mask the `cube` in-place or return a newly masked `cube`. + Defaults to False. + + Returns + ------- + iris.Cube + A masked version of the input cube, if in_place is False + + + See Also + -------- + :func:`~iris.util.mask_cube` + + Notes + ----- + This function allows masking a cube with any cartopy projection by a shape object, + most commonly from Natural Earth Shapefiles via cartopy. + To mask a cube from a shapefile, both must first be on the same coordinate system. + Shapefiles are mostly on a lat/lon grid with a projection very similar to GeogCS + The shapefile is projected to the coord system of the cube using cartopy, then each cell + is compared to the shapefile to determine overlap and populate a true/false array + This array is then used to mask the cube using the `iris.util.mask_cube` function + This uses numpy arithmetic logic for broadcasting, so you may encounter unexpected + results if your cube has other dimensions the same length as the x/y dimensions + + Examples + -------- + >>> import shapely + >>> from iris.util import mask_cube_from_shapefile + >>> cube = iris.load_cube(iris.sample_data_path("E1_north_america.nc")) + >>> shape = shapely.geometry.box(-100,30, -80,40) # box between 30N-40N 100W-80W + >>> masked_cube = mask_cube_from_shapefile(cube, shape) + + ... + """ + shapefile_mask = create_shapefile_mask(shape, cube, minimum_weight) + masked_cube = mask_cube(cube, shapefile_mask, in_place=in_place) + if not in_place: + return masked_cube