Source code for geovista.gridlines

# 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.

"""Provides graticule support and other geographical lines of interest for geolocation.

Notes
-----
.. versionadded:: 0.3.0

"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING

import lazy_loader as lazy

from .common import (
    BASE,
    CENTRAL_MERIDIAN,
    GV_REMESH_POINT_IDS,
    PERIOD,
    REMESH_SEAM,
    to_cartesian,
    wrap,
)
from .crs import WGS84, to_wkt

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

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

__all__ = [
    "GRATICULE_CLOSED_INTERVAL",
    "GRATICULE_ZLEVEL",
    "GraticuleGrid",
    "LABEL_DEGREE",
    "LABEL_EAST",
    "LABEL_NORTH",
    "LABEL_SOUTH",
    "LABEL_WEST",
    "LATITUDE_N_SAMPLES",
    "LATITUDE_POLES_LABEL",
    "LATITUDE_POLES_PARALLEL",
    "LATITUDE_START",
    "LATITUDE_STEP",
    "LATITUDE_STEP_PERIOD",
    "LATITUDE_STOP",
    "LONGITUDE_N_SAMPLES",
    "LONGITUDE_START",
    "LONGITUDE_STEP",
    "LONGITUDE_STEP_PERIOD",
    "LONGITUDE_STOP",
    "create_meridian_labels",
    "create_meridians",
    "create_parallel_labels",
    "create_parallels",
]

GRATICULE_CLOSED_INTERVAL: bool = False
"""Whether longitudes within half-closed [-180, 180) or closed [-180, 180] interval."""

GRATICULE_ZLEVEL: int = 1
"""The default zlevel for graticule meridians and parallels."""

LABEL_DEGREE: str = "°"
"""The degree symbol label."""

LABEL_EAST: str = f"{LABEL_DEGREE}E"
"""The east of the prime meridian label."""

LABEL_NORTH: str = f"{LABEL_DEGREE}N"
"""The north of the equatorial parallel label."""

LABEL_SOUTH: str = f"{LABEL_DEGREE}S"
"""The south of the equatorial parallel label."""

LABEL_WEST: str = f"{LABEL_DEGREE}W"
"""The west of the prime meridian label."""

LATITUDE_N_SAMPLES: int = 360
"""The default number of points in a line of latitude."""

LATITUDE_POLES_PARALLEL: bool = False
"""Whether to generate parallels at the north/south poles."""

LATITUDE_POLES_LABEL: bool = True
"""Whether to generate a north/south pole label."""

LATITUDE_START: float = -90.0
"""The first graticule line of latitude (degrees)."""

LATITUDE_STEP: float = 30.0
"""The default step size between graticule parallels (degrees)."""

LATITUDE_STEP_PERIOD: float = 90.0
"""The period or upper bound (degrees) for parallel step size."""

LATITUDE_STOP: float = 90.0
"""The last graticule line of latitude (degrees)."""

LONGITUDE_N_SAMPLES: int = 180
"""The default number of points in a meridian line."""

LONGITUDE_START: float = BASE
"""The first meridian line in the graticule (degrees)."""

LONGITUDE_STEP: float = 45.0
"""The default step size between graticule meridians (degrees)."""

LONGITUDE_STEP_PERIOD: float = 180.0
"""The period or upper bound (degrees) for meridian step size."""

LONGITUDE_STOP: float = BASE + PERIOD
"""The last graticule meridian (degrees)."""


[docs] @dataclass class GraticuleGrid: """Graticule composed of a block of meshes, labels and their points. Notes ----- .. versionadded:: 0.3.0 """ blocks: pv.MultiBlock lonlat: ArrayLike labels: list[str] mask: ArrayLike = None
def _step_period(lon: float, lat: float) -> tuple[float, float]: """Wrap graticule meridian/parallel step size (degrees) to sane upper bounds. Parameters ---------- lon : float The longitude step (degrees) between meridians. lat : float The latitude step (degrees) between parallels. Returns ------- tuple of float The lon/lat step values within the period. Notes ----- .. versionadded:: 0.3.0 """ lon_sign = 1 if lon >= 0 else -1 lat_sign = 1 if lat >= 0 else -1 lon = (lon % LONGITUDE_STEP_PERIOD) * lon_sign lat = (lat % LATITUDE_STEP_PERIOD) * lat_sign if np.isclose(lon, 0): lon = abs(lon) if np.isclose(lat, 0): lat = abs(lat) return (lon, lat)
[docs] def create_meridian_labels(lons: list[float]) -> list[str]: """Generate labels for the meridians. Parameters ---------- lons : list of float The meridian lines that require a label of their location east or west relative to the prime meridian. Returns ------- list of str The sequence of string labels for each meridian line. Notes ----- .. versionadded:: 0.3.0 """ result = [] if not isinstance(lons, Iterable): lons = [lons] for lon in lons: # TODO @bjlittle: Explicit truncation is performed, perhaps offer format # control when required. value = int(lon) direction = LABEL_EAST if value == 0 or np.isclose(np.abs(value), 180): direction = LABEL_DEGREE elif value < 0: direction = LABEL_WEST result.append(f"{np.abs(value)}{direction}") return result
[docs] def create_meridians( start: float | None = None, stop: float | None = None, step: float | None = None, lat_step: float | None = None, n_samples: int | None = None, closed_interval: bool | None = None, central_meridian: float | None = None, radius: float | None = None, zlevel: int | None = None, zscale: float | None = None, ) -> GraticuleGrid: """Generate graticule lines of constant longitude (meridians) with labels. Parameters ---------- start : float, optional The first line of longitude (degrees). The graticule will include this meridian. Defaults to :data:`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``. Defaults to :data:`LONGITUDE_STOP`. step : float, optional The delta (degrees) between neighbouring meridians. Defaults to :data:`LONGITUDE_STEP`. lat_step : float, optional The delta (degrees) between neighbouring parallels. Sets the frequency of the labels. Defaults to :data:`LATITUDE_STEP`. n_samples : int, optional The number of points in a single line of longitude. Defaults to :data:`LONGITUDE_N_SAMPLES`. closed_interval : bool, default=False Longitude values will be in the half-closed interval [-180, 180). Otherwise, longitudes will be in the closed interval [-180, 180]. Defaults to :data:`GRATICULE_CLOSED_INTERVAL`. central_meridian : float, optional The central meridian of the longitude range. Defaults to :data:`geovista.common.CENTRAL_MERIDIAN`. radius : float, optional The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`. 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 :data:`GRATICULE_ZLEVEL`. zscale : float, optional The proportional multiplier for z-axis `zlevel`. Defaults to :data:`geovista.common.ZLEVEL_SCALE`. Returns ------- GraticuleGrid The graticule meridians and points with labels on those meridians. Notes ----- .. versionadded:: 0.3.0 """ if start is None: start = LONGITUDE_START if stop is None: stop = LONGITUDE_STOP lon_step = LONGITUDE_STEP if step is None else step if lon_step <= 0: emsg = f"Require a non-zero positive value for 'step', got {str(lon_step)!r}." raise ValueError(emsg) if n_samples is None: n_samples = LONGITUDE_N_SAMPLES if lat_step is None: lat_step = LATITUDE_STEP if lat_step <= 0: emsg = ( f"Require a non-zero positive value for 'lat_step', got {str(lat_step)!r}." ) raise ValueError(emsg) if closed_interval is None: closed_interval = GRATICULE_CLOSED_INTERVAL if central_meridian is None: central_meridian = CENTRAL_MERIDIAN if zlevel is None: zlevel = GRATICULE_ZLEVEL # period sanity for step sizes lon_step, lat_step = _step_period(lon_step, lat_step) lons = np.arange(start, stop + lon_step, lon_step, dtype=float) if closed_interval: boundary = wrap(central_meridian - BASE) lons = wrap(lons + central_meridian) mask = np.isclose(lons, boundary) idxs = np.where(mask)[0] if idxs.size: mask[idxs[0]] = False else: mask = None lons = np.unique(wrap(lons)) lats = np.linspace(LATITUDE_START, LATITUDE_STOP, num=n_samples) blocks = pv.MultiBlock() for index, lon in enumerate(lons): xyz = to_cartesian( np.ones_like(lats) * lon, lats, radius=radius, zlevel=zlevel, zscale=zscale, ) # a meridian with N points has N-1 lines n_points = xyz.shape[0] - 1 lines = np.full((n_points, 3), 2, dtype=int) lines[:, 1] = np.arange(n_points) lines[:, 2] = np.arange(n_points) + 1 mesh = pv.PolyData(xyz, lines=lines) to_wkt(mesh, WGS84) if closed_interval and mask[index]: # mark this meridian as the closed interval wrap seam = np.empty(mesh.n_points, dtype=int) seam.fill(REMESH_SEAM) mesh.point_data[GV_REMESH_POINT_IDS] = seam mesh.set_active_scalars(name=None) blocks[f"{index},{lon}"] = mesh grid_points, grid_labels = [], [] labels = create_meridian_labels(list(lons)) grid_lats = np.arange(LATITUDE_START, LATITUDE_STOP, lat_step, dtype=float) + ( lat_step / 2 ) for lat in grid_lats: lonlat = np.vstack([lons, np.ones_like(lons, dtype=float) * lat]).T grid_points.append(lonlat) grid_labels.extend(labels) grid_points = np.vstack(grid_points) if mask is not None: mask = np.tile(mask, grid_lats.size) return GraticuleGrid( blocks=blocks, lonlat=grid_points, labels=grid_labels, mask=mask )
[docs] def create_parallel_labels( lats: list[float], poles_parallel: bool | None = None ) -> list[str]: """Generate labels for the parallels. Parameters ---------- lats : list of float The lines of latitude that require a label of their location north or south relative to the equator. poles_parallel : bool, optional Whether to generate a label for the north/south poles. Defaults to :data:`LATITUDE_POLES_PARALLEL`. Returns ------- list of str The sequence of string labels for each line of latitude. Notes ----- .. versionadded:: 0.3.0 """ result = [] if poles_parallel is None: poles_parallel = LATITUDE_POLES_PARALLEL if not isinstance(lats, Iterable): lats = [lats] for lat in lats: # explicit truncation, perhaps offer format control when required value = int(lat) direction = LABEL_NORTH if value == 0: direction = LABEL_DEGREE elif value < 0: direction = LABEL_SOUTH if not poles_parallel and np.isclose(np.abs(value), 90): continue result.append(f"{np.abs(value)}{direction}") return result
[docs] def create_parallels( start: float | None = None, stop: float | None = None, step: float | None = None, lon_step: float | None = None, n_samples: int | None = None, poles_parallel: bool | None = None, poles_label: bool | None = None, radius: float | None = None, zlevel: int | None = None, zscale: float | None = None, ) -> GraticuleGrid: """Generate graticule lines of constant latitude (parallels) with labels. Parameters ---------- start : float, optional The first line of latitude (degrees). The graticule will include this parallel. Also see ``poles_parallel``. Defaults to :data:`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``. Defaults to :data:`LATITUDE_STOP`. step : float, optional The delta (degrees) between neighbouring parallels. Defaults to :data:`LATITUDE_STEP`. lon_step : float, optional The delta (degrees) between neighbouring meridians. Sets the frequency of the labels. Defaults to :data:`LONGITUDE_STEP`. n_samples : int, optional The number of points in a single line of latitude. Defaults to :data:`LATITUDE_N_SAMPLES`. poles_parallel : bool, optional Whether to create a line of latitude at the north/south poles. Also see ``poles_label``. Defaults to :data:`LATITUDE_POLES_PARALLEL`. poles_label : bool, optional Whether to create a single north/south pole label. Only applies when ``poles_parallel=False``. Defaults to :data:`LATITUDE_POLES_LABEL`. radius : float, optional The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`. 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 :data:`GRATICULE_ZLEVEL`. zscale : float, optional The proportional multiplier for z-axis `zlevel`. Defaults to :data:`geovista.common.ZLEVEL_SCALE`. Returns ------- GraticuleGrid The graticule parallels and points with labels on the parallels. Notes ----- .. versionadded:: 0.3.0 """ if start is None: start = LATITUDE_START if stop is None: stop = LATITUDE_STOP lat_step = LATITUDE_STEP if step is None else step if lat_step <= 0: emsg = f"Require a non-zero positive value for 'step', got {str(lat_step)!r}." raise ValueError(emsg) if lon_step is None: lon_step = LONGITUDE_STEP if lon_step <= 0: emsg = ( f"Require a non-zero positive value for 'lon_step', got {str(lon_step)!r}." ) raise ValueError(emsg) if n_samples is None: n_samples = LATITUDE_N_SAMPLES if poles_parallel is None: poles_parallel = LATITUDE_POLES_PARALLEL if poles_label is None: poles_label = LATITUDE_POLES_LABEL if zlevel is None: zlevel = GRATICULE_ZLEVEL # period sanity for step sizes lon_step, lat_step = _step_period(lon_step, lat_step) lats = np.arange(start, stop + lat_step, lat_step, dtype=float) lons = wrap(np.linspace(LONGITUDE_START, LONGITUDE_STOP, num=n_samples + 1)[:-1]) grid_lats = [] blocks = pv.MultiBlock() for index, lat in enumerate(lats): if not poles_parallel and np.isclose(np.abs(lat), 90.0): continue xyz = to_cartesian( lons, np.ones_like(lons) * lat, radius=radius, zlevel=zlevel, zscale=zscale, ) # a parallel is a closed loop with N points and N lines n_points = xyz.shape[0] lines = np.full((n_points, 3), 2, dtype=int) lines[:, 1] = np.arange(n_points) lines[:, 2] = np.arange(n_points) + 1 lines[-1, 2] = 0 mesh = pv.PolyData(xyz, lines=lines) to_wkt(mesh, WGS84) blocks[f"{index},{lat}"] = mesh grid_lats.append(lat) grid_points, grid_labels = [], [] labels = create_parallel_labels(grid_lats, poles_parallel=poles_parallel) grid_lons = wrap( np.arange(LONGITUDE_START, LONGITUDE_STOP, lon_step, dtype=float) + (lon_step / 2) ) for lon in grid_lons: lonlat = np.vstack([np.ones_like(grid_lats, dtype=float) * lon, grid_lats]).T grid_points.append(lonlat) grid_labels.extend(labels) if not poles_parallel and poles_label: lonlat = [] if 90 in lats: lonlat.append([0, 90]) if -90 in lats: lonlat.append([0, -90]) if lonlat: lonlat = np.array(lonlat, dtype=float) grid_points.append(lonlat) pole_labels = create_parallel_labels( list(lonlat[:, 1]), poles_parallel=True ) grid_labels.extend(pole_labels) grid_points = np.vstack(grid_points) return GraticuleGrid(blocks=blocks, lonlat=grid_points, labels=grid_labels)