Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ Methods
Grid.copy
Grid.calculate_total_face_area
Grid.compute_face_areas
Grid.compute_face_node_angles
Grid.construct_face_centers
Grid.get_ball_tree
Grid.get_kd_tree
Expand Down
67 changes: 67 additions & 0 deletions test/grid/geometry/test_angles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Purpose: tests related to angle calculations on a grid
"""

import numpy as np
import xarray as xr

import uxarray as ux


def test_face_node_angles_triangle():
"""ensure Grid.compute_face_node_angles() works as expected for a simple ~30,60,90 triangle."""
# make a tiny triangle with known angles (90,60,30 degrees):
# (n1)
# | %%
# | %%
# (n0) ------ (n2)
node_lon = [0, 0, np.sqrt(3)]
node_lat = [0, 1, 0]
face_node_connectivity = [[0, 1, 2]]
grid = ux.Grid.from_topology(node_lon, node_lat, face_node_connectivity)
angles_rad = grid.compute_face_node_angles()
angles_deg = grid.compute_face_node_angles(degrees=True)
assert np.allclose(np.rad2deg(angles_rad), angles_deg)
angles_uxarr = grid.compute_face_node_angles(as_uxarray=True)
assert isinstance(angles_rad, xr.DataArray)
assert isinstance(angles_uxarr, ux.UxDataArray)
assert np.all(angles_rad == angles_uxarr)
angle_at_n0 = angles_deg.isel(n_face=0, n_max_face_nodes=0)
angle_at_n1 = angles_deg.isel(n_face=0, n_max_face_nodes=1)
angle_at_n2 = angles_deg.isel(n_face=0, n_max_face_nodes=2)
assert np.isclose(angle_at_n0, 90.0, atol=0, rtol=1e-16) # rad2deg(arctan2(any_value, 0)) == 90
assert np.isclose(angle_at_n1, 60.0, atol=1e-2, rtol=0) # basically 60 degrees
assert np.isclose(angle_at_n2, 30.0, atol=1e-2, rtol=0) # basically 30 degrees
# on a unit sphere, spherical excess == face area, via Girard's theorem.
spherical_excess = angles_rad.sum('n_max_face_nodes') - np.pi
face_areas = grid.compute_face_areas()
assert np.allclose(spherical_excess, face_areas, atol=0, rtol=1e-12)

def test_face_node_angles_hexagons_and_pentagons():
"""ensure face_node_angles works as expected on grids with hexagons and pentagons"""
grid = ux.tutorial.open_grid('quad-hexagon') # has multiple faces, all hexagons.
angles_deg = grid.compute_face_node_angles(degrees=True)
# every hexagon in this grid is close to regular (all 120 degree angles):
regular_hex_deviation = angles_deg - 120.0
assert np.max(np.abs(regular_hex_deviation)) < 4.0
# generalized spherical excess formula uses (n - 2) * np.pi; n==6 for all of these faces
angles = grid.compute_face_node_angles() # (need to use radians for this formula)
spherical_excess = angles.sum('n_max_face_nodes') - (6 - 2) * np.pi
face_areas = grid.compute_face_areas()
assert np.allclose(spherical_excess, face_areas, atol=0, rtol=1e-10)

# now test a grid which has pentagons too,
# to ensure the implementation works even when the number of nodes per face varies.
grid = ux.tutorial.open_grid('mpas-QU-480')
# not all close to regular so don't try to check that.
# ensure nan values wherever n_max_face_nodes dimension is larger than n_nodes_per_face
angles = grid.compute_face_node_angles()
assert not np.all(grid.n_nodes_per_face == grid.n_max_face_nodes)
should_have_nans = angles.where(grid.n_nodes_per_face < grid.n_max_face_nodes, drop=True)
assert should_have_nans.size > 0
should_be_nans = should_have_nans.isel(n_max_face_nodes = -1)
assert np.all(np.isnan(should_be_nans))
# generalized spherical excess formula uses (n_nodes_per_face - 2) * np.pi
spherical_excess = angles.sum('n_max_face_nodes') - (grid.n_nodes_per_face - 2) * np.pi
face_areas = grid.compute_face_areas()
assert np.allclose(spherical_excess, face_areas, atol=0, rtol=1e-9)
77 changes: 77 additions & 0 deletions uxarray/grid/angles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Purpose: angle calculations on a grid
"""

