# Copyright (c) 2018 The Harmonica Developers.
# Distributed under the terms of the BSD 3-Clause License.
# SPDX-License-Identifier: BSD-3-Clause
#
# This code is part of the Fatiando a Terra project (https://www.fatiando.org)
#
"""
Define a layer of prisms
"""
import warnings
import numpy as np
import verde as vd
import xarray as xr
from ..visualization import prism_to_pyvista
from .prism_gravity import prism_gravity
[docs]
def prism_layer(
coordinates,
surface,
reference,
properties=None,
):
"""
Create a layer of prisms of equal size
Build a regular grid of prisms of equal size on the horizontal directions
with variable top and bottom boundaries and properties like density,
magnetization, etc. The function returns a :class:`xarray.Dataset`
containing ``easting``, ``northing``, ``top`` and ``bottom`` coordinates,
and all physical properties as ``data_var`` s. The ``easting`` and
``northing`` coordinates correspond to the location of the center of each
prism.
The ``prism_layer`` dataset accessor can be used to access special methods
and attributes for the layer of prisms, like the horizontal dimensions of
the prisms, getting the boundaries of each prisms, etc.
See :class:`DatasetAccessorPrismLayer` for the definition of these methods
and attributes.
Parameters
----------
coordinates : tuple
List containing the coordinates of the centers of the prisms in the
following order: ``easting``, ``northing``. The arrays must be 1d
arrays containing the coordinates of the centers per axis, or could be
2d arrays as the ones returned by :func:`numpy.meshgrid`. All
coordinates should be in meters and should define a regular grid.
surface : 2d-array
Array used to create the uppermost boundary of the prisms layer. All
heights should be in meters. On every point where ``surface`` is below
``reference``, the ``surface`` value will be used to set the
``bottom`` boundary of that prism, while the ``reference`` value will
be used to set the ``top`` boundary of the prism.
reference : float or 2d-array
Reference surface used to create the lowermost boundary of the prisms
layer. It can be either a plane or an irregular surface passed as 2d
array. Height(s) must be in meters.
properties : dict or None
Dictionary containing the physical properties of the prisms. The keys
must be strings that will be used to name the corresponding ``data_var``
inside the :class:`xarray.Dataset`, while the values must be 2d-arrays.
All physical properties must be passed in SI units. If None, no
``data_var`` will be added to the :class:`xarray.Dataset`. Default is
None.
Returns
-------
dataset : :class:`xarray.Dataset`
Dataset containing the coordinates of the center of each prism, the
height of its top and bottom boundaries and its corresponding physical
properties.
See also
--------
harmonica.DatasetAccessorPrismLayer
Examples
--------
>>> # Create a synthetic relief
>>> import numpy as np
>>> easting = np.linspace(0, 10, 5)
>>> northing = np.linspace(2, 8, 4)
>>> surface = np.arange(20, dtype=float).reshape((4, 5))
>>> density = 2670.0 * np.ones_like(surface)
>>> # Define a layer of prisms
>>> prisms = prism_layer(
... (easting, northing),
... surface,
... reference=0,
... properties={"density": density},
... )
>>> print(prisms) # doctest: +SKIP
<xarray.Dataset>
Dimensions: (northing: 4, easting: 5)
Coordinates:
* easting (easting) float64 0.0 2.5 5.0 7.5 10.0
* northing (northing) float64 2.0 4.0 6.0 8.0
top (northing, easting) float64 0.0 1.0 2.0 3.0 ... 17.0 18.0 19.0
bottom (northing, easting) float64 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0
Data variables:
density (northing, easting) float64 2.67e+03 2.67e+03 ... 2.67e+03
Attributes:
coords_units: meters
properties_units: SI
>>> # Get the boundaries of the layer (will exceed the region)
>>> boundaries = prisms.prism_layer.boundaries
>>> list(float(b) for b in boundaries)
[-1.25, 11.25, 1.0, 9.0]
>>> # Get the boundaries of one of the prisms
>>> prism = prisms.prism_layer.get_prism((0, 2))
>>> list(float(b) for b in prism)
[3.75, 6.25, 1.0, 3.0, 0.0, 2.0]
""" # noqa: W505
dims = ("northing", "easting")
# Initialize data and data_names as None
data, data_names = None, None
# If properties were passed, then replace data_names and data for its keys
# and values, respectively
if properties:
data_names = tuple(p for p in properties.keys())
data = tuple(np.asarray(p) for p in properties.values())
# Create xr.Dataset for prisms
prisms = vd.make_xarray_grid(
coordinates, data=data, data_names=data_names, dims=dims
)
_check_regular_grid(prisms.easting.values, prisms.northing.values)
# Append some attributes to the xr.Dataset
attrs = {"coords_units": "meters", "properties_units": "SI"}
prisms.attrs = attrs
# Create the top and bottom coordinates of the prisms
prisms.prism_layer.update_top_bottom(surface, reference)
return prisms
def _check_regular_grid(easting, northing):
"""
Check if the easting and northing coordinates define a regular grid
.. note:
This function should live inside Verde in the future
"""
if not np.allclose(easting[1] - easting[0], easting[1:] - easting[:-1]):
raise ValueError("Passed easting coordinates are not evenly spaced.")
if not np.allclose(northing[1] - northing[0], northing[1:] - northing[:-1]):
raise ValueError("Passed northing coordinates are not evenly spaced.")
[docs]
@xr.register_dataset_accessor("prism_layer")
class DatasetAccessorPrismLayer:
"""
Defines dataset accessor for layer of prisms
.. warning::
This class is not intended to be initialized.
Use the `prism_layer` accessor for accessing the methods and
attributes of this class.
See also
--------
harmonica.prism_layer
"""
def __init__(self, xarray_obj):
self._obj = xarray_obj
@property
def dims(self):
"""
Return the dims tuple of the prism layer
The tuple follows the xarray order: ``"northing"``, ``"easting"``.
"""
return ("northing", "easting")
@property
def spacing(self):
"""
Spacing between center of prisms
Returns
-------
s_north : float
Spacing between center of prisms on the South-North direction.
s_east : float
Spacing between center of prisms on the West-East direction.
"""
easting, northing = self._obj.easting.values, self._obj.northing.values
_check_regular_grid(easting, northing)
s_north, s_east = northing[1] - northing[0], easting[1] - easting[0]
return s_north, s_east
@property
def boundaries(self):
"""
Boundaries of the layer
Returns
-------
boundaries : tuple
Boundaries of the layer of prisms in the following order: ``west``,
``east``, ``south``, ``north``.
"""
s_north, s_east = self.spacing
west = self._obj.easting.values.min() - s_east / 2
east = self._obj.easting.values.max() + s_east / 2
south = self._obj.northing.values.min() - s_north / 2
north = self._obj.northing.values.max() + s_north / 2
return west, east, south, north
@property
def size(self):
"""
Return the total number of prisms on the layer
Returns
-------
size : int
Total number of prisms in the layer.
"""
return self._obj.northing.size * self._obj.easting.size
@property
def shape(self):
"""
Return the number of prisms on each direction
Returns
-------
n_north : int
Number of prisms on the South-North direction.
n_east : int
Number of prisms on the West-East direction.
"""
return (self._obj.northing.size, self._obj.easting.size)
def _get_prism_horizontal_boundaries(self, easting, northing):
"""
Compute the horizontal boundaries of the prism
Parameters
----------
easting : float or array
Easting coordinate of the center of the prism
northing : float or array
Northing coordinate of the center of the prism
"""
spacing = self.spacing
west = easting - spacing[1] / 2
east = easting + spacing[1] / 2
south = northing - spacing[0] / 2
north = northing + spacing[0] / 2
return west, east, south, north
[docs]
def update_top_bottom(self, surface, reference):
"""
Update top and bottom boundaries of the layer
Change the values of the ``top`` and ``bottom`` coordinates based on
the passed ``surface`` and ``reference``. The ``top`` and ``bottom``
boundaries of every
prism will be equal to the corresponding ``surface`` and ``reference``
values, respectively, if ``surface`` is above the ``reference`` on that
point. Otherwise the ``top`` and ``bottom`` boundaries of the prism
will be equal to its corresponding ``reference`` and ``surface``,
respectively.
Parameters
----------
surface : 2d-array
Array used to create the uppermost boundary of the prisms layer.
All heights should be in meters. On every point where ``surface``
is below ``reference``, the ``surface`` value will be used to set
the ``bottom`` boundary of that prism, while the ``reference``
value will be used to set the ``top`` boundary of the prism.
reference : float or 2d-array
Reference surface used to create the lowermost boundary of the
prisms layer. It can be either a plane or an irregular surface
passed as 2d array. Height(s) must be in meters.
"""
surface, reference = np.asarray(surface), np.asarray(reference)
if surface.shape != self.shape:
raise ValueError(
f"Invalid surface array with shape '{surface.shape}'. "
+ "Its shape should be compatible with the coordinates "
+ "of the layer of prisms."
)
if reference.ndim != 0:
if reference.shape != self.shape:
raise ValueError(
f"Invalid reference array with shape '{reference.shape}'. "
+ "Its shape should be compatible with the coordinates "
+ "of the layer of prisms."
)
else:
reference = reference * np.ones(self.shape)
top = surface.copy()
bottom = reference.copy()
reverse = surface < reference
top[reverse] = reference[reverse]
bottom[reverse] = surface[reverse]
self._obj.coords["top"] = (self.dims, top)
self._obj.coords["bottom"] = (self.dims, bottom)
[docs]
def gravity(
self,
coordinates,
field,
progressbar=False,
density_name="density",
thickness_threshold=None,
**kwargs,
):
"""
Computes the gravity generated by the layer of prisms
Uses :func:`harmonica.prism_gravity` for computing the gravity field
generated by the prisms of the layer.
The density of the prisms will be assigned from the ``data_var`` chosen
through the ``density_name`` argument.
Ignores the prisms which ``top`` or ``bottom`` boundaries are
``np.nan``s.
Prisms thinner than a given threshold can be optionally ignored through
the ``thickness_threshold`` argument.
All ``kwargs`` will be passed to :func:`harmonica.prism_gravity`.
Parameters
----------
coordinates : list of arrays
List of arrays containing the ``easting``, ``northing`` and
``upward`` coordinates of the computation points defined on
a Cartesian coordinate system. All coordinates should be in meters.
field : str
Gravitational field that wants to be computed.
The available fields are:
- Gravitational potential: ``potential``
- Eastward acceleration: ``g_e``
- Northward acceleration: ``g_n``
- Downward acceleration: ``g_z``
- Diagonal tensor components: ``g_ee``, ``g_nn``, ``g_zz``
- Non-diagonal tensor components: ``g_en``, ``g_ez``, ``g_nz``
progressbar : bool (optional)
If True, a progress bar of the computation will be printed to
standard error (stderr). Requires :mod:`numba_progress` to be
installed. Default to ``False``.
density_name : str (optional)
Name of the property layer (or ``data_var`` of the
:class:`xarray.Dataset`) that will be used for the density of each
prism in the layer. Default to ``"density"``
thickness_threshold : float or None
Prisms thinner than this threshold will be ignored in the
forward gravity calculation. If None, every prism with non-zero
volume will be considered. Default to None.
Returns
-------
result : array
Gravitational potential is returned in :math:`\text{J}/\text{kg}`,
acceleration components in mGal, and tensor components in Eotvos.
See also
--------
harmonica.prism_gravity
"""
# Get boundaries and density of the prisms
boundaries = self._to_prisms()
density = self._obj[density_name].values
# Get the mask for selecting only the prisms whose top boundary, bottom
# boundary and density have no nans
mask = self._get_nonans_mask(property_name=density_name)
# Select only the boundaries and density elements for masked prisms
boundaries = boundaries[mask.ravel()]
density = density[mask]
# Discard thin prisms and their densities
if thickness_threshold is not None:
boundaries, density = _discard_thin_prisms(
boundaries,
density,
thickness_threshold,
)
# Return gravity field of prisms
return prism_gravity(
coordinates,
prisms=boundaries,
density=density,
field=field,
progressbar=progressbar,
**kwargs,
)
def _get_nonans_mask(self, property_name=None):
"""
Build a mask for prisms with no nans on top, bottom or a property
Parameters
----------
property_name : str (optional)
Name of the property layer (or ``data_var`` of the
:class:`xarray.Dataset`) that will be used for masking the prisms
in the layer.
Returns
-------
mask : 2d-array
Array of bools that can be used as a mask for selecting prisms with
no nans on top boundaries, bottom boundaries and the passed
property.
"""
# Mask the prisms that contains no nans on top and bottom boundaries
mask = np.logical_and(
np.logical_not(np.isnan(self._obj.top.values)),
np.logical_not(np.isnan(self._obj.bottom.values)),
)
# Mask the prisms that contains nans on the selected property
if property_name is not None:
mask_property = np.logical_not(np.isnan(self._obj[property_name].values))
# Warn if a nan is found within the masked property
if not mask_property[mask].all():
warnings.warn(
f"Found missing values in '{property_name}' property "
+ "of the prisms layer. The prisms with a nan as "
+ f"'{property_name}' will be ignored.",
stacklevel=1,
)
mask = np.logical_and(mask, mask_property)
return mask
def _to_prisms(self):
"""
Return the boundaries of each prism of the layer
Returns
-------
prisms : 2d-array
Array containing the boundaries of each prism of the layer.
Each row contains the boundaries of each prism in the following
order: ``west``, ``east``, ``south``, ``north``, ``bottom``,
``top``.
"""
easting, northing = np.meshgrid(
self._obj.easting.values, self._obj.northing.values
)
west, east, south, north = self._get_prism_horizontal_boundaries(
easting.ravel(), northing.ravel()
)
bottom = self._obj.bottom.values.ravel()
top = self._obj.top.values.ravel()
prisms = np.vstack((west, east, south, north, bottom, top)).T
return prisms
[docs]
def get_prism(self, indices):
"""
Return the boundaries of the chosen prism
Parameters
----------
indices : tuple
Indices of the desired prism of the layer in the following order:
``(index_northing, index_easting)``.
Returns
-------
prism : tuple
Boundaries of the prisms in the following order:
``west``, ``east``, ``south``, ``north``, ``bottom``, ``top``.
"""
# Get the center of the prism
center_easting = self._obj.easting.values[indices[1]]
center_northing = self._obj.northing.values[indices[0]]
# Calculate the boundaries of the prism
west, east, south, north = self._get_prism_horizontal_boundaries(
center_easting, center_northing
)
bottom = self._obj.bottom.values[indices]
top = self._obj.top.values[indices]
return west, east, south, north, bottom, top
[docs]
def to_pyvista(self, drop_null_prisms=True):
"""
Return a pyvista UnstructuredGrid to plot the PrismLayer
Parameters
----------
drop_null_prisms : bool (optional)
If True, prisms with zero volume or with any :class:`numpy.nan` as
their top or bottom boundaries won't be included in the
:class:`pyvista.UnstructuredGrid`.
If False, every prism in the layer will be included.
Default True.
Returns
-------
pv_grid : :class:`pyvista.UnstructuredGrid`
:class:`pyvista.UnstructuredGrid` containing each prism of the
layer as a hexahedron along with their properties.
"""
prisms = self._to_prisms()
null_prisms = np.zeros_like(prisms[:, 0], dtype=bool)
if drop_null_prisms:
bottom, top = prisms[:, -2], prisms[:, -1]
null_prisms = (top == bottom) | (np.isnan(top)) | (np.isnan(bottom))
prisms = prisms[np.logical_not(null_prisms)]
# Define properties
properties = None
if self._obj.data_vars:
properties = {
data_var: np.asarray(self._obj[data_var]).ravel()[
np.logical_not(null_prisms)
]
for data_var in self._obj.data_vars
}
return prism_to_pyvista(prisms, properties=properties)
def _discard_thin_prisms(
prisms,
density,
thickness_threshold,
):
"""
Discard prisms with a thickness below a threshold
Parameters
----------
prisms : 2d-array
Array containing the boundaries of the prisms in the following order:
``w``, ``e``, ``s``, ``n``, ``bottom``, ``top``.
The array must have the following shape: (``n_prisms``, 6), where
``n_prisms`` is the total number of prisms.
density : 1d-array
Array containing the density of each prism in kg/m^3. Must have the
same size as the number of prisms.
thickness_threshold : float
Prisms thinner than this threshold will be discarded.
Returns
-------
prisms : 2d-array
A copy of the ``prisms`` array that doesn't include the thin prisms.
density : 1d-array
A copy of the ``density`` array that doesn't include the density values
for thin prisms.
"""
bottom, top = prisms[:, -2], prisms[:, -1]
# Mark prisms with thickness < threshold as null prisms
thickness = top - bottom
null_prisms = thickness < thickness_threshold
# Keep only thick prisms and their densities
prisms = prisms[np.logical_not(null_prisms), :]
density = density[np.logical_not(null_prisms)]
return prisms, density