(x1, y1, z) are the Cartesian coordinates of the first node.
\n",
+ "\n",
+ "
(x2, y2, z) are the Cartesian coordinates of the second node (note that the z coordinate is the same for both nodes).
\n",
+ "\n",
+ "
\n",
+ "\n",
+ "### Assumptions:\n",
+ "- This formula assumes that the input coordinates \\((x_1, y_1)\\) and \\((x_2, y_2)\\) are normalized (i.e., they lie on the unit sphere).\n",
+ "\n",
+ "\n",
+ "For the same large triangle used in **Section 4**, we calculate the area correction term when an edge lies along the line of constant latitude.\n",
+ "\n",
+ "### The following code:\n",
+ "- Creates a spherical rectangle and calculates the exact area using the spherical excess formula.\n",
+ "- Plots the rectangle, it has two lines of constant lattitude.\n",
+ "- A mesh is formed with uxarray and area is calculated\n",
+ "- Area is again calculated with `correct_area` flag set to `True`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The area of the spherical rectangle is approximately 1.132592 steradians\n",
+ "The area of the spherical rectangle is approximately 45971520.05 square kilometers\n"
+ ]
+ }
+ ],
+ "source": [
+ "def theoretical_spherical_rectangle_area(lons, lats, radius=1):\n",
+ " \"\"\"\n",
+ " Calculate the area of a spherical rectangle on a sphere.\n",
+ "\n",
+ " Parameters:\n",
+ " lons (np.ndarray): Longitudes of the rectangle's corners (in degrees).\n",
+ " lats (np.ndarray): Latitudes of the rectangle's corners (in degrees).\n",
+ " radius (float): Radius of the sphere (default is unit sphere).\n",
+ "\n",
+ " Returns:\n",
+ " float: Area of the spherical rectangle in steradians (or square units if radius is provided).\n",
+ " \"\"\"\n",
+ " # Convert degrees to radians\n",
+ " lons_rad = np.radians(lons)\n",
+ " lats_rad = np.radians(lats)\n",
+ "\n",
+ " # Compute longitude and latitude differences\n",
+ " delta_lambda = abs(lons_rad[0] - lons_rad[2]) # Assuming rectangular shape\n",
+ " sin_phi_diff = abs(np.sin(lats_rad[0]) - np.sin(lats_rad[2]))\n",
+ "\n",
+ " # Calculate area\n",
+ " area = radius**2 * delta_lambda * sin_phi_diff\n",
+ " return area\n",
+ "\n",
+ "\n",
+ "# Define nodes\n",
+ "node_lon = np.array([90.0, 90.0, 10.0, 10.0]) # Longitudes in degrees\n",
+ "node_lat = np.array([80.0, 10.0, 10.0, 80.0]) # Latitudes in degrees\n",
+ "\n",
+ "# Calculate the area\n",
+ "area_steradians = theoretical_spherical_rectangle_area(node_lon, node_lat)\n",
+ "print(\n",
+ " f\"The area of the spherical rectangle is approximately {area_steradians:.6f} steradians\"\n",
+ ")\n",
+ "\n",
+ "# If Earth's radius is used (R ≈ 6371 km):\n",
+ "earth_radius_km = 6371\n",
+ "area_km2 = theoretical_spherical_rectangle_area(\n",
+ " node_lon, node_lat, radius=earth_radius_km\n",
+ ")\n",
+ "print(\n",
+ " f\"The area of the spherical rectangle is approximately {area_km2:.2f} square kilometers\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {},
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/vnd.holoviews_exec.v0+json": "",
+ "text/html": [
+ "
\n",
+ " \n",
+ "
\n",
+ ""
+ ],
+ "text/plain": [
+ ":Path [Longitude,Latitude]"
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {
+ "application/vnd.holoviews_exec.v0+json": {
+ "id": "6d081030-85b6-478a-b5f2-230275b30e6d"
+ }
+ },
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Define face-node connectivity and create/plot a uxarray object face\n",
+ "face_node_connectivity = np.array([[0, 1, 2, 3]])\n",
+ "\n",
+ "face = ux.Grid.from_topology(\n",
+ " node_lon=node_lon,\n",
+ " node_lat=node_lat,\n",
+ " face_node_connectivity=face_node_connectivity,\n",
+ " fill_value=-1,\n",
+ ")\n",
+ "face.plot(backend=\"bokeh\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "area_no_correction = face.compute_face_areas(quadrature_rule=\"gaussian\", order=8)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Check if edge passes through pole: False\n",
+ "6.030208312509488e-17 0.984807753012208 0.9698463103929541 0.17101007166283433 0.17364817766693033 correction: 0.04692118879641144\n",
+ "\n",
+ "\n",
+ "calculated correction: 0.04692118879641144\n",
+ "Edge is in the Northern hemisphere.\n",
+ "For Node 1 [6.03020831e-17 9.84807753e-01 1.73648178e-01] \n",
+ " and Node 2 [0.96984631 0.17101007 0.17364818] \n",
+ "CORRECTION 0.04692118879641144\n",
+ "Check if edge passes through pole: False\n",
+ "0.1710100716628344 0.030153689607045817 1.0632884247878861e-17 0.17364817766693041 0.984807753012208 correction: 0.006156712978357515\n",
+ "\n",
+ "\n",
+ "calculated correction: 0.006156712978357515\n",
+ "Edge is in the Northern hemisphere.\n",
+ "For Node 1 [0.17101007 0.03015369 0.98480775] \n",
+ " and Node 2 [1.06328842e-17 1.73648178e-01 9.84807753e-01] \n",
+ "CORRECTION -0.006156712978357515\n",
+ "AREA Before Correction 1.0918281804990866\n",
+ "AREA After Correction 1.1325926563171405\n"
+ ]
+ }
+ ],
+ "source": [
+ "corrected_area = face.compute_face_areas(\n",
+ " quadrature_rule=\"gaussian\", order=8, correct_area=True\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The percentage difference between the corrected and uncorrected areas is 3.73%\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Calculate the percentage difference between the corrected and uncorrected areas\n",
+ "percentage_difference = (\n",
+ " (corrected_area[0][0] - area_no_correction[0][0]) / area_no_correction[0][0] * 100\n",
+ ")\n",
+ "\n",
+ "print(\n",
+ " f\"The percentage difference between the corrected and uncorrected areas is {percentage_difference:.2f}%\"\n",
+ ")"
+ ]
}
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3 (ipykernel)",
+ "display_name": "uxarray_env3.12",
"language": "python",
"name": "python3"
},
@@ -388,7 +2566,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.9"
+ "version": "3.12.7"
}
},
"nbformat": 4,
diff --git a/test/constants.py b/test/constants.py
index 4a54e1b1a..eeb23ecf3 100644
--- a/test/constants.py
+++ b/test/constants.py
@@ -7,6 +7,7 @@
NNODES_outRLL1deg = 64442
DATAVARS_outCSne30 = 4
TRI_AREA = 1.047
+CORRECTED_TRI_AREA = 1.3281
# 4*Pi is 12.56
MESH30_AREA = 12.566
PSI_INTG = 12.566
diff --git a/test/test_grid.py b/test/test_grid.py
index 4c8d98c76..8300b7590 100644
--- a/test/test_grid.py
+++ b/test/test_grid.py
@@ -266,15 +266,16 @@ def test_face_areas_calculate_total_face_area_triangle():
# validate the grid
assert grid_verts.validate()
- # calculate area
- area_gaussian = grid_verts.calculate_total_face_area(
- quadrature_rule="gaussian", order=5)
- nt.assert_almost_equal(area_gaussian, constants.TRI_AREA, decimal=3)
-
+ # calculate area without correction
area_triangular = grid_verts.calculate_total_face_area(
quadrature_rule="triangular", order=4)
nt.assert_almost_equal(area_triangular, constants.TRI_AREA, decimal=1)
+ # calculate area
+ area_gaussian = grid_verts.calculate_total_face_area(
+ quadrature_rule="gaussian", order=5, correct_area=True)
+ nt.assert_almost_equal(area_gaussian, constants.CORRECTED_TRI_AREA, decimal=3)
+
def test_face_areas_calculate_total_face_area_file():
"""Create a uxarray grid from vertices and saves an exodus file."""
area = ux.open_grid(gridfile_CSne30).calculate_total_face_area()
diff --git a/uxarray/grid/area.py b/uxarray/grid/area.py
index b785f87f8..ef7073868 100644
--- a/uxarray/grid/area.py
+++ b/uxarray/grid/area.py
@@ -7,7 +7,13 @@
@njit(cache=True)
def calculate_face_area(
- x, y, z, quadrature_rule="gaussian", order=4, coords_type="spherical"
+ x,
+ y,
+ z,
+ quadrature_rule="gaussian",
+ order=4,
+ coords_type="spherical",
+ correct_area=False,
):
"""Calculate area of a face on sphere.
@@ -56,23 +62,30 @@ def calculate_face_area(
# num triangles is two less than the total number of nodes
num_triangles = num_nodes - 2
+ if coords_type == "spherical":
+ # Preallocate arrays for Cartesian coordinates
+ n_points = len(x)
+ x_cartesian = np.empty(n_points)
+ y_cartesian = np.empty(n_points)
+ z_cartesian = np.empty(n_points)
+
+ # Convert all points to Cartesian coordinates using an explicit loop
+ for i in range(n_points):
+ lon_rad = np.deg2rad(x[i])
+ lat_rad = np.deg2rad(y[i])
+ cartesian = _lonlat_rad_to_xyz(lon_rad, lat_rad)
+ x_cartesian[i], y_cartesian[i], z_cartesian[i] = cartesian
+
+ x, y, z = x_cartesian, y_cartesian, z_cartesian
+
# Using tempestremap GridElements: https://github.com/ClimateGlobalChange/tempestremap/blob/master/src/GridElements.cpp
# loop through all sub-triangles of face
+ total_correction = 0.0
for j in range(0, num_triangles):
node1 = np.array([x[0], y[0], z[0]], dtype=x.dtype)
node2 = np.array([x[j + 1], y[j + 1], z[j + 1]], dtype=x.dtype)
node3 = np.array([x[j + 2], y[j + 2], z[j + 2]], dtype=x.dtype)
- if coords_type == "spherical":
- node1 = _lonlat_rad_to_xyz(np.deg2rad(x[0]), np.deg2rad(y[0]))
- node1 = np.asarray(node1)
-
- node2 = _lonlat_rad_to_xyz(np.deg2rad(x[j + 1]), np.deg2rad(y[j + 1]))
- node2 = np.asarray(node2)
-
- node3 = _lonlat_rad_to_xyz(np.deg2rad(x[j + 2]), np.deg2rad(y[j + 2]))
- node3 = np.asarray(node3)
-
for p in range(len(dW)):
if quadrature_rule == "gaussian":
for q in range(len(dW)):
@@ -92,9 +105,113 @@ def calculate_face_area(
area += dW[p] * jacobian
jacobian += jacobian
+ # check if the any edge is on the line of constant latitude
+ # which means we need to check edges for same z-coordinates and call area correction routine
+ correction = 0.0
+ if correct_area:
+ for i in range(num_nodes):
+ node1 = np.array([x[i], y[i], z[i]], dtype=x.dtype)
+ node2 = np.array(
+ [
+ x[(i + 1) % num_nodes],
+ y[(i + 1) % num_nodes],
+ z[(i + 1) % num_nodes],
+ ],
+ dtype=x.dtype,
+ )
+ # Check if z-coordinates are approximately equal
+ if np.isclose(node1[2], node2[2]):
+ # Check if the edge passes through a pole
+ passes_through_pole = edge_passes_through_pole(node1, node2)
+ print("Check if edge passes through pole: ", passes_through_pole)
+
+ if passes_through_pole:
+ # Skip the edge if it passes through a pole
+ continue
+ else:
+ # Calculate the correction term
+ correction = area_correction(node1, node2)
+ print("\n\ncalculated correction: ", correction)
+ # Check if the edge is in the northern or southern hemisphere
+ hemisphere = ""
+ if node1[2] > 0 and node2[2] > 0:
+ hemisphere = "Northern"
+ else:
+ hemisphere = "Southern"
+ if hemisphere != "":
+ print("Edge is in the ", hemisphere, " hemisphere.")
+ # Check if the edge goes from higher to lower longitude
+ # Convert Cartesian coordinates to longitude
+ lon1 = np.arctan2(node1[1], node1[0])
+ lon2 = np.arctan2(node2[1], node2[0])
+
+ # Calculate the longitude difference in radians
+ lon_diff = lon2 - lon1
+
+ # Adjust for the case where longitude wraps around the 180° meridian
+ if lon_diff > np.pi:
+ lon_diff -= 2 * np.pi
+ elif lon_diff < -np.pi:
+ lon_diff += 2 * np.pi
+
+ # Check if the longitude is increasing
+ if lon_diff > 0 and hemisphere == "Northern":
+ correction = -correction
+ elif lon_diff < 0 and hemisphere == "Southern":
+ correction = -correction
+
+ print(
+ "For Node 1 ",
+ node1,
+ "\n and Node 2",
+ node2,
+ "\nCORRECTION",
+ correction,
+ )
+ total_correction += correction
+
+ if total_correction != 0.0:
+ print("AREA Before Correction", area)
+
+ area += total_correction
+
+ if total_correction != 0.0:
+ print("AREA After Correction", area)
+
return area, jacobian
+@njit(cache=True)
+def edge_passes_through_pole(node1, node2):
+ """
+ Check if the edge passes through a pole.
+
+ Parameters:
+ - node1: first node of the edge (normalized).
+ - node2: second node of the edge (normalized).
+
+ Returns:
+ - bool: True if the edge passes through a pole, False otherwise.
+ """
+ # Calculate the normal vector to the plane defined by the origin, node1, and node2
+ n = np.cross(node1, node2)
+
+ # Check for numerical stability issues with the normal vector
+ if np.allclose(n, 0):
+ # Handle cases where the cross product is near zero, such as when nodes are nearly identical or opposite
+ return False
+
+ # Normalize the normal vector
+ n = n / np.linalg.norm(n)
+
+ # North and South Pole vectors
+ p_north = np.array([0.0, 0.0, 1.0])
+ p_south = np.array([0.0, 0.0, -1.0])
+
+ # Check if the normal vector is orthogonal to either pole
+ return np.isclose(np.dot(n, p_north), 0) or np.isclose(np.dot(n, p_south), 0)
+
+
@njit(cache=True)
def get_all_face_area_from_coords(
x,
@@ -106,6 +223,7 @@ def get_all_face_area_from_coords(
quadrature_rule="triangular",
order=4,
coords_type="spherical",
+ correct_area=False,
):
"""Given coords, connectivity and other area calculation params, this
routine loop over all faces and return an numpy array with areas of each
@@ -141,6 +259,10 @@ def get_all_face_area_from_coords(
-------
area of all faces : ndarray
"""
+ # this casting helps to prevent the type mismatch
+ x = np.asarray(x, dtype=np.float64)
+ y = np.asarray(y, dtype=np.float64)
+ z = np.asarray(z, dtype=np.float64)
n_face, n_max_face_nodes = face_nodes.shape
@@ -161,7 +283,7 @@ def get_all_face_area_from_coords(
# After getting all the nodes of a face assembled call the cal. face area routine
face_area, face_jacobian = calculate_face_area(
- face_x, face_y, face_z, quadrature_rule, order, coords_type
+ face_x, face_y, face_z, quadrature_rule, order, coords_type, correct_area
)
# store current face area
area[face_idx] = face_area
@@ -170,6 +292,55 @@ def get_all_face_area_from_coords(
return area, jacobian
+@njit(cache=True)
+def area_correction(node1, node2):
+ """
+ Calculate the area correction A using the given formula.
+
+ Parameters:
+ - node1: first node of the edge (normalized).
+ - node2: second node of the edge (normalized).
+ - z: z-coordinate (shared by both points and part of the formula, normalized).
+
+ Returns:
+ - A: correction term of the area, when one of the edges is a line of constant latitude
+ """
+ x1 = node1[0]
+ y1 = node1[1]
+ x2 = node2[0]
+ y2 = node2[1]
+ z = node1[2]
+
+ # Calculate terms
+ term1 = x1 * y2 - x2 * y1
+ den1 = x1**2 + y1**2 + x1 * x2 + y1 * y2
+ den2 = x1 * x2 + y1 * y2
+
+ # Helper function to handle arctan quadrants
+ def arctan_quad(y, x):
+ if x > 0:
+ return np.arctan(y / x)
+ elif x < 0 and y >= 0:
+ return np.arctan(y / x) + np.pi
+ elif x < 0 and y < 0:
+ return np.arctan(y / x) - np.pi
+ elif x == 0 and y > 0:
+ return np.pi / 2
+ elif x == 0 and y < 0:
+ return -np.pi / 2
+ else:
+ return 0 # x == 0 and y == 0 case
+
+ # Compute angles using arctan
+ angle1 = arctan_quad(z * term1, den1)
+ angle2 = arctan_quad(term1, den2)
+
+ # Compute A
+ A = abs(2 * angle1 - z * angle2)
+ print(x1, y1, x2, y2, z, "correction:", A)
+ return A
+
+
@njit(cache=True)
def calculate_spherical_triangle_jacobian(node1, node2, node3, dA, dB):
"""Calculate Jacobian of a spherical triangle. This is a helper function
diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py
index 105841339..8312ff1d2 100644
--- a/uxarray/grid/grid.py
+++ b/uxarray/grid/grid.py
@@ -1766,7 +1766,10 @@ def encode_as(self, grid_type: str) -> xr.Dataset:
return out_ds
def calculate_total_face_area(
- self, quadrature_rule: Optional[str] = "triangular", order: Optional[int] = 4
+ self,
+ quadrature_rule: Optional[str] = "triangular",
+ order: Optional[int] = 4,
+ correct_area: Optional[bool] = False,
) -> float:
"""Function to calculate the total surface area of all the faces in a
mesh.
@@ -1784,7 +1787,9 @@ def calculate_total_face_area(
"""
# call function to get area of all the faces as a np array
- face_areas, face_jacobian = self.compute_face_areas(quadrature_rule, order)
+ face_areas, face_jacobian = self.compute_face_areas(
+ quadrature_rule, order, correct_area=correct_area
+ )
return np.sum(face_areas)
@@ -1793,6 +1798,7 @@ def compute_face_areas(
quadrature_rule: Optional[str] = "triangular",
order: Optional[int] = 4,
latlon: Optional[bool] = True,
+ correct_area: Optional[bool] = False,
):
"""Face areas calculation function for grid class, calculates area of
all faces in the grid.
@@ -1860,6 +1866,7 @@ def compute_face_areas(
quadrature_rule,
order,
coords_type,
+ correct_area,
)
min_jacobian = np.min(self._face_jacobian)