import numpy as np
from numba import njit, prange

from uxarray.grid.utils import _numba_norm3, _small_angle_of_2_vectors


@njit(cache=True, parallel=True)
def _compute_face_node_angles_convex(
node_x,
node_y,
node_z,
face_node_connectivity,
n_nodes_per_face,
):
"""
Calculate the angles at each node for each face, assuming convex faces
and a spherical geometry (these assumptions occur throughout uxarray).

Parameters
----------
node_x : np.ndarray with shape (n_nodes,)
X coordinates of the nodes.
node_y : np.ndarray with shape (n_nodes,)
Y coordinates of the nodes.
node_z : np.ndarray with shape (n_nodes,)
Z coordinates of the nodes.
face_node_connectivity : np.ndarray with shape (n_faces, n_max_face_nodes)
Connectivity array defining which nodes form each face.
n_nodes_per_face : np.ndarray with shape (n_faces,)
Number of nodes for each face.

Returns
-------
np.ndarray with shape (n_faces, n_max_face_nodes)
Angles at each node of each face.
INT_FILL_VALUE elements from face_node_connectivity correspond with np.nan in the result.
"""
n_faces, _n_max_face_nodes = face_node_connectivity.shape
result = np.full(face_node_connectivity.shape, np.nan, dtype=np.float64)
for i in prange(n_faces):
n_nodes = n_nodes_per_face[i]
for j in range(n_nodes):
ihere = face_node_connectivity[i, j]
iprev = face_node_connectivity[i, (j - 1) % n_nodes]
inext = face_node_connectivity[i, (j + 1) % n_nodes]
xhere = node_x[ihere]
yhere = node_y[ihere]
zhere = node_z[ihere]
v1 = (node_x[iprev] - xhere, node_y[iprev] - yhere, node_z[iprev] - zhere)
v2 = (node_x[inext] - xhere, node_y[inext] - yhere, node_z[inext] - zhere)
# Spherical geometry: project onto tangent plane at the current node
normal = (xhere, yhere, zhere)
normal_norm = _numba_norm3(normal) # |normal|
normal = (
normal[0] / normal_norm,
normal[1] / normal_norm,
normal[2] / normal_norm,
)
# v1 -= np.dot(v1, normal) * normal
v1_dot_normal = v1[0] * normal[0] + v1[1] * normal[1] + v1[2] * normal[2]
v2_dot_normal = v2[0] * normal[0] + v2[1] * normal[1] + v2[2] * normal[2]
v1 = (
v1[0] - v1_dot_normal * normal[0],
v1[1] - v1_dot_normal * normal[1],
v1[2] - v1_dot_normal * normal[2],
)
v2 = (
v2[0] - v2_dot_normal * normal[0],
v2[1] - v2_dot_normal * normal[1],
v2[2] - v2_dot_normal * normal[2],
)
result[i, j] = _small_angle_of_2_vectors(v1, v2)
return result
48 changes: 48 additions & 0 deletions uxarray/grid/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from uxarray.cross_sections import GridCrossSectionAccessor
from uxarray.errors import DataCenteringError, DimensionError, GridInvalidError
from uxarray.formatting_html import grid_repr
from uxarray.grid.angles import _compute_face_node_angles_convex
from uxarray.grid.area import _get_all_face_area_from_coords
from uxarray.grid.bounds import _populate_face_bounds
from uxarray.grid.connectivity import (
Expand Down Expand Up @@ -1972,6 +1973,53 @@ def copy(self):
source_dims_dict=self._source_dims_dict,
)

