Source code for geovista.geodesic

# Copyright (c) 2021, GeoVista Contributors.
#
# This file is part of GeoVista and is distributed under the 3-Clause BSD license.
# See the LICENSE file in the package root directory for licensing details.

"""Provide geodesic operators for geolocated meshes.

Notes
-----
.. versionadded:: 0.1.0

"""

from __future__ import annotations

from collections.abc import Iterable
from enum import Enum
from typing import TYPE_CHECKING, TypeAlias
import warnings

import lazy_loader as lazy

from .common import (
    GV_FIELD_RADIUS,
    RADIUS,
    ZLEVEL_SCALE,
    MixinStrEnum,
    distance,
    to_cartesian,
    wrap,
)
from .common import cast_UnstructuredGrid_to_PolyData as cast
from .crs import WGS84, to_wkt

if TYPE_CHECKING:
    import numpy as np
    from numpy.typing import ArrayLike
    import pyvista as pv

# lazy import third-party dependencies
np = lazy.load("numpy")
pyproj = lazy.load("pyproj")
pv = lazy.load("pyvista")

__all__ = [
    "BBOX_C",
    "BBOX_RADIUS_RATIO",
    "BBOX_TOLERANCE",
    "BBox",
    "Corners",
    "ELLIPSE",
    "EnclosedPreference",
    "GEODESIC_NPTS",
    "N_PANELS",
    "PANEL_BBOX_BY_IDX",
    "PANEL_IDX_BY_NAME",
    "PANEL_NAME_BY_IDX",
    "PREFERENCE",
    "line",
    "npoints",
    "npoints_by_idx",
    "panel",
    "wedge",
]

# type aliases
Corners: TypeAlias = tuple[float, float, float, float]
"""Type alias for the corners of a bounding-box."""

# constants
ELLIPSE: str = "WGS84"
"""Default geodesic ellipse. See :func:`pyproj.list.get_ellps_map`."""

GEODESIC_NPTS: int = 64
"""Number of equally spaced geodesic points between/including end-point/s."""

BBOX_C: int = 256
"""The bounding-box face geometry will contain ``BBOX_C**2`` cells."""

BBOX_RADIUS_RATIO = 1e-1
"""Ratio the bounding-box inner and outer faces are offset from the surface mesh."""

BBOX_TOLERANCE = 1e-6
"""The bounding-box tolerance on intersection."""

PANEL_IDX_BY_NAME: dict[str, int] = {
    "africa": 0,
    "asia": 1,
    "pacific": 2,
    "americas": 3,
    "arctic": 4,
    "antarctic": 5,
}
"""Lookup table for cubed-sphere panel index by panel name."""

PANEL_NAME_BY_IDX: dict[int, str] = {
    0: "africa",
    1: "asia",
    2: "pacific",
    3: "americas",
    4: "arctic",
    5: "antarctic",
}
"""Lookup table for cubed-sphere panel name by panel index."""

CSC: float = np.rad2deg(np.arcsin(1 / np.sqrt(3)))
"""Latitude (degrees) of a cubed-sphere panel corner."""

PANEL_BBOX_BY_IDX: dict[int, tuple[Corners, Corners]] = {
    0: ((-45, 45, 45, -45), (CSC, CSC, -CSC, -CSC)),
    1: ((45, 135, 135, 45), (CSC, CSC, -CSC, -CSC)),
    2: ((135, -135, -135, 135), (CSC, CSC, -CSC, -CSC)),
    3: ((-135, -45, -45, -135), (CSC, CSC, -CSC, -CSC)),
    4: ((-45, 45, 135, -135), (CSC, CSC, CSC, CSC)),
    5: ((-45, 45, 135, -135), (-CSC, -CSC, -CSC, -CSC)),
}
"""Cubed-sphere panel bounding-box longitudes and latitudes."""

N_PANELS: int = len(PANEL_IDX_BY_NAME)
"""The number of cubed-sphere panels."""

PREFERENCE: str = "center"
"""The default bounding-box preference."""


