from __future__ import annotations
import typing as t
import warnings
import attrs
import mitsuba as mi
import numpy as np
import pint
import pinttrs
import xarray as xr
from ._core import Surface
from ..bsdfs import BSDF, LambertianBSDF, OpacityMaskBSDF
from ..core import SceneElement
from ..geometry import PlaneParallelGeometry, SceneGeometry, SphericalShellGeometry
from ..shapes import (
BufferMeshShape,
FileMeshShape,
RectangleShape,
Shape,
SphereShape,
shape_factory,
)
from ...attrs import define, documented, get_doc
from ...constants import EARTH_RADIUS
from ...units import symbol, to_quantity
from ...units import unit_context_config as ucc
from ...units import unit_context_kernel as uck
from ...units import unit_registry as ureg
def _middle(a, axis=None):
a_min = a.min(axis=axis)
a_max = a.max(axis=axis)
return 0.5 * (a_min + a_max)
def _apply(transform: "mitsuba.ScalarTransform4f", vertices):
"""
Apply a Mitsuba transform to a Numpy buffer. This function works also with
scalar variants of Mitsuba.
"""
return (
transform.matrix.numpy()
@ np.concatenate((vertices, np.ones((vertices.shape[0], 1))), axis=1).T
).T[:, :3]
def _mercator(lon, lat, planet_radius):
"""
Convert longitude and latitude values to (x, y) coordinates using a
Mercator projection.
"""
# See math here: https://en.wikipedia.org/wiki/Mercator_projection
x = planet_radius * lon
y = planet_radius * np.log(np.tan(0.25 * np.pi + 0.5 * lat))
return x, y
def _mercator_inverse(x, y, planet_radius):
"""
Convert (x, y) coordinates to longitude and latitude using an inverse
Mercator projection.
"""
# See math here: https://en.wikipedia.org/wiki/Mercator_projection
lon = x / planet_radius
lat = 2.0 * np.arctan(np.exp(y / planet_radius)) - 0.5 * np.pi
return lon, lat
def _transform_vertices_spherical_shell_lonlat(vertices, planet_radius):
"""
Convert the (lon, lat, elevation) vertices from the initial vertex generation
into (x, y, z) values for spherical shell geometries.
Parameters
----------
vertices : ndarray
List of mesh vertices in (lon, lat, elevation) tuples, with lon and lat
in radians.
planet_radius : float
Planet radius in kernel length units.
Returns
-------
vertices : ndarray
"""
lon = vertices[:, 0]
lat = vertices[:, 1]
lon_center, lat_center = _middle(vertices[:, :2], axis=0)
elevation = vertices[:, 2]
phi_r = lon
theta_r = np.pi / 2.0 - lat
x = np.sin(theta_r) * np.cos(phi_r) * (elevation + planet_radius)
y = np.sin(theta_r) * np.sin(phi_r) * (elevation + planet_radius)
z = np.cos(theta_r) * (elevation + planet_radius)
vertices = np.array((x, y, z)).transpose()
# At this point, vertices are positioned in an ECEF frame; transform them to
# the local ENU frame
trafo = _transform_lonlat_range_to_local(lon_center, lat_center)
vertices = _apply(trafo, vertices)
return vertices
def _transform_lonlat_range_to_local(
lon_center, lat_center
) -> "mitsuba.ScalarTransform4f":
"""
Create a transformation matrix to position points placed in ECEF coordinates
from longitude / latitude information to the local frame in a spherical-shell
geometry.
"""
angle_1 = np.rad2deg(lon_center)
angle_2 = 90.0 - np.rad2deg(lat_center)
angle_3 = 90.0
return (
mi.ScalarTransform4f().rotate(axis=[0, 0, 1], angle=-angle_3)
[docs]
@ mi.ScalarTransform4f().rotate(axis=[0, 1, 0], angle=-angle_2)
@ mi.ScalarTransform4f().rotate(axis=[0, 0, 1], angle=-angle_1)
)
def triangulate_grid(
x: np.ndarray,
y: np.ndarray,
z: np.ndarray | None = None,
flip: bool = False,
divide: t.Literal["nesw", "nwse"] = "nesw",
):
"""
Create a 2D triangulation for a regular (x, y) grid.
Parameters
----------
x : ndarray
List of x grid values.
y : ndarray
List of y grid values.
z : ndarray, optional
If passed, a 3rd coordinate for vertices in gridded format (y-major).
flip : bool
If ``True``, flip triangle orientation (clockwise by default).
divide : {"nesw", "nwse"}, default: "nesw"
Cell division method.
Returns
-------
vertices : ndarray
Vertex list (y-major) as a (n, 2) array.
faces : ndarray
Face definitions as a (n, 3) array.
"""
x_grid, y_grid = np.meshgrid(x, y)
vertices = np.array((x_grid.ravel(), y_grid.ravel())).T
xi = np.arange(0, len(x), 1, dtype="int")
yi = np.arange(0, len(y), 1, dtype="int")
def _vertex_indices(xg, yg, xstride):
return xg + xstride * yg
xg, yg = np.meshgrid(xi[:-1], yi[:-1])
vertices_sw = _vertex_indices(xg.ravel(), yg.ravel(), xstride=len(x))
xg, yg = np.meshgrid(xi[1:], yi[:-1])
vertices_se = _vertex_indices(xg.ravel(), yg.ravel(), xstride=len(x))
xg, yg = np.meshgrid(xi[:-1], yi[1:])
vertices_nw = _vertex_indices(xg.ravel(), yg.ravel(), xstride=len(x))
xg, yg = np.meshgrid(xi[1:], yi[1:])
vertices_ne = _vertex_indices(xg.ravel(), yg.ravel(), xstride=len(x))
if divide == "nesw":
face_indices_1 = np.array((vertices_sw, vertices_se, vertices_ne)).T
face_indices_2 = np.array((vertices_sw, vertices_ne, vertices_nw)).T
elif divide == "nwse":
face_indices_1 = np.array((vertices_sw, vertices_nw, vertices_se)).T
face_indices_2 = np.array((vertices_nw, vertices_ne, vertices_se)).T
else:
raise ValueError(f"unknown cell division method '{divide}'")
faces = np.concatenate((face_indices_1, face_indices_2))
if flip:
faces = faces[:, [0, 2, 1]]
# If relevant, add elevation as 3rd vertex coordinate
if z is not None:
# IMPORTANT: meshgrid() produces an (N_y, N_x)-shaped array, while we
# expected the z array to be laid out oppositely; hence the transpose
# prior to flattening
vertices = np.concatenate((vertices, z.T.ravel().reshape(-1, 1)), axis=1)
return vertices, faces
def _dem_texcoords(xlon, ylat, xlon_lim, ylat_lim) -> np.ndarray:
"""
Map the x and y coordinates of the elements of `vertices` to the [0, 1] range
using a linear scale within xlim and ylim respectively.
Parameters
----------
xlon : array-like
An array of shape (N,) that contains the x/longitude coordinates of an
arbitrary number of vertices.
ylat : array-like
An array of shape (N,) that contains the y/latitude coordinates of an
arbitrary number of vertices.
xlon_lim : tuple[float, float]
Lower and upper bounds defining the linear transform that converts the x
coordinate to the u texture coordinate.
ylat_lim : tuple[float, float]
Lower and upper bounds defining the linear transform that converts the y
coordinate to the v texture coordinate.
"""
uvs_x = (xlon - xlon_lim[0]) / (xlon_lim[1] - xlon_lim[0])
uvs_y = (ylat - ylat_lim[0]) / (ylat_lim[1] - ylat_lim[0])
return np.stack([uvs_x, uvs_y]).T
[docs]
def mesh_from_dem(
da: xr.DataArray,
geometry: str | dict | SceneGeometry,
planet_radius: pint.Quantity | float | None = None,
add_texcoords: bool = False,
) -> tuple[BufferMeshShape, pint.Quantity, pint.Quantity]:
"""
Construct a DEM surface mesh from a data array holding elevation data.
This function has 4 modes of operations, depending on 2 parameters:
* the selected geometry type (plane-parallel or spherical-shell);
* the coordinates of the input dataset (longitude/latitude or x/y).
Alongside the generated mesh, this function returns extents that can be used
to position a background stencil around the produced shape (see the notes
for details).
Parameters
----------
da : DataArray
Data array with elevation data, indexed either by latitude and longitude
coordinates or x and y coordinates.
geometry : .SceneGeometry or dict or str
Scene geometry configuration. The value is pre-processed by the
:meth:`.SceneGeometry.convert` converter.
planet_radius : quantity or float, default: :data:`.EARTH_RADIUS`
Planet radius used to convert latitude/longitude to x/y when
``geometry`` is a :class:`.PlaneParallelGeometry` instance.
This parameter is unused otherwise. If a unitless value is passed, it is
interpreted using
:ref:`default config length units <sec-user_guide-unit_guide_user>`.
add_texcoords : bool, default: False
If ``True``, texture coordinates are added to the created mesh.
Returns
-------
mesh : .BufferMeshShape
A triangulated mesh representing the DEM.
xlon_lim : quantity
Limits of DEM on the x/longitude axis, in length (resp. angle) units for
plane-parallel (resp. spherical-shell) geometries.
ylat_lim : quantity
Limits of DEM on the y/latitude axis, in length (resp. angle) units for
plane-parallel (resp. spherical-shell) geometries.
Notes
-----
* The ``da`` parameter may use the following formats:
* with longitude / latitude coordinates, then named ``"lon"`` and
``"lat"`` respectively;
* with x / y coordinates, then named ``"x"`` and ``"y"`` respectively.
Coordinate and variable units are specified using the ``units`` xarray
attributes and are expected to be consistent with coordinate names.
* The mesh generation algorithm operates in four modes, two of which exist
for testing purposes and not recommended for quantitative applications:
* **Plane-parallel / xy mode:** The mesh is generated using vertices
positioned at grid points, then offset to center it at the origin
location of the local frame. The returned extent is in length units.
* **Spherical-shell / lonlat mode:** Coordinates are straightforwardly
converted to an ECEF frame. The resulting mesh is positioned where it
should fit on the reference geoid (currently a perfect sphere). The
returned extent is in longitude/latitude.
* **Plane-parallel / lonlat mode:** The input data is interpreted using a
Mercator projection, and generated vertices are offset to center the
mesh at the origin of the local frame. The returned extent is in length
units. *The projection is highly distorting and should not be used for
quantitative applications.*
* **Spherical-shell / xy mode:** Coordinates are converted to longitude
and latitude using an inverse Mercator projection, assuming that the
dataset is centred at the equator, then reinterpreted in an ECEF frame.
The returned extent is in longitude/latitude. *The projection is highly
distorting and should not be used for quantitative applications.*
* The generated mesh can optionally be assigned texture coordinates used to
map spatially-varying data (*e.g.* textured reflectance value for a
Lambertian BSDF).
"""
# Pre-process geometry parameter
geometry = SceneGeometry.convert(geometry)
if planet_radius is not None:
if isinstance(geometry, SphericalShellGeometry):
warnings.warn(
"The ``planet_radius`` argument is set to a user-defined "
"value but will be overridden by the value held by the "
"SphericalGeometry instance passed as the ``geometry`` "
"parameter."
)
# Add default units if quantity is unitless
planet_radius = pinttrs.util.ensure_units(
planet_radius, default_units=ucc.get("length")
)
# Set default planet radius value
planet_radius = EARTH_RADIUS if planet_radius is None else planet_radius
if isinstance(geometry, SphericalShellGeometry):
planet_radius = geometry.planet_radius
# Check data array coordinates and dimensions
length_kernel_u = uck.get("length")
if ("lon" in da.coords) and ("lat" in da.coords):
mode = "lonlat"
xlon_dim, ylat_dim = "lon", "lat"
elif ("x" in da.coords) and ("y" in da.coords):
mode = "xy"
xlon_dim, ylat_dim = "x", "y"
else:
raise ValueError(
"Data array coordinates must include either `x/y` or `lon/lat`.\n"
f"Got: {da.coords}"
)
# Convert to distances in plane-parallel geometry,
# and to angles in spherical-shell geometry.
xlon = to_quantity(da[xlon_dim])
ylat = to_quantity(da[ylat_dim])
xlon_center = _middle(xlon)
ylat_center = _middle(ylat)
# Extract elevation data and ensure y-major layout
elevation = to_quantity(da.transpose(xlon_dim, ylat_dim))
# By default, no texture coordinates are assigned
texcoords = None
# Process vertex data depending on geometry and coordinate mode, generate triangulation
if isinstance(geometry, PlaneParallelGeometry):
if mode == "xy":
xlon = xlon - xlon_center
ylat = ylat - ylat_center
vertices, faces = triangulate_grid(
xlon.m_as(length_kernel_u),
ylat.m_as(length_kernel_u),
elevation.m_as(length_kernel_u),
)
xlon_lim = (xlon.m.min(), xlon.m.max()) * xlon.u
ylat_lim = (ylat.m.min(), ylat.m.max()) * ylat.u
# If relevant, assign texture coordinates
if add_texcoords:
texcoords = _dem_texcoords(
vertices[:, 0], vertices[:, 1], xlon_lim.m, ylat_lim.m
)
elif mode == "lonlat":
x, y = _mercator(
xlon.m_as(ureg.rad),
ylat.m_as(ureg.rad),
planet_radius.m_as(length_kernel_u),
)
da = (
da.assign_coords(
{
"x": ("lon", x, {"units": symbol(length_kernel_u)}),
"y": ("lat", y, {"units": symbol(length_kernel_u)}),
}
)
.swap_dims({"lon": "x", "lat": "y"})
.drop_vars(("lon", "lat"))
)
return mesh_from_dem(
da, geometry, planet_radius, add_texcoords=add_texcoords
)
else:
raise RuntimeError(f"unknown input mode {mode}")
elif isinstance(geometry, SphericalShellGeometry):
if mode == "xy":
lon, lat = _mercator_inverse(
xlon.m_as(length_kernel_u),
ylat.m_as(length_kernel_u),
planet_radius.m_as(length_kernel_u),
)
da = (
da.assign_coords(
{
"lon": ("x", np.rad2deg(lon), {"units": "degree"}),
"lat": ("y", np.rad2deg(lat), {"units": "degree"}),
}
)
.swap_dims(({"x": "lon", "y": "lat"}))
.drop_vars(("x", "y"))
)
return mesh_from_dem(
da, geometry, planet_radius, add_texcoords=add_texcoords
)
elif mode == "lonlat":
xlon = xlon.to(ureg.rad)
ylat = ylat.to(ureg.rad)
vertices, faces = triangulate_grid(
xlon.m, ylat.m, elevation.m_as(length_kernel_u)
)
# If relevant, assign texture coordinates
xlon_lim = (xlon.m.min(), xlon.m.max()) * xlon.u
ylat_lim = (ylat.m.min(), ylat.m.max()) * ylat.u
if add_texcoords:
texcoords = _dem_texcoords(
vertices[:, 0], vertices[:, 1], xlon_lim.m, ylat_lim.m
)
# Rotate mesh to Eradiate's local frame (located at the North pole)
vertices = _transform_vertices_spherical_shell_lonlat(
vertices, planet_radius.m_as(length_kernel_u)
)
# Recompute limits (returned afterwards)
xlon_lim = (xlon.m.min(), xlon.m.max()) * xlon.u
ylat_lim = (ylat.m.min(), ylat.m.max()) * ylat.u
else:
raise RuntimeError(f"unknown input mode {mode}")
else: # For completeness
raise TypeError(f"unhandled geometry type '{type(PlaneParallelGeometry)}'")
# Create mesh instance
mesh = BufferMeshShape(vertices=vertices, faces=faces, texcoords=texcoords)
return mesh, xlon_lim, ylat_lim
[docs]
@define(eq=False, slots=False)
class DEMSurface(Surface):
"""
DEM Surface [``dem``]
A mesh-based representation of a Digital Elevation Model (DEM). This class
holds a mesh shape instance, as well as a background shape that provides a
surface beyond the extents of the mesh.
The intended instantiation method is to, first, create a mesh using the
:func:`.mesh_from_dem`, then use the data it returns to call the
:meth:`.from_mesh` constructor.
"""
id: str | None = documented(
attrs.field(
default="terrain",
validator=attrs.validators.optional(attrs.validators.instance_of(str)),
),
doc=get_doc(Surface, "id", "doc"),
type=get_doc(Surface, "id", "type"),
init_type=get_doc(Surface, "id", "init_type"),
default='"surface"',
)
shape: Shape = documented(
attrs.field(
converter=attrs.converters.optional(shape_factory.convert),
validator=attrs.validators.optional(
attrs.validators.instance_of((BufferMeshShape, FileMeshShape))
),
kw_only=True,
default=None,
),
doc="Shape describing the surface.",
type=".BufferMeshShape or .FileMeshShape or None",
init_type=".BufferMeshShape or .FileMeshShape or dict",
default="None",
)
shape_background: Shape = documented(
attrs.field(
converter=attrs.converters.optional(shape_factory.convert),
validator=attrs.validators.optional(
attrs.validators.instance_of((SphereShape, RectangleShape))
),
kw_only=True,
default=None,
),
doc="Shape describing the background surface.",
type=".SphereShape or .RectangleShape or None",
init_type=".SphereShape or .RectangleShape or dict, optional",
default="None",
)
@property
def _shape_id(self) -> str:
"""
Mitsuba shape object identifier.
"""
return f"{self.id}_shape"
@property
def _bsdf_id(self) -> str:
"""
Mitsuba BSDF object identifier.
"""
return f"{self.id}_bsdf"
@property
def _template_shapes(self) -> dict:
return {}
@property
def _params_shapes(self) -> dict:
return {}
@property
def _template_bsdfs(self) -> dict:
return {}
@property
def _params_bsdfs(self) -> dict:
return {}
@property
def objects(self) -> dict[str, SceneElement]:
# Inherit docstring
return {
self._shape_id: self.shape,
f"{self._shape_id}_background": self.shape_background,
}
[docs]
@classmethod
def from_mesh(
cls,
mesh: BufferMeshShape | FileMeshShape,
xlon_lim: pint.Quantity,
ylat_lim: pint.Quantity,
id: str = "surface",
geometry: SceneGeometry | str | dict = "plane_parallel",
planet_radius: pint.Quantity | None = None,
bsdf: BSDF | None = None,
bsdf_mesh: BSDF | None = None,
bsdf_background: BSDF | None = None,
) -> DEMSurface:
"""
Construct a :class:`.DEMSurface` instance from a mesh object and
coordinates which specify its location.
Parameters
----------
mesh : .BufferMeshShape or .FileMeshShape
DEM as a triangulated mesh. The BSDF of this shape will be
overridden by the ``bsdf_mesh`` parameter.
xlon_lim : quantity
Limits of the x/longitude range covered by the mesh.
ylat_lim : quantity
Limits of the y/latitude range covered by the mesh.
id : str, default: "surface"
Identifier of the scene element.
geometry : .SceneGeometry or str or dict, default: "plane_parallel"
Scene geometry. Strings and dictionaries are processed by the
:meth:`.SceneGeometry.convert` function.
planet_radius : quantity, default: .EARTH_RADIUS
Planet radius. Used only in case of a plane parallel geometry to
convert between latitude/longitude and x/y coordinates.
bsdf : .BSDF, default: :class:`LambertianBSDF() <.LambertianBSDF>`
Alias to ``bsdf_mesh`` (**deprecated**). If both are defined,
``bsdf_mesh`` takes precedence.
bsdf_mesh : .BSDF, default: :class:`LambertianBSDF() <.LambertianBSDF>`
Scattering model attached to the mesh.
bsdf_background : .BSDF, default: :class:`LambertianBSDF() <.LambertianBSDF>`
Scattering model attached to the background shape.
Returns
-------
.DEMSurface
"""
geometry = SceneGeometry.convert(geometry)
if bsdf_mesh is None:
bsdf_mesh = LambertianBSDF() if bsdf is None else bsdf
else:
if bsdf is not None:
warnings.warn(
"while calling DEMSurface.from_mesh(): "
"both bsdf and bsdf_mesh parameters were specified; "
"bsdf_mesh takes precedence"
)
bsdf_background = (
LambertianBSDF() if bsdf_background is None else bsdf_background
)
mesh = attrs.evolve(mesh, bsdf=bsdf_mesh)
if isinstance(geometry, PlaneParallelGeometry):
kernel_length_units = uck.get("length")
g_width = geometry.width.to(kernel_length_units)
g_altitude = geometry.ground_altitude.to(kernel_length_units)
x_range = (xlon_lim[1] - xlon_lim[0]).m_as(kernel_length_units)
x_scale = g_width.m / (x_range * 3.0)
y_range = (ylat_lim[1] - ylat_lim[0]).m_as(kernel_length_units)
y_scale = g_width.m / (y_range * 3.0)
opacity_array = np.array(
[
[1.0, 1.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
],
)
opacity_mask_trafo = mi.ScalarTransform4f().scale(
[x_scale, y_scale, 1.0]
) @ mi.ScalarTransform4f().translate(
[-0.5 + (0.5 / x_scale), -0.5 + (0.5 / y_scale), 0.0]
)
opacity_bsdf = OpacityMaskBSDF(
nested_bsdf=bsdf_background,
opacity_bitmap=opacity_array,
uv_trafo=opacity_mask_trafo,
)
surface_background = RectangleShape(
id=f"{id}_background",
edges=g_width,
center=[0.0, 0.0, g_altitude.m] * g_altitude.units,
normal=[0, 0, 1],
up=[0, 1, 0],
bsdf=opacity_bsdf,
)
elif isinstance(geometry, SphericalShellGeometry):
if planet_radius is not None:
warnings.warn(
"SphericalShellGeometry overrides the `planet_radius` argument."
)
def _to_uv(lon_lim, lat_lim) -> "mitsuba.ScalarTransform4f":
"""
Compute the `to_uv` transformation for the opacity mask bitmap.
It moves the central (transparent) part of the bitmap to where
it covers the specified longitude/latitude extent.
"""
lon_range = lon_lim[1] - lon_lim[0]
lon_scale = 120.0 / lon_range
lat_range = lat_lim[1] - lat_lim[0]
lat_scale = 60.0 / lat_range
lon_middle = _middle(lon_lim)
lon_uv = lon_middle / 360.0 + 0.5
lat_mean = (lat_lim[1] + lat_lim[0]) / 2.0
lat_uv = 0.5 - (lat_mean / 180)
return mi.ScalarTransform4f().scale(
(lon_scale, lat_scale, 1.0)
) @ mi.ScalarTransform4f().translate(
[-lon_uv + (0.5 / lon_scale), -lat_uv + (0.5 / lat_scale), 0.0]
)
opacity_mask_trafo = _to_uv(
xlon_lim.m_as(ureg.deg), ylat_lim.m_as(ureg.deg)
)
opacity_array = np.array(
[
[1.0, 1.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
],
)
opacity_bsdf = OpacityMaskBSDF(
nested_bsdf=bsdf_background,
opacity_bitmap=opacity_array,
uv_trafo=opacity_mask_trafo,
)
# The 'hole' in the background surface is created at the equator:
# rotate the shape to align it with the mesh
to_world = (
mi.ScalarTransform4f().rotate(axis=[0, 0, 1], angle=90)
@ mi.ScalarTransform4f().rotate(
axis=[0, 1, 0], angle=(90 - _middle(ylat_lim.m_as(ureg.deg)))
)
@ mi.ScalarTransform4f().rotate(
axis=[0, 0, 1], angle=-_middle(xlon_lim.m_as(ureg.deg))
)
)
surface_background = SphereShape(
id=f"{id}_shape_background",
center=[0.0, 0.0, 0.0] * geometry.planet_radius.units,
radius=geometry.planet_radius + geometry.ground_altitude,
bsdf=opacity_bsdf,
to_world=to_world,
)
else:
raise TypeError(f"Unhandled scene geometry type '{type(geometry)}'")
return cls(shape=mesh, shape_background=surface_background, id=id)