Source code for covseisnet.spatial

"""Module to deal with spatial and geographical data."""

import numpy as np
from obspy.core.trace import Stats
from obspy.geodetics.base import locations2degrees


[docs] class Regular3DGrid(np.ndarray): r"""Base class for regular three-dimensional grid of values such as velocity models, travel times, and differential travel times. The grid is a placeholder for any mathematic object :math:`f` that depends on three variables :math:`(\varphi, \lambda, z) \in \mathbb{R}^3` such as :math:`f(\varphi, \lambda, z)`. The grid is defined by its geographical extent :math:`(\varphi_0, \varphi_1, \lambda_0, \lambda_1, z_0, z_1)` and the number of points in each direction :math:`(n_{\varphi}, n_{\lambda}, n_{z})`. At any coordinate :math:`(\varphi, \lambda, z)` of the grid we can assign a value :math:`f(\varphi, \lambda, z)`. The assignment of the values is done by either filling the grid with a constant value, a random value, or a user-defined value. .. rubric:: Attributes - :attr:`lon` (:class:`numpy.ndarray`): The longitudes of the grid in decimal degrees. - :attr:`lat` (:class:`numpy.ndarray`): The latitudes of the grid in decimal degrees. - :attr:`depth` (:class:`numpy.ndarray`): The depths of the grid in kilometers. - :attr:`mesh` (:class:`list` of :class:`numpy.ndarray`): The meshgrid of the grid in the form ``(lon, lat, depth)``. .. rubric:: Methods - :meth:`flatten`: Flatten the grid to allow for faster calculations. Example ------- Create a 3D regular grid of values with random values, and plot it. .. plot:: import numpy as np import covseisnet as csn np.random.seed(42) grid = csn.spatial.Regular3DGrid( extent=(40, 41, 50, 51, 0, 20), shape=(10, 10, 10), fill_value="random", ) csn.plot.grid3d(grid, cmap="cividis") """ lon: np.ndarray lat: np.ndarray depth: np.ndarray mesh: list[np.ndarray] def __new__( cls, extent: tuple[float, float, float, float, float, float], shape: tuple[int, int, int], fill_value: float | str = np.nan, ): r""" Arguments --------- extent: tuple The geographical extent of the grid in the form ``(lon_min, lon_max, lat_min, lat_max, depth_min, depth_max)``. The longitudes and latitudes are in decimal degrees, and the depths in kilometers. shape: tuple The number of points in the grid in the form ``(n_lon, n_lat, n_depth)``. fill_value: float or str, optional The fill value of the grid. By default, it is set to :attr:`np.nan`. """ # Extent if isinstance(fill_value, float): obj = np.full(shape, fill_value).view(cls) elif fill_value == "random": obj = np.random.rand(*shape).view(cls) else: raise ValueError( f"Invalid fill value '{fill_value}'. " "The fill value must be a float or 'random'." ) # Grid mesh obj.lon = np.linspace(extent[0], extent[1], shape[0]) obj.lat = np.linspace(extent[2], extent[3], shape[1]) obj.depth = np.linspace(extent[4], extent[5], shape[2]) obj.mesh = np.meshgrid(obj.lon, obj.lat, obj.depth, indexing="ij") return obj def __array_finalize__(self, obj): if obj is None: return self.lon = getattr(obj, "lon", np.array([np.nan])) self.lat = getattr(obj, "lat", np.array([np.nan])) self.depth = getattr(obj, "depth", np.array([np.nan])) self.mesh = getattr(obj, "mesh", [np.array([np.nan])]) def __str__(self): ax_string = "\t{}: [{:.2f}, {:.2f}] with {} points\n" if (self.lon is None) or (self.lat is None) or (self.depth is None): raise ValueError("The grid axes are not defined.") return ( f"{self.__class__.__name__}(\n" + ax_string.format( "lon", self.lon.min(), self.lon.max(), len(self.lon) ) + ax_string.format( "lat", self.lat.min(), self.lat.max(), len(self.lat) ) + ax_string.format( "depth", self.depth.min(), self.depth.max(), len(self.depth) ) + f"\tmesh: {self.size:,} points\n" + f"\tnan values: {np.isnan(self).sum():,} points\n" + f"\tmin: {np.nanmin(self):.3f}\n" + f"\tmax: {np.nanmax(self):.3f}\n" + ")" ) @property def extent(self): r"""Geographical extent of the grid in the form ``(lon_min, lon_max, lat_min, lat_max, depth_min, depth_max)``.""" if (self.lon is None) or (self.lat is None) or (self.depth is None): raise ValueError("The grid axes are not defined.") return ( self.lon.min(), self.lon.max(), self.lat.min(), self.lat.max(), self.depth.min(), self.depth.max(), ) @property def resolution(self): r"""Resolution of the grid in the form ``(lon_res, lat_res, depth_res)``.""" if (self.lon is None) or (self.lat is None) or (self.depth is None): raise ValueError("The grid axes are not defined.") return ( np.abs(self.lon[1] - self.lon[0]), np.abs(self.lat[1] - self.lat[0]), np.abs(self.depth[1] - self.depth[0]), )
[docs] def flatten(self): r"""Flatten the grid. This method flattens the grid to allow for faster calculations. The flattened grid is a 2D array with the shape ``(n_points, 3)`` where the columns are the longitudes, latitudes, and depths of the grid. The method internally uses the :attr:`mesh` attribute to flatten the grid with the :attr:`numpy.ndarray.flat` attribute. """ if self.mesh is None: raise ValueError("The grid mesh is not defined.") return np.array( [ self.mesh[0].flat, self.mesh[1].flat, self.mesh[2].flat, ] ).T
[docs] def geographical_to_cartesian( lon: float, lat: float, depth: float, earth_radius: float = 6371 ) -> tuple: r""" Convert geographical to Cartesian coordinates. Given a point of longitude :math:`\varphi`, latitude :math:`\lambda`, and depth :math:`d`, the Cartesian coordinates :math:`(x, y, z)` are calculated as follows: .. math:: \begin{cases} x = (R - d) \cos(\lambda) \cos(\varphi) \\ y = (R - d) \cos(\lambda) \sin(\varphi) \\ z = (R - d) \sin(\lambda) \end{cases} where :math:`R` is the mean radius of the Earth. The depth is given in kilometers. And the latitude and longitude are given in radians (turned into radians by the function internally). Note that this transformation is only valid for small distances, and that no reference coordinate is provided. This is to calculate the relative distances between points in the same region. Arguments --------- lon, lat, depth : float Longitude, latitude, and depth of the point, with depth in kilometers, and latitude and longitude in degrees. earth_radius : float, optional Mean radius of the Earth in kilometers. Returns ------- x, y, z : float Cartesian coordinates of the point. """ # Convert latitude and longitude from degrees to radians lat_rad = np.radians(lat) lon_rad = np.radians(lon) # Calculate Cartesian coordinates x = (earth_radius - depth) * np.cos(lat_rad) * np.cos(lon_rad) y = (earth_radius - depth) * np.cos(lat_rad) * np.sin(lon_rad) z = (earth_radius - depth) * np.sin(lat_rad) return x, y, z
[docs] def straight_ray_distance( lon1: float, lat1: float, depth1: float, lon2: float, lat2: float, depth2: float, earth_radius: float = 6371, ): r""" Straight-ray distance between two geographical coordinates. The straight-ray distance between two points is calculated using the Euclidean distance between the Cartesian coordinates of the points. The Cartesian coordinates are calculated using the geographical coordinates and the mean radius of the Earth, with the :func:`geographical_to_cartesian` function. The units of the longitudes and latitudes are in degrees, and the depths in kilometers. Arguments --------- lon1, lat1, depth1 : float Longitude, latitude, and depth of the first point. lon2, lat2, depth2 : float Longitude, latitude, and depth of the second point. earth_radius : float, optional Mean radius of the Earth in kilometers. Returns ------- distance : float Straight-ray distance between the two points in kilometers. """ # Convert both points to Cartesian coordinates x1, y1, z1 = geographical_to_cartesian(lon1, lat1, depth1, earth_radius) x2, y2, z2 = geographical_to_cartesian(lon2, lat2, depth2, earth_radius) # Calculate the distance using the 3D distance formula distance = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2 + (z2 - z1) ** 2) return distance
[docs] def pairwise_great_circle_distances_from_stats( stats: list[Stats] | None = None, output_units: str = "km", include_diagonal: bool = True, ) -> list[float]: r"""Get the pairwise great-circle distances between stats coordinates. Calculate the pairwise great-circle distances between the coordinates of the different stations listed by a list of :class:`~obspy.core.trace.Stats` objects. The distances are calculated using the :func:`obspy.geodetics.base.locations2degrees`, if the stats contain a `coordinates` attribute. The output units can be in degrees or kilometers. Arguments --------- stats : list of :class:`~obspy.core.trace.Stats`, optional The list of stats objects containing the coordinates of the stations. output_units : str, optional The output units of the pairwise distances. The units can be 'degrees', 'kilometers', or 'miles'. include_diagonal : bool, optional Whether to include the diagonal of the pairwise distances. Returns ------- :class:`numpy.ndarray` The pairwise distances between the stations. """ # Check that stats is defined if stats is None: raise ValueError("The stats are not defined.") # Get the station coordinates coordinates = [stat.coordinates for stat in stats] # Calculate the pairwise distances of the upper triangular part distance_to_diagonal = 0 if include_diagonal else 1 n_stations = len(coordinates) pairwise_distances = [] for i in range(n_stations): for j in range(i + distance_to_diagonal, n_stations): degrees = locations2degrees( coordinates[i].latitude, coordinates[i].longitude, coordinates[j].latitude, coordinates[j].longitude, ) pairwise_distances.append(degrees) # Convert the pairwise distances to the output units match output_units: case "degrees": pairwise_distances = pairwise_distances case "kilometers" | "km": pairwise_distances = [ distance * 111.11 for distance in pairwise_distances ] case _: raise ValueError( f"Invalid output units '{output_units}'. " "The output units must be 'degrees', 'kilometers', or 'miles'." ) return pairwise_distances