def compute_face_node_angles(
self,
*,
degrees: bool = False,
as_uxarray: bool = False,
) -> xr.DataArray | UxDataArray:
"""Compute the angles at each node of each face in the grid.
Assumes convex faces and a spherical geometry (consistent with other uxarray methods).

Parameters
----------
degrees : bool, defaults to False
Whether to return angles in degrees (if True) or radians (if False).
as_uxarray : bool, defaults to False
Whether to return a uxarray.DataArray (if True) instead of an xarray.DataArray (if False).
If True, equivalent to uxarray.DataArray(self.compute_face_node_angles(..., as_uxarray=False), uxgrid=self).

Returns
-------
face_node_angles : xr.DataArray or uxarray.UxDataArray (if as_uxarray=True)
The internal angles at each node, for each face in the grid.
Has 'n_face' and 'n_max_face_nodes' dimensions, with same size as in self.
For faces with fewer than n_max_face_nodes, fill value is np.nan.
"""
from uxarray.conventions.ugrid import FACE_DIM, N_MAX_FACE_NODES_DIM

result = _compute_face_node_angles_convex(
self.node_x.values,
self.node_y.values,
self.node_z.values,
self.face_node_connectivity.values,
self.n_nodes_per_face.values,
)
result = xr.DataArray(
data=result,
dims=[FACE_DIM, N_MAX_FACE_NODES_DIM],
name="face_node_angles",
attrs={"description": "Internal angles at each node of each face."},
)
if degrees:
result = np.rad2deg(result)
if as_uxarray:
from uxarray.core.dataarray import UxDataArray

result = UxDataArray(result, uxgrid=self)
return result

def calculate_total_face_area(
self,
quadrature_rule: str = "triangular",
Expand Down
53 changes: 46 additions & 7 deletions uxarray/grid/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,63 @@ def _small_angle_of_2_vectors(u, v):

Parameters
----------
u : numpy.ndarray
u : numpy.ndarray or iterable of length 3
The first 3D vector.
v : numpy.ndarray
v : numpy.ndarray or iterable of length 3
The second 3D vector.

Returns
-------
float
The smallest angle between `u` and `v` in radians.
"""
v_norm_times_u = np.linalg.norm(v) * u
u_norm_times_v = np.linalg.norm(u) * v
vec_minus = v_norm_times_u - u_norm_times_v
vec_sum = v_norm_times_u + u_norm_times_v
angle_u_v_rad = 2 * np.arctan2(np.linalg.norm(vec_minus), np.linalg.norm(vec_sum))
# don't convert to numpy array if not already numpy array.
# The formula is: angle = 2 * arctan2(| |v|*u - |u|*v |, | |v|*u + |u|*v |)
v_norm = _numba_norm3(v)
u_norm = _numba_norm3(u)
v_norm_times_u = (v_norm * u[0], v_norm * u[1], v_norm * u[2])
u_norm_times_v = (u_norm * v[0], u_norm * v[1], u_norm * v[2])
vec_minus = (
v_norm_times_u[0] - u_norm_times_v[0],
v_norm_times_u[1] - u_norm_times_v[1],
v_norm_times_u[2] - u_norm_times_v[2],
)
vec_sum = (
v_norm_times_u[0] + u_norm_times_v[0],
v_norm_times_u[1] + u_norm_times_v[1],
v_norm_times_u[2] + u_norm_times_v[2],
)
norm_vec_minus = _numba_norm3(vec_minus)
norm_vec_sum = _numba_norm3(vec_sum)
angle_u_v_rad = 2 * np.arctan2(norm_vec_minus, norm_vec_sum)
return angle_u_v_rad


# TODO: move _numba_norm3 to a higher-level utils file. For more details, see issue #1648.
Comment thread
Sevans711 marked this conversation as resolved.
@njit(cache=True)
def _numba_norm3(u):
"""
Compute the Euclidean norm of a 3D vector.
Implementation is currently equivalent to np.linalg.norm:
sqrt(u[0]**2 + u[1]**2 + u[2]**2)

Does NOT internally convert u to a list or numpy array;
utilizing tuples in numba instead of many tiny lists/arrays
can improve performance significantly.

Parameters
----------
u : iterable of length 3, possibly a numpy array
The 3D vector.

Returns
-------
float
The Euclidean norm of the vector `u`.
"""
return (u[0] ** 2 + u[1] ** 2 + u[2] ** 2) ** 0.5


@njit(cache=True)
def _angle_of_2_vectors(u, v):
"""
Expand Down