# TODO @bjlittle: Use StrEnum and auto when minimum supported python version is 3.11.
[docs] class EnclosedPreference(MixinStrEnum, Enum): """Enumeration of mesh geometry enclosed preferences. Notes ----- .. versionadded:: 0.3.0 """ CELL = "cell" CENTER = "center" POINT = "point"
[docs] class BBox: # numpydoc ignore=PR01 """A 3-D bounding-box constructed from geodesic lines or great circles.""" def __init__( self, lons: ArrayLike, lats: ArrayLike, ellps: str | None = ELLIPSE, c: int | None = BBOX_C, triangulate: bool | None = False, ) -> None: """Create 3-D geodesic bounding-box to extract enclosed mesh, lines or point. The bounding-box region is specified in terms of its four corners, in degrees of longitude and latitude. As the bounding-box is a geodesic, it can only ever at most enclose half of an ellipsoid. The geometry of the bounding-box may be specified as either an open or closed longitude/latitude geometry i.e., 4 or 5 longitude/latitude values. Parameters ---------- lons : ArrayLike The longitudes (degrees) of the bounding-box, in the half-closed interval ``[-180, 180)``. Note that, longitudes will be wrapped to this interval. lats : ArrayLike The latitudes (degrees) of the bounding-box, in the closed interval ``[-90, 90]``. ellps : str, optional The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`. Defaults to :data:`ELLIPSE`. c : float, optional The bounding-box face geometry will contain ``c**2`` cells. Defaults to :data:`BBOX_C`. triangulate : bool, optional Specify whether the bounding-box faces are triangulated. Defaults to ``False``. Notes ----- .. versionadded:: 0.1.0 """ if not isinstance(lons, Iterable): lons = [lons] if not isinstance(lats, Iterable): lats = [lats] lons = np.asanyarray(lons) lats = np.asanyarray(lats) n_lons, n_lats = lons.size, lats.size if n_lons != n_lats: emsg = ( f"Require the same number of longitudes ({n_lons}) and " f"latitudes ({n_lats})." ) raise ValueError(emsg) if n_lons < 4: emsg = ( "Require a bounding-box geometry containing at least 4 " "longitude/latitude values to create the bounding-box manifold, " f"got '{n_lons}'." ) raise ValueError(emsg) if n_lons > 5: emsg = ( "Require a bounding-box geometry containing 4 (open) or 5 (closed) " "longitude/latitude values to create the bounding-box manifold, " f"got {n_lons}." ) raise ValueError(emsg) # ensure the specified bbox geometry is open if n_lons == 5: if np.isclose(lons[0], lons[-1]) and np.isclose(lats[0], lats[-1]): lons, lats = lons[-1], lats[-1] else: wmsg = ( "geovista bounding-box was specified with 5 longitude/latitude" "values, however the first and last values are not close enough to " "specify a closed geometry - ignoring last value." ) warnings.warn(wmsg, stacklevel=2) lons, lats = lons[:-1], lats[:-1] self.lons = lons self.lats = lats self.ellps = ellps self.c = c self.triangulate = triangulate # the resultant bounding-box mesh self._mesh = None # cache prior surface radius, as an optimisation self._surface_radius = None def __eq__(self, other: BBox) -> bool: result = NotImplemented if isinstance(other, BBox): result = False lhs = (self.ellps, self.c, self.triangulate) rhs = (other.ellps, other.c, other.triangulate) if all(x[0] == x[1] for x in zip(lhs, rhs, strict=True)) and np.allclose( self.lons, other.lons ): result = np.allclose(self.lats, other.lats) return result def __ne__(self, other: BBox) -> bool: result = self == other if result is not NotImplemented: result = not result return result def __repr__(self) -> str: params = ( f"ellps={self.ellps}, c={self.c}, n_points={self.mesh.n_points}, " f"n_cells={self.mesh.n_cells}" ) return f"{__package__}.{self.__class__.__name__}<{params}>" @property def mesh(self) -> pv.PolyData: """The manifold bounding-box mesh. Returns ------- PolyData The bounding-box mesh. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add a ``C32`` bounding-box around the Gulf of Guinea. The geodesic bounding-box is generated by defining 4 longitude-latitude corners. Natural Earth coastlines are also rendered along with a texture mapped Natural Earth base layer. >>> import geovista >>> from geovista.geodesic import BBox >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer( ... texture=geovista.natural_earth_hypsometric(), style="wireframe" ... ) >>> bbox = BBox(lons=[-15, 20, 25, -15], lats=[-25, -20, 15, 10], c=32) >>> _ = plotter.add_mesh(bbox.mesh, color="white") >>> plotter.camera.zoom(1.5) >>> plotter.show() """ if self._mesh is None: self._generate_bbox_mesh() return self._mesh def _init(self) -> None: """Bootstrap the bounding-box state. Notes ----- .. versionadded:: 0.1.0 """ self._idx_map = np.empty((self.c + 1, self.c + 1), dtype=int) self._bbox_lons, self._bbox_lats = [], [] self._bbox_count = 0 self._geod = pyproj.Geod(ellps=self.ellps) self._npts = self.c - 1 self._n_faces = self.c * self.c self._n_points = (self.c + 1) * (self.c + 1) def _bbox_face_edge_idxs(self) -> np.ndarray: """Get the bounding-box outer edge indices. Inspects the index map (_idx_map) topology to determine the sequence of indices that define the bounding-box edge/boundary. This sequence of indices is open i.e., it's implied that the last index is connected to the first in the sequence. The indices (_idx_map) reference the actual longitude/latitude values (_bbox_lons/_bbox_lats). This state is configured when generating the bounding-box face (_generate_bbox_face). Returns ------- ndarray The indices of the bounding-box edge. Notes ----- .. versionadded:: 0.1.0 """ return np.concatenate( [ self._idx_map[0], self._idx_map[1:, -1], self._idx_map[-1, -2::-1], self._idx_map[-2:0:-1, 0], ] ) def _generate_bbox_face(self) -> None: """Construct 2-D geodetic bounding-box surface defined by corners. Given the longitude/latitude corners of the bounding-box and the number of faces that define the bounding-box mesh i.e., c**2, determine all the associated geodesic points (geometry) and indices (topology) of the bounding-box mesh. The indices (_idx_map) reference the longitude/latitude points (_bbox_lon/_bbox_lat), and together are required to create the resultant bounding-box :class:`pyvista.PolyData` mesh. Notes ----- .. versionadded:: 0.1.0 """ # corner indices c1_idx, c2_idx, c3_idx, c4_idx = range(4) def bbox_extend(lons: Iterable[float], lats: Iterable[float]) -> None: """Register the bounding box longitudes and latitudes. Parameters ---------- lons : iterable of float The bounding box longitudes. lats : iterable of float The bounding box latitudes. """ assert len(lons) == len(lats) self._bbox_lons.extend(lons) self._bbox_lats.extend(lats) self._bbox_count += len(lons) def bbox_update( idx1: int, idx2: int, row: int | None = None, column: int | None = None ) -> None: """Generate and register a sampled geodesic bounding box line. The line is either a row or column that is a bounding box edge, or a row or column that traverses across the surface of the bounding box. Parameters ---------- idx1 : int The start index of the bounding box, located on an edge. idx2 : int The end index of the bounding box, located on an edge. row : int, optional The index of the bounding box row. If not provided, the column spans all rows. column : int, optional The index of the bounding box column. If not provided, the row spans all columns. """ assert row is not None or column is not None if row is None: row = slice(None) if column is None: column = slice(None) glons, glats = npoints_by_idx( self._bbox_lons, self._bbox_lats, idx1, idx2, npts=self._npts, geod=self._geod, ) self._idx_map[row, column] = [ idx1, *(np.arange(self._npts) + self._bbox_count), idx2, ] bbox_extend(glons, glats) # register bbox edge indices, and points bbox_extend(self.lons, self.lats) bbox_update(c1_idx, c2_idx, row=0) bbox_update(c4_idx, c3_idx, row=-1) bbox_update(c1_idx, c4_idx, column=0) bbox_update(c2_idx, c3_idx, column=-1) # register bbox inner indices and points for row_idx in range(1, self.c): row = self._idx_map[row_idx] bbox_update(row[0], row[-1], row=row_idx) def _generate_bbox_mesh( self, surface: pv.PolyData | None = None, radius: float | None = None ) -> None: """Construct 3-D geodetic bounding-box extruded surface defined by corners. The bounding-box mesh consists of an inner surface, an outer surface, and a skirt that joins these two surfaces together to create a mesh that is a manifold. The purpose of the bounding-box is to determine which faces/points of a third-party mesh are contained/enclosed within the bounding-box manifold. Note that, the bounding-box inner surface and outer surface are congruent. Parameters ---------- surface : PolyData, optional The surface that the bounding-box will be enclosing. radius : float, optional The radius of the surface that the bounding-box will be enclosing. Note that, the `radius` is only used when the `surface` is not provided. Defaults to :data:`geovista.common.RADIUS`. Notes ----- .. versionadded:: 0.1.0 """ if surface is not None: radius = distance(surface) radius = RADIUS if radius is None else abs(float(radius)) if radius != self._surface_radius: self._init() self._surface_radius = radius self._generate_bbox_face() skirt_faces = self._generate_bbox_skirt() # generate the face indices bbox_n_faces = self._n_faces * 2 faces_n = np.broadcast_to(np.array([4], dtype=np.int8), (bbox_n_faces, 1)) faces_c1 = np.ravel(self._idx_map[: self.c, : self.c]).reshape(-1, 1) faces_c2 = np.ravel(self._idx_map[: self.c, 1:]).reshape(-1, 1) faces_c3 = np.ravel(self._idx_map[1:, 1:]).reshape(-1, 1) faces_c4 = np.ravel(self._idx_map[1:, : self.c]).reshape(-1, 1) inner_faces = np.hstack([faces_c1, faces_c2, faces_c3, faces_c4]) outer_faces = inner_faces + self._n_points faces = np.vstack([inner_faces, outer_faces]) bbox_faces = np.hstack([faces_n, faces]) # convert bbox lons/lats to ndarray (internal convenience i.e., boundary) self._bbox_lons = np.asanyarray(self._bbox_lons) self._bbox_lats = np.asanyarray(self._bbox_lats) # calculate the radii of the inner and outer bbox faces offset = self._surface_radius * BBOX_RADIUS_RATIO inner_radius = self._surface_radius - offset outer_radius = self._surface_radius + offset # generate the face points inner_xyz = to_cartesian( self._bbox_lons, self._bbox_lats, radius=inner_radius ) outer_xyz = to_cartesian( self._bbox_lons, self._bbox_lats, radius=outer_radius ) bbox_xyz = np.vstack([inner_xyz, outer_xyz]) # include the bbox skirt bbox_faces = np.vstack([bbox_faces, skirt_faces]) # create the bbox mesh self._mesh = pv.PolyData(bbox_xyz, faces=bbox_faces) self._mesh.field_data[GV_FIELD_RADIUS] = np.array([radius]) to_wkt(self._mesh, WGS84) if self.triangulate: self._mesh = self._mesh.triangulate() def _generate_bbox_skirt(self) -> np.ndarray: """Calculate indices of faces for boundary-box skirt. Determine the indices of the skirt that will join the inner and outer bounding-box surfaces to create a "water-tight" manifold. Returns ------- ndarray The indices of the bounding-box skirt. Notes ----- .. verseionadded:: 0.1.0 """ skirt_n_faces = 4 * self.c faces_n = np.broadcast_to(np.array([4], dtype=np.int8), (skirt_n_faces, 1)) faces_c1 = self._bbox_face_edge_idxs().reshape(-1, 1) faces_c2 = np.roll(faces_c1, -1) faces_c3 = faces_c2 + self._n_points faces_c4 = np.roll(faces_c3, 1) return np.hstack([faces_n, faces_c1, faces_c2, faces_c3, faces_c4])
[docs] def boundary( self, surface: pv.PolyData | None = None, radius: float | None = None ) -> pv.PolyData: """Footprint of bounding-box intersecting on the provided mesh surface. The region of the bounding-box that intersects on the surface of the mesh that will be enclosed. Parameters ---------- surface : PolyData, optional The :class:`pyvista.PolyData` mesh that will be enclosed by the bounding-box boundary. radius : float, optional The radius of the spherical mesh that will be enclosed by the bounding-box boundary. Note that, the `radius` is only used when the `surface` is not provided. Defaults to :data:`geovista.common.RADIUS`. Returns ------- PolyData The boundary of the bounding-box. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add the boundary of a ``C32`` bounding-box to the plotter for a region around the Gulf of Guinea. The geodesic bounding-box is generated by defining 4 longitude-latitude corners. The boundary is generated from where the bounding-box intersects with the surface of the C48 Sea Surface Temperature (SST) cubed-sphere mesh. >>> import geovista >>> from geovista.geodesic import BBox >>> from geovista.pantry.meshes import lfric_sst >>> plotter = geovista.GeoPlotter() >>> mesh = lfric_sst() >>> _ = plotter.add_mesh(mesh, cmap="balance") >>> bbox = BBox(lons=[-15, 20, 25, -15], lats=[-25, -20, 15, 10], c=32) >>> _ = plotter.add_mesh(bbox.boundary(mesh), color="orange", line_width=3) >>> plotter.view_yz() >>> plotter.show() """ self._generate_bbox_mesh(surface=surface, radius=radius) radius = self._surface_radius + self._surface_radius * ZLEVEL_SCALE edge_idxs = self._bbox_face_edge_idxs() edge_lons = self._bbox_lons[edge_idxs] edge_lats = self._bbox_lats[edge_idxs] edge_xyz = to_cartesian(edge_lons, edge_lats, radius=radius) edge = pv.lines_from_points(edge_xyz, close=True) edge.field_data[GV_FIELD_RADIUS] = np.array([radius]) to_wkt(edge, WGS84) return edge
[docs] def enclosed( self, surface: pv.PolyData, tolerance: float | None = BBOX_TOLERANCE, outside: bool | None = False, preference: str | EnclosedPreference | None = None, ) -> pv.PolyData: """Extract region of the `surface` contained within the bounding-box. Note that, points that are on the surface of the bounding-box manifold are not considered within the bounding-box. See the `preference` and `tolerance` options. Parameters ---------- surface : PolyData The :class:`pyvista.PolyData` mesh to be checked for containment. tolerance : float, optional The tolerance on the intersection operation with the `surface`, expressed as a fraction of the diagonal of the bounding-box. See PyVista's :meth:`~pyvista.DataSetFilters.select_enclosed_points` for more. Defaults to :data:`BBOX_TOLERANCE`. outside : bool, optional By default, select those points of the `surface` that are inside the bounding-box. Otherwise, select those points that are outside the bounding-box. Defaults to ``False``. preference : str or EnclosedPreference, optional Criteria for defining whether a face of a `surface` mesh is deemed to be enclosed by the bounding-box. A `preference` of ``cell`` requires all points defining the face to be within the bounding-box. A `preference` of ``center`` requires that only the face cell center is within the bounding-box. A `preference` of ``point`` requires at least one point that defines the face to be within the bounding-box. Defaults to :data:`PREFERENCE`. Returns ------- PolyData The :class:`pyvista.PolyData` representing those parts of the provided `surface` enclosed by the bounding-box. This behaviour may be inverted with the `outside` parameter. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add the boundary of a ``C32`` bounding-box to the plotter for a region around the Gulf of Guinea. The geodesic bounding-box is generated by defining 4 longitude-latitude corners. Add the region enclosed by a ``C32`` bounding-box manifold to the plotter for a region around the ``0`` meridian. The geodesic bounding-box is generated by defining 4 longitude-latitude corners. The region is generated from all cells of the C48 Sea Surface Temperature (SST) cubed-sphere mesh that have their cell ``center`` enclosed by the bounding-box manifold. >>> import geovista >>> from geovista.geodesic import BBox >>> from geovista.pantry.meshes import lfric_sst >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer(texture=geovista.natural_earth_hypsometric()) >>> mesh = lfric_sst() >>> bbox = BBox(lons=[-15, 20, 25, -15], lats=[-25, -20, 15, 10], c=32) >>> region = bbox.enclosed(mesh) >>> _ = plotter.add_mesh(region, cmap="balance") >>> plotter.view_yz() >>> plotter.show() The same ``region`` is rendered again, but with the land mask cells removed using the :meth:`pyvista.DataSetFilters.threshold` filter. >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer(texture=geovista.natural_earth_hypsometric()) >>> _ = plotter.add_mesh(region.threshold(), cmap="balance") >>> plotter.view_yz() >>> plotter.show() """ if preference is None: preference = PREFERENCE if not EnclosedPreference.valid(preference): options = " or ".join(f"{item!r}" for item in EnclosedPreference.values()) emsg = f"Expected a preference of {options}, got '{preference}'." raise ValueError(emsg) # capture the current active scalars name active_scalars_name = surface.active_scalars_name preference = EnclosedPreference(preference) self._generate_bbox_mesh(surface=surface) if preference == EnclosedPreference.CENTER: original = surface surface = surface.cell_centers() # filter the surface with the bbox manifold mesh selected = surface.select_enclosed_points( self.mesh, tolerance=tolerance, inside_out=outside, check_surface=False ) # name of the point mask generated by select_enclosed_points scalars = "SelectedPoints" # sample the surface with the enclosed cells to extract the bbox region if preference == EnclosedPreference.CENTER: region = original.extract_cells(selected[scalars].view(bool)) elif preference == EnclosedPreference.POINT: region = selected.threshold(0.5, scalars=scalars, preference="cell") else: region = surface.extract_points( selected[scalars].view(bool), adjacent_cells=False ) # ensure to preserve active scalars name region.active_scalars_name = active_scalars_name return cast(region)
[docs] def line( lons: ArrayLike, lats: ArrayLike, surface: pv.PolyData | None = None, radius: float | None = None, npts: int | None = None, ellps: str | None = None, close: bool | None = False, zlevel: int | None = None, zscale: float | None = None, ) -> pv.PolyData: """Geodesic line consisting of one or more connected geodesic line segments. Note that, as a convenience, if a single value is provided for `lons` and ``N`` values are provided for `lats`, then the longitude value will be automatically repeated ``N`` times, and vice versa, providing ``N >= 2``. Parameters ---------- lons : ArrayLike The longitudes (degrees) of the geodesic line segments, in the half-closed interval ``[-180, 180)``. Note that, longitudes will be wrapped to this interval. lats : ArrayLike The latitudes (degrees) of the geodesic line segments, in the closed interval ``[-90, 90]``. surface : PolyData, optional The surface that the geodesic line will be rendered over. radius : float, optional The radius of the spherical surface that the geodesic line will be rendered over. Note that, the `radius` is only used when the `surface` is not provided. Defaults to :data:`geovista.common.RADIUS`. npts : float, optional The number of equally spaced geodesic points in a line segment, excluding the segment end-point, but including the segment start-point i.e., `npts` must be at least 2. Defaults to :data:`GEODESIC_NPTS`. ellps : str, optional The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`. Defaults to :data:`ELLIPSE`. close : bool, optional Whether to close the geodesic line segments into a loop i.e., the last point is connected to the first point. Defaults to ``False``. zlevel : int, optional The z-axis level. Used in combination with the `zscale` to offset the `radius` by a proportional amount i.e., ``radius * zlevel * zscale``. Defaults to ``1``. zscale : float, optional The proportional multiplier for z-axis `zlevel`. Defaults to :data:`geovista.common.ZLEVEL_SCALE`. Returns ------- PolyData The geodesic line. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add the anti-meridian great circle to the plotter. A texture mapped Natural Earth base layer is also rendered. >>> import geovista >>> from geovista.geodesic import line >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer(texture=geovista.natural_earth_1()) >>> meridian = line(-180, [90, 0, -90]) >>> _ = plotter.add_mesh(meridian, color="orange", line_width=3) >>> plotter.view_yz(negative=True) >>> plotter.show() """ if surface is not None: radius = distance(surface) else: radius = RADIUS if radius is None else abs(float(radius)) zscale = ZLEVEL_SCALE if zscale is None else float(zscale) zlevel = 1 if zlevel is None else int(zlevel) radius += radius * zlevel * zscale if npts is None: npts = GEODESIC_NPTS if ellps is None: ellps = ELLIPSE if not isinstance(lons, Iterable): lons = [lons] if not isinstance(lats, Iterable): lats = [lats] lons, lats = np.asanyarray(lons), np.asanyarray(lats) # check whether to auto-repeat lons or lats if (lons.size == 1 or lats.size == 1) and (lons.size + lats.size > 2): if lons.size == 1: lons = np.repeat(lons[0], lats.size) else: lats = np.repeat(lats[0], lons.size) n_lons, n_lats = lons.size, lats.size if n_lons != n_lats: emsg = ( f"Require the same number of longitudes ({n_lons}) and " f"latitudes ({n_lats})." ) raise ValueError(emsg) if n_lons < 2: emsg = ( "Require a line geometry containing at least 2 longitude/latitude " f"values, got '{n_lons}'." ) raise ValueError(emsg) lons = wrap(lons) # check for minimal loop corner case if np.isclose(lons[0], lons[-1]) and np.isclose(lats[0], lats[-1]) and n_lons == 2: emsg = ( "Require a closed line (loop) geometry containing at least 3 " f"longitude/latitude values, got '{n_lons}'." ) raise ValueError(emsg) line_lons, line_lats = [], [] geod = pyproj.Geod(ellps=ellps) for idx in range(n_lons - 1): glons, glats = npoints_by_idx( lons, lats, idx, idx + 1, npts=npts, include_start=True, include_end=False, geod=geod, ) line_lons.extend(glons) line_lats.extend(glats) # finally, include the end-point line_lons.append(lons[-1]) line_lats.append(lats[-1]) xyz = to_cartesian(line_lons, line_lats, radius=radius) lines = pv.lines_from_points(xyz, close=close) lines.field_data[GV_FIELD_RADIUS] = np.array([radius]) to_wkt(lines, WGS84) return lines
[docs] def npoints( start_lon: float, start_lat: float, end_lon: float, end_lat: float, npts: int | None = GEODESIC_NPTS, radians: bool | None = False, include_start: bool | None = False, include_end: bool | None = False, geod: pyproj.Geod | None = None, ) -> tuple[tuple[float], tuple[float]]: """Calculate geodesic mid-points between provided start and end points. Given a single start-point and end-point, calculate the equally spaced intermediate longitude and latitude `npts` points along the geodesic line that spans between the start and end points. Note that, longitudes (degrees) will be wrapped to the half-closed interval ``[-180, 180)``. Parameters ---------- start_lon : float The longitude of the start-point for the geodesic line. start_lat : float The latitude of the start-point for the geodesic line. end_lon : float The longitude of the end-point for the geodesic line. end_lat : float The latitude of the end-point for the geodesic line. npts : int, optional The number of points to be returned, which may include the start-point and/or the end-point, if required. Defaults to :data:`GEODESIC_NPTS`. radians : bool, optional If ``True``, the start and end points are assumed to be in radians, otherwise degrees. Defaults to ``False``. include_start : bool, optional Whether to include the start-point in the geodesic points returned. Defaults to ``False``. include_end : bool, optional Whether to include the end-point in the geodesic points returned. Defaults to ``False``. geod : Geod, optional Definition of the ellipsoid for geodesic calculations. Defaults to :data:`ELLIPSE`. Returns ------- tuple Tuple of longitude points and latitude points along the geodesic line between the start-point and the end-point. Notes ----- .. versionadded:: 0.1.0 Examples -------- >>> from geovista.geodesic import npoints >>> import numpy as np >>> points = npoints(start_lon=-10, start_lat=20, end_lon=10, end_lat=30, npts=5) >>> np.array(points, dtype=np.float16) array([[-6.887, -3.69 , -0.41 , 2.963, 6.43 ], [21.84 , 23.62 , 25.34 , 26.98 , 28.53 ]], dtype=float16) """ if geod is None: geod = pyproj.Geod(ellps=ELLIPSE) initial_idx = 0 if include_start else 1 terminus_idx = 0 if include_end else 1 glonlats = geod.npts( start_lon, start_lat, end_lon, end_lat, npts, radians=radians, initial_idx=initial_idx, terminus_idx=terminus_idx, ) glons, glats = zip(*glonlats, strict=True) glons = tuple(wrap(glons)) return glons, glats
[docs] def npoints_by_idx( lons: ArrayLike, lats: ArrayLike, start_idx: int, end_idx: int, npts: int | None = GEODESIC_NPTS, radians: bool | None = False, include_start: bool | None = False, include_end: bool | None = False, geod: pyproj.Geod | None = None, ) -> tuple[tuple[float], tuple[float]]: """Calculate geodesic mid-points between provided start and end indices. Given a single start-point index and end-point index, calculate the equally spaced intermediate longitude and latitude `npts` points along the geodesic line that spans between the start and end points. Note that, longitudes (degrees) will be wrapped to the half-closed interval ``[-180, 180)``. Parameters ---------- lons : ArrayLike The longitudes to be sampled by the provided indices. lats : ArrayLike The latitudes to be sampled by the provided indices. start_idx : int The index of the start-point. end_idx : int The index of the end-point. npts : int, optional The number of points to be returned, which may include the start-point and/or the end-point, if required. Defaults to :data:`GEODESIC_NPTS`. radians : bool, optional If ``True``, the `lons` and `lats` are assumed to be in radians, otherwise degrees. Defaults to ``False``. include_start : bool, optional Whether to include the start-point in the geodesic points returned. Defaults to ``False``. include_end : bool, optional Whether to include the end-point in the geodesic points returned. Defaults to ``False``. geod : Geod, optional Definition of the ellipsoid for geodesic calculations. Defaults to :data:`ELLIPSE`. Returns ------- tuple Tuple of longitude points and latitude points along the geodesic line between the start-point and the end-point. Notes ----- .. versionadded:: 0.1.0 Examples -------- >>> from geovista.geodesic import npoints_by_idx >>> import numpy as np >>> points = npoints_by_idx( ... lons=[-10, 0, 10], lats=[20, 25, 30], start_idx=0, end_idx=1, npts=5 ... ) >>> np.array(points, dtype=np.float16) array([[-8.38 , -6.746, -5.09 , -3.414, -1.718], [20.88 , 21.73 , 22.58 , 23.4 , 24.22 ]], dtype=float16) """ if geod is None: geod = pyproj.Geod(ellps=ELLIPSE) start_lonlat = lons[start_idx], lats[start_idx] end_lonlat = lons[end_idx], lats[end_idx] return npoints( *start_lonlat, *end_lonlat, npts=npts, radians=radians, include_start=include_start, include_end=include_end, geod=geod, )
[docs] def panel( name: int | str, ellps: str | None = ELLIPSE, c: int | None = BBOX_C, triangulate: bool | None = False, ) -> BBox: """Create boundary-box for specific cubed-sphere panel. Parameters ---------- name : int or str The cubed-sphere index, see :data:`PANEL_NAME_BY_IDX`, or name, see :data:`PANEL_IDX_BY_NAME`, which specifies the panel bounding-box, see :data:`PANEL_BBOX_BY_IDX`. ellps : str, optional The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`. Defaults to :data:`ELLIPSE`. c : float, optional The bounding-box face geometry will contain ``c**2`` cells. Defaults to :data:`BBOX_C`. triangulate : bool, optional Specify whether the panel bounding-box faces are triangulated. Defaults to ``False``. Returns ------- BBox The bounding-box that encloses the required cubed-sphere panel. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add a ``wireframe`` bounding-box to the plotter for the ``americas`` panel of a cubed-sphere. The geodesic bounding-box is generated from the 4 corners of the cubed-sphere panel located over Americas. A texture mapped Natural Earth base layer is also rendered. >>> import geovista >>> from geovista.geodesic import panel >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer( ... texture=geovista.natural_earth_hypsometric(), opacity=0.5 ... ) >>> bbox = panel("americas", c=8) >>> _ = plotter.add_mesh(bbox.mesh, color="orange", style="wireframe") >>> plotter.view_xz() >>> plotter.show() """ if isinstance(name, str): if name.lower() not in PANEL_IDX_BY_NAME: ordered = sorted(PANEL_IDX_BY_NAME) valid = ", ".join(f"'{kind}'" for kind in ordered[:-1]) valid = f"{valid} or '{ordered[-1]}'" emsg = f"Panel name must be either {valid}, got '{name}'." raise ValueError(emsg) idx = PANEL_IDX_BY_NAME[name.lower()] else: idx = name if idx not in range(N_PANELS): emsg = ( f"Panel index must be in the closed interval " f"[0, {N_PANELS-1}], got '{idx}'." ) raise ValueError(emsg) lons, lats = PANEL_BBOX_BY_IDX[idx] return BBox(lons, lats, ellps=ellps, c=c, triangulate=triangulate)
[docs] def wedge( lon1: float, lon2: float, ellps: str | None = ELLIPSE, c: int | None = BBOX_C, triangulate: bool | None = False, ) -> BBox: """Create geodesic bounding-box manifold wedge from the north to the south pole. Parameters ---------- lon1 : float The first longitude (degrees) defining the geodesic wedge region. lon2 : float The second longitude (degrees) defining the geodesic wedge region. ellps : str, optional The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`. Defaults to :data:`ELLIPSE`. c : float, optional The bounding-box face geometry will contain ``c**2`` cells. Defaults to :data:`BBOX_C`. triangulate : bool, optional Specify whether the wedge bounding-box faces are triangulated. Defaults to ``False``. Returns ------- BBox The bounding-box that encloses the required geodesic wedge. Notes ----- .. versionadded:: 0.1.0 Examples -------- Add a ``C8`` sixty-degree wide ``wireframe`` bounding-box wedge to the plotter. A texture mapped NASA Blue Marble base layer is also rendered. >>> import geovista >>> from geovista.geodesic import wedge >>> plotter = geovista.GeoPlotter() >>> _ = plotter.add_base_layer(texture=geovista.blue_marble(), opacity=0.5) >>> bbox = wedge(-30, 30, c=8) >>> _ = plotter.add_mesh(bbox.mesh, color="orange", style="wireframe") >>> plotter.view_yz() >>> plotter.show() """ delta = abs(lon1 - lon2) if 0 < delta >= 180: emsg = ( "A geodesic wedge must have an absolute longitudinal difference " f"(degrees) in the open interval (0, 180), got '{delta}'." ) raise ValueError(emsg) lons = (lon1, lon2, lon2, lon1) lats = (90, 90, -90, -90) return BBox(lons, lats, ellps=ellps, c=c, triangulate=triangulate)