Skip to content

Commit

Permalink
support cartographic add_points (#442)
Browse files Browse the repository at this point in the history
* decorate dunder inits with return types

* add_points

* fix doc-strings

* minor refactor

* refactor earthquakes example

* add_points doc-string and traps

* fix mocker patch pattern

* test coverage
  • Loading branch information
bjlittle authored Sep 18, 2023
1 parent 0948b72 commit e5eee73
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 24 deletions.
7 changes: 2 additions & 5 deletions src/geovista/examples/earthquakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from warnings import warn

import geovista as gv
from geovista.common import to_cartesian
from geovista.pantry import usgs_earthquakes
import geovista.theme # noqa: F401

Expand Down Expand Up @@ -56,14 +55,12 @@ def main() -> None:
warn(wmsg, stacklevel=2)
return

# convert coordinate to cartesian
points = to_cartesian(lons=sample.lons, lats=sample.lats)

# plot the mesh
plotter = gv.GeoPlotter()
sargs = {"title": "Magnitude", "shadow": True}
plotter.add_points(
points,
xs=sample.lons,
ys=sample.lats,
cmap="fire_r",
render_points_as_spheres=True,
scalars=sample.data,
Expand Down
173 changes: 167 additions & 6 deletions src/geovista/geoplotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
from typing import Any
from warnings import warn

import numpy.typing as npt
from pyproj import CRS
import pyvista as pv
import pyvista.core.utilities.helpers as helpers

from .bridge import Transform
from .common import (
GV_FIELD_ZSCALE,
GV_REMESH_POINT_IDS,
Expand All @@ -32,8 +35,10 @@
from .core import add_texture_coords, resize, slice_mesh
from .crs import (
WGS84,
CRSLike,
from_wkt,
get_central_meridian,
has_wkt,
projected,
set_central_meridian,
to_wkt,
Expand All @@ -51,6 +56,9 @@

__all__ = ["GeoPlotter"]

#: The valid 'style' options for adding points.
ADD_POINTS_STYLE: list[str, ...] = ["points", "points_gaussian"]

#: Proportional multiplier for z-axis levels/offsets of base-layer mesh.
BASE_ZLEVEL_SCALE: int = 1.0e-3

Expand Down Expand Up @@ -436,7 +444,7 @@ def add_graticule(
meridian. Defaults to :data:`geovista.gridlines.LONGITUDE_START`.
lon_stop : float, optional
The last line of longitude (degrees). The graticule will include this
meridian when it is a multiple of ``lon_step``. Also see
meridian when it is a multiple of `lon_step`. Also see
``closed_interval``. Defaults to :data:`geovista.gridlines.LONGITUDE_STOP`.
lon_step : float, optional
The delta (degrees) between neighbouring meridians. Defaults to
Expand All @@ -447,7 +455,7 @@ def add_graticule(
:data:`geovista.gridlines.LATITUDE_START`.
lat_stop : float, optional
The last line of latitude (degrees). The graticule will include this
parallel when it is a multiple of ``lat_step``. Defaults to
parallel when it is a multiple of `lat_step`. Defaults to
:data:`geovista.gridlines.LATITUDE_STOP`.
lat_step : float, optional
The delta (degrees) between neighbouring parallels. Defaults to
Expand Down Expand Up @@ -509,7 +517,7 @@ def add_graticule(
)

def add_mesh(self, mesh: Any, **kwargs: Any | None) -> pv.Actor:
"""Add the ``mesh`` to the plotter scene.
"""Add the `mesh` to the plotter scene.
See :meth:`pyvista.Plotter.add_mesh`.
Expand Down Expand Up @@ -704,7 +712,7 @@ def add_meridians(
meridian. Defaults to :data:`geovista.gridlines.LONGITUDE_START`.
stop : float, optional
The last line of longitude (degrees). The graticule will include this
meridian when it is a multiple of ``step``. Also see ``closed_interval``.
meridian when it is a multiple of `step`. Also see ``closed_interval``.
Defaults to :data:`geovista.gridlines.LONGITUDE_STOP`.
step : float, optional
The delta (degrees) between neighbouring meridians. Defaults to
Expand Down Expand Up @@ -872,11 +880,11 @@ def add_parallels(
----------
start : float, optional
The first line of latitude (degrees). The graticule will include this
parallel. Also see ``poles_parallel``. Defaults to
parallel. Also see `poles_parallel`. Defaults to
:data:`geovista.gridlines.LATITUDE_START`.
stop : float, optional
The last line of latitude (degrees). The graticule will include this
parallel when it is a multiple of ``step``. Also see ``poles_parallel`.
parallel when it is a multiple of `step`. Also see `poles_parallel`.
Defaults to :data:`geovista.gridlines.LATITUDE_STOP`.
step : float, optional
The delta (degrees) between neighbouring parallels. Defaults to
Expand Down Expand Up @@ -965,6 +973,159 @@ def add_parallels(
point_labels_args=point_labels_args,
)

def add_points(
self,
points: npt.ArrayLike | pv.PolyData | None = None,
xs: npt.ArrayLike | None = None,
ys: npt.ArrayLike | None = None,
scalars: str | npt.ArrayLike | None = None,
crs: CRSLike | None = None,
radius: float | None = None,
style: str | None = None,
zlevel: int | npt.ArrayLike | None = None,
zscale: float | None = None,
**kwargs: Any | None,
) -> pv.Actor:
"""Add points to the plotter scene.
See :meth:`pyvista.Plotter.add_mesh`.
Parameters
----------
points : ArrayLike or PolyData, optional
Array of xyz points, in canonical `crs` units, or the points of the mesh
to be rendered.
xs : ArrayLike, optional
A 1-D, 2-D or 3-D array of point-cloud x-values, in canonical `crs` units.
Must have the same shape as the `ys`.
ys : ys : ArrayLike
A 1-D, 2-D or 3-D array of point-cloud y-values, in canonical `crs` units.
Must have the same shape as the `xs`.
scalars : str or ArrayLike, optional
Values used to color the points. Either, the string name of an array that is
present on the `points` mesh or an array equal to the number of points.
Alternatively, an array of values equal to the number of points to be
rendered. If both `color` and `scalars` are ``None``, then the active
scalars on the `points` mesh are used.
crs : CRSLike, optional
The Coordinate Reference System of the provided `points`, or `xs` and `ys`.
May be anything accepted by :meth:`pyproj.CRS.from_user_input`. Defaults
to ``EPSG:4326`` i.e., ``WGS 84``.
radius : float, optional
The radius of the mesh point-cloud. Defaults to
:data:`geovista.common.RADIUS`.
style : str, optional
Visualization style of the points to be rendered. Maybe either ``points``
or ``points_gaussian``. The ``points_gaussian`` option maybe controlled
with the ``emissive`` and ``render_points_as_spheres`` options.
zlevel : int or ArrayLike, default=0
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
If `zlevel` is not a scalar, then its shape must match or broadcast
with the shape of the `xs` and `ys`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
**kwargs : dict, optional
See :meth:`pyvista.Plotter.add_mesh`.
Returns
-------
Actor
The rendered actor added to the plotter scene.
Notes
-----
.. versionadded:: 0.4.0
"""
if crs is not None:
# sanity check the source crs
crs = CRS.from_user_input(crs)

if style is None:
style = "points"

if style not in ADD_POINTS_STYLE:
options = "or ".join(f"{option!r}" for option in ADD_POINTS_STYLE)
emsg = (
f"Invalid 'style' for 'add_points', expected {options}, "
f"got {style!r}."
)
raise ValueError(emsg)

if points is None and xs is None and ys is None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got neither."
)
raise ValueError(emsg)

if points is not None and xs is not None and ys is not None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got both 'points', and 'xs' and 'ys'."
)
raise ValueError(emsg)

if points is not None:
if xs is not None or ys is not None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got both 'points', and 'xs' or 'ys'."
)
raise ValueError(emsg)

if not helpers.is_pyvista_dataset(points):
points = helpers.wrap(points)

mesh = points

if crs is not None:
if has_wkt(mesh):
other = from_wkt(mesh)
if other != crs:
emsg = (
"The CRS serialized as WKT on the 'points' mesh does not "
"match the provided 'crs'."
)
raise ValueError(emsg)
else:
# serialize the provided CRS on the points mesh as wkt
to_wkt(mesh, crs)
elif not has_wkt(mesh):
# assume CRS is WGS84
to_wkt(mesh, WGS84)
else:
if xs is None or ys is None:
emsg = (
"Require either 'points', or both 'xs' and 'ys' to be specified, "
"got only 'xs' or 'ys'."
)
raise ValueError(emsg)

if isinstance(scalars, str):
wmsg = (
f"Ignoring the 'scalars' string name '{scalars}', as no 'points' "
"mesh was provided."
)
warn(wmsg, stacklevel=2)

mesh = Transform.from_points(
xs=xs,
ys=ys,
crs=crs,
radius=radius,
zlevel=zlevel,
zscale=zscale,
)

# defensive kwarg pop
if "texture" in kwargs:
_ = kwargs.pop("texture")

return self.add_mesh(mesh, style=style, scalars=scalars, **kwargs)


class GeoPlotter(GeoPlotterBase, pv.Plotter):
"""A geospatial aware plotter.
Expand Down
28 changes: 17 additions & 11 deletions tests/geometry/test_coastlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
def test_defaults(mocker):
"""Test expected defaults are honoured."""
mesh = mocker.sentinel.mesh
fetch = mocker.patch("geovista.geometry.fetch_coastlines", return_value=mesh)
resize = mocker.patch("geovista.geometry.resize")
_ = mocker.patch("geovista.geometry.fetch_coastlines", return_value=mesh)
_ = mocker.patch("geovista.geometry.resize")
result = coastlines()
assert result == mesh
fetch.assert_called_once_with(resolution=COASTLINES_RESOLUTION)
from geovista.geometry import fetch_coastlines, resize

fetch_coastlines.assert_called_once_with(resolution=COASTLINES_RESOLUTION)
resize.assert_called_once_with(
mesh, radius=None, zlevel=1, zscale=None, inplace=True
)
Expand All @@ -25,23 +27,27 @@ def test_resize_kwarg_pass_thru(mocker):
radius = mocker.sentinel.radius
zscale = mocker.sentinel.zscale
zlevel = mocker.sentinel.zlevel
fetch = mocker.patch("geovista.geometry.fetch_coastlines", return_value=mesh)
resize = mocker.patch("geovista.geometry.resize")
_ = mocker.patch("geovista.geometry.fetch_coastlines", return_value=mesh)
_ = mocker.patch("geovista.geometry.resize")
kwargs = {"radius": radius, "zscale": zscale, "zlevel": zlevel}
result = coastlines(resolution=resolution, **kwargs)
assert result == mesh
fetch.assert_called_once_with(resolution=resolution)
from geovista.geometry import fetch_coastlines, resize

fetch_coastlines.assert_called_once_with(resolution=resolution)
resize.assert_called_once_with(mesh, **kwargs, inplace=True)


def test_fetch_exception(mocker):
"""Test coastlines are loaded on cache fetch failure."""
mesh = mocker.sentinel.mesh
fetch = mocker.patch("geovista.geometry.fetch_coastlines", side_effect=ValueError)
load = mocker.patch("geovista.geometry.load_coastlines", return_value=mesh)
resize = mocker.patch("geovista.geometry.resize")
_ = mocker.patch("geovista.geometry.fetch_coastlines", side_effect=ValueError)
_ = mocker.patch("geovista.geometry.load_coastlines", return_value=mesh)
_ = mocker.patch("geovista.geometry.resize")
result = coastlines()
assert result == mesh
assert fetch.call_count == 1
from geovista.geometry import fetch_coastlines, load_coastlines, resize

assert fetch_coastlines.call_count == 1
assert resize.call_count == 1
load.assert_called_once_with(resolution=COASTLINES_RESOLUTION)
load_coastlines.assert_called_once_with(resolution=COASTLINES_RESOLUTION)
Loading

0 comments on commit e5eee73

Please sign in to comment.