Skip to content

Commit d61b550

Browse files
authored
Merge pull request #57 from andrewdnolan/coastlines
Coastlines (outlines) based on MPAS mesh connectivity info
2 parents e334a05 + b2bba15 commit d61b550

File tree

12 files changed

+310
-49
lines changed

12 files changed

+310
-49
lines changed

docs/developers_guide/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Plotting
1616
:toctree: generated/
1717
1818
polypcolor
19+
coastlines
1920
contour
2021
contourf
2122

docs/user_guide/quick_start.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,25 @@ fig, ax = plt.subplots(
4848
subplot_kw={"projection": projection},
4949
)
5050
51-
cmap = cmocean.tools.crop(cmocean.cm.topo, -5e3, 0, 0.0)
52-
5351
# create a `Descriptor` object which takes the mesh information and creates
5452
# the polygon coordinate arrays needed for `matplotlib.collections.PolyCollection`.
5553
descriptor = mosaic.Descriptor(ds, projection, transform)
5654
5755
# using the `Descriptor` object we just created, make a pseudocolor plot of
58-
# the "indexToCellID" variable, which is defined at cell centers.
56+
# the "bottomDepth" variable, which is defined at cell centers.
5957
collection = mosaic.polypcolor(
60-
ax, descriptor, -ds.bottomDepth, antialiaseds=True, cmap=cmap
58+
ax, descriptor, ds.bottomDepth, antialiaseds=True, cmap=cmocean.cm.deep
6159
)
6260
63-
ax.coastlines(lw=0.5)
64-
ax.add_feature(cfeature.LAND, fc="grey", zorder=-1, alpha=0.8)
61+
# plot the coastlines as resolved by the MPAS mesh
62+
mosaic.coastlines(ax, descriptor)
63+
6564
fig.colorbar(
6665
collection,
6766
fraction=0.1,
6867
shrink=0.4,
6968
extend="both",
70-
label="Topography [m a.s.l.]",
69+
label="Bottom Depth [m]",
7170
);
7271
```
7372

mosaic/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
from __future__ import annotations
22

33
from mosaic import datasets
4+
from mosaic.coastlines import coastlines
45
from mosaic.contour import contour, contourf
56
from mosaic.descriptor import Descriptor
67
from mosaic.polypcolor import polypcolor
78

8-
__all__ = ["Descriptor", "contour", "contourf", "datasets", "polypcolor"]
9+
__all__ = [
10+
"Descriptor",
11+
"coastlines",
12+
"contour",
13+
"contourf",
14+
"datasets",
15+
"polypcolor",
16+
]

mosaic/coastlines.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from __future__ import annotations
2+
3+
import warnings
4+
5+
import cartopy.feature as cfeature
6+
import numpy as np
7+
import shapely
8+
from cartopy.mpl.geoaxes import GeoAxes
9+
10+
from mosaic.contour import ContourGraph, MPASContourGenerator
11+
from mosaic.descriptor import Descriptor
12+
13+
14+
def coastlines(
15+
ax: GeoAxes, descriptor: Descriptor, color: str = "black", **kwargs
16+
) -> None:
17+
"""
18+
Plot coastal **outlines** using the connectivity info from the MPAS mesh
19+
20+
Parameters
21+
----------
22+
ax : cartopy.mpl.geoaxes.GeoAxes
23+
The cartopy axes to add the coastlines to.
24+
descriptor : Descriptor
25+
The descriptor containing the projection and dataset information.
26+
**kwargs
27+
Additional keyword arguments. See
28+
:py:class:`matplotlib.collections.Collection` for supported options.
29+
"""
30+
31+
if not isinstance(ax, GeoAxes):
32+
msg = (
33+
"Must provide a `cartopy.mpl.geoaxes` instance for "
34+
"`mosaic.coastlines` to work. "
35+
)
36+
raise TypeError(msg)
37+
38+
if "edgecolor" in kwargs and "ec" in kwargs:
39+
msg = "Cannot specify both 'edgecolor' and 'ec'."
40+
raise TypeError(msg)
41+
if "edgecolor" in kwargs:
42+
color = kwargs.pop("edgecolor")
43+
elif "ec" in kwargs:
44+
color = kwargs.pop("ec")
45+
46+
if "facecolor" in kwargs and "fc" in kwargs:
47+
msg = "Cannot specify both 'facecolor' and 'fc'."
48+
raise TypeError(msg)
49+
if "facecolor" in kwargs or "fc" in kwargs:
50+
warnings.warn(
51+
"'facecolor (fc)' is not supported for `mosaic.coastlines` "
52+
"and will be ignored.",
53+
stacklevel=2,
54+
)
55+
kwargs.pop("facecolor", None)
56+
kwargs.pop("fc", None)
57+
58+
kwargs["edgecolor"] = color
59+
kwargs["facecolor"] = "none"
60+
61+
generator = MPASCoastlineGenerator(descriptor)
62+
coastlines = generator.create_coastlines()
63+
64+
geometries = shapely.GeometryCollection(
65+
[shapely.LineString(cl) for cl in coastlines]
66+
)
67+
68+
feature = cfeature.ShapelyFeature(geometries, descriptor.projection)
69+
ax.add_feature(feature, **kwargs)
70+
71+
72+
class MPASCoastlineGenerator(MPASContourGenerator):
73+
def __init__(self, descriptor: Descriptor):
74+
# pass a dummy field to the parent class
75+
super().__init__(descriptor, descriptor.ds.nCells)
76+
77+
self.domain = descriptor.projection.domain
78+
self.boundary = descriptor.projection.boundary
79+
80+
shapely.prepare(self.domain)
81+
82+
def create_coastlines(self) -> list[np.ndarray]:
83+
graph = self._create_coastline_graph()
84+
lines = self._split_and_order_graph(graph)
85+
86+
return self._snap_lines_to_boundary(lines)
87+
88+
def _create_coastline_graph(self) -> ContourGraph:
89+
edge_mask = (self.ds.cellsOnEdge == -1).any("TWO").values
90+
91+
vertex_1 = self.ds.verticesOnEdge[edge_mask].isel(TWO=0).values
92+
vertex_2 = self.ds.verticesOnEdge[edge_mask].isel(TWO=1).values
93+
94+
return ContourGraph(vertex_1, vertex_2)
95+
96+
def _snap_lines_to_boundary(
97+
self, lines: list[np.ndarray]
98+
) -> list[np.ndarray]:
99+
def snap(point: np.ndarray):
100+
return self.boundary.interpolate(
101+
self.boundary.project(shapely.Point(point))
102+
)
103+
104+
complete_lines = []
105+
for line in lines:
106+
# only need to snap lines that are not already closed loops
107+
if np.array_equal(line[0], line[-1]):
108+
complete_lines.append(line)
109+
continue
110+
111+
contain_mask = shapely.contains_xy(self.domain, *line.T)
112+
if not contain_mask.any():
113+
continue
114+
115+
clipped = line[contain_mask]
116+
117+
if len(clipped) == 1:
118+
# if only one point inside domain,
119+
# all snapped points will lie along the same line
120+
continue
121+
122+
# TODO: For coastlines with end points outside domain it would be
123+
# better to cut at boundary intersection point rather than snapping
124+
p0, p1 = snap(clipped[0]), snap(clipped[-1])
125+
126+
complete_lines.append(
127+
np.concatenate([np.array(p0.xy).T, clipped, np.array(p1.xy).T])
128+
)
129+
130+
return complete_lines

mosaic/contour.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def __init__(self, descriptor: Descriptor, z: ArrayLike):
251251
self.ds = descriptor.ds
252252
self._z = np.asarray(array)
253253

254-
self.boundary_edge_mask = (self.ds.cellsOnEdge == -1).any("TWO").values
254+
self.boundary_edge_mask = (self.ds.cellsOnEdge < 0).any("TWO").values
255255
self.boundary_vertices = np.unique(
256256
self.ds.verticesOnEdge[self.boundary_edge_mask]
257257
)
@@ -291,9 +291,9 @@ def _create_contour_graph(
291291
""" """
292292
ds = self.ds
293293

294-
padded_mask = np.r_[False, mask]
294+
padded_mask = np.r_[False, False, mask]
295295
# mark mask as False for all cells outside domain
296-
cells_on_edge_mask = np.asarray(padded_mask[ds.cellsOnEdge + 1])
296+
cells_on_edge_mask = np.asarray(padded_mask[ds.cellsOnEdge + 2])
297297

298298
# boolean mask for edges along contour
299299
edge_mask = np.logical_xor.reduce(cells_on_edge_mask, axis=1)

mosaic/descriptor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ def _compute_cell_patches(ds: Dataset) -> ndarray:
725725
# connectivity arrays have already been zero indexed
726726
verticesOnCell = ds.verticesOnCell
727727
# get a mask of the active vertices
728-
mask = verticesOnCell == -1
728+
mask = verticesOnCell < 0
729729

730730
# tile the first vertices index
731731
firstVertex = np.tile(verticesOnCell[:, 0], (maxEdges, 1)).T
@@ -782,8 +782,8 @@ def _compute_vertex_patches(ds: Dataset) -> ndarray:
782782
cellsOnVertex = ds.cellsOnVertex.values
783783
edgesOnVertex = ds.edgesOnVertex.values
784784
# get a mask of active nodes
785-
cellMask = cellsOnVertex == -1
786-
edgeMask = edgesOnVertex == -1
785+
cellMask = cellsOnVertex < 0
786+
edgeMask = edgesOnVertex < 0
787787

788788
# get the coordinates needed to patch construction
789789
xCell = ds.xCell.values

mosaic/utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ def _make_lookup_table(mask: np.ndarray[bool]) -> np.ndarray[np.int64]:
4444
old_idx = np.flatnonzero(mask) # 0..N-1
4545
new_idx = np.arange(old_idx.size, dtype=np.int64)
4646

47-
# lut[0] reserved for old=-1
48-
lut = np.full(mask.size + 1, -1, dtype=np.int64)
47+
# make culling along projection boundary with -2
48+
lut = np.full(mask.size + 1, -2, dtype=np.int64)
49+
# culled land boundaries stay -1
50+
lut[0] = -1
4951
lut[old_idx + 1] = new_idx
5052

5153
return lut

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ build-backend = "setuptools.build_meta"
6464
minversion = "6"
6565
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
6666
xfail_strict = true
67-
filterwarnings = ["error"]
67+
filterwarnings = [
68+
"error",
69+
"ignore:numpy.ndarray size changed:RuntimeWarning",
70+
]
6871
log_cli_level = "INFO"
6972
testpaths = ["tests"]
7073

tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
5+
import cartopy.crs as ccrs
6+
import pytest
7+
8+
import mosaic
9+
10+
# get the names, as strings, of unsupported projections for spherical meshes
11+
unsupported = [
12+
p.__name__ for p in mosaic.descriptor.UNSUPPORTED_SPHERICAL_PROJECTIONS
13+
]
14+
15+
PROJECTIONS = [
16+
obj()
17+
for name, obj in inspect.getmembers(ccrs)
18+
if inspect.isclass(obj)
19+
and issubclass(obj, ccrs.Projection)
20+
and not name.startswith("_") # skip internal classes
21+
and obj is not ccrs.Projection # skip base Projection class
22+
and name not in unsupported # skip unsupported projections
23+
]
24+
25+
26+
def id_func(projection):
27+
return type(projection).__name__
28+
29+
30+
@pytest.fixture(scope="module", params=PROJECTIONS, ids=id_func)
31+
def iterate_supported_projections(request):
32+
def _factory(ds):
33+
return mosaic.Descriptor(ds, request.param, ccrs.Geodetic())
34+
35+
return _factory

0 commit comments

Comments
 (0)