Skip to content

Commit e9289c9

Browse files
committed
o robust angle calculation and enhanced documentation
1 parent 263713e commit e9289c9

File tree

2 files changed

+71
-32
lines changed

2 files changed

+71
-32
lines changed

docs/user-guide/zonal-average.ipynb

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@
1717
"metadata": {},
1818
"outputs": [],
1919
"source": [
20+
"import matplotlib.pyplot as plt\n",
2021
"import numpy as np\n",
2122
"\n",
2223
"import uxarray as ux\n",
2324
"\n",
2425
"uxds = ux.open_dataset(\n",
2526
" \"../../test/meshfiles/ugrid/outCSne30/outCSne30.ug\",\n",
2627
" \"../../test/meshfiles/ugrid/outCSne30/outCSne30_vortex.nc\",\n",
27-
")\n",
28-
"uxds[\"psi\"].plot(cmap=\"inferno\", periodic_elements=\"split\")"
28+
")"
2929
]
3030
},
3131
{
@@ -35,10 +35,10 @@
3535
"source": [
3636
"## What is a Zonal Average/Mean?\n",
3737
"\n",
38-
"A zonal average (or zonal mean) is a statistical measure that represents the average of a variable along one or more lines of constant latitude. In other words, it's the mean value calculated around the sphere at constant latitudes.\n",
38+
"A zonal average (or zonal mean) is a statistical measure that represents the average of a variable along lines of constant latitude or over latitudinal bands.\n",
3939
"\n",
4040
"UXarray provides two types of zonal averaging:\n",
41-
"- **Non-conservative**: Weights candidate faces by the length of intersection of a line of constant latitude\n",
41+
"- **Non-conservative**: Samples values at specific latitude lines\n",
4242
"- **Conservative**: Preserves integral quantities by weighting faces by their area overlap with latitude bands\n",
4343
"\n",
4444
"```{seealso}\n",
@@ -53,7 +53,26 @@
5353
"source": [
5454
"## Non-Conservative Zonal Averaging\n",
5555
"\n",
56-
"The non-conservative method samples values at specific lines of constant latitude. This is the default behavior and is suitable for visualization and general analysis where exact conservation is not required."
56+
"The non-conservative method samples values at specific lines of constant latitude. This is the default behavior and is suitable for visualization and general analysis where exact conservation is not required.\n",
57+
"\n",
58+
"Let's first visualize our data field and then demonstrate zonal averaging:"
59+
]
60+
},
61+
{
62+
"cell_type": "code",
63+
"execution_count": null,
64+
"id": "global_field_plot",
65+
"metadata": {},
66+
"outputs": [],
67+
"source": [
68+
"# Display the global field\n",
69+
"uxds[\"psi\"].plot(cmap=\"inferno\", periodic_elements=\"split\", title=\"Global Field\")\n",
70+
"\n",
71+
"# Show conceptual latitude bands for zonal averaging\n",
72+
"print(\"Latitude bands for zonal averaging:\")\n",
73+
"lat_bands = np.arange(-90, 91, 30)\n",
74+
"for i, lat in enumerate(lat_bands[:-1]):\n",
75+
" print(f\"Band {i + 1}: {lat}° to {lat_bands[i + 1]}°\")"
5776
]
5877
},
5978
{
@@ -222,13 +241,13 @@
222241
"id": "visual_comparison",
223242
"metadata": {},
224243
"source": [
225-
"### Visual Comparison: Conservative vs Non-Conservative",
226-
"",
227-
"The differences between methods reflect their fundamental approaches:",
228-
"",
229-
"- **Conservative**: More accurate for physical quantities because it accounts for the actual area of each face within latitude bands",
230-
"- **Non-conservative**: Faster but approximates by sampling at specific latitude lines",
231-
"",
244+
"### Visual Comparison: Conservative vs Non-Conservative\n",
245+
"\n",
246+
"The differences between methods reflect their fundamental approaches:\n",
247+
"\n",
248+
"- **Conservative**: More accurate for physical quantities because it accounts for the actual area of each face within latitude bands\n",
249+
"- **Non-conservative**: Faster but approximates by sampling at specific latitude lines\n",
250+
"\n",
232251
"The differences you see indicate how much area-weighting matters for your specific data and grid resolution."
233252
]
234253
},
@@ -239,8 +258,6 @@
239258
"metadata": {},
240259
"outputs": [],
241260
"source": [
242-
"import matplotlib.pyplot as plt\n",
243-
"\n",
244261
"# Compare with non-conservative at same latitudes\n",
245262
"band_centers = 0.5 * (bands[:-1] + bands[1:])\n",
246263
"non_conservative_comparison = uxds[\"psi\"].zonal_mean(lat=band_centers)\n",
@@ -338,29 +355,35 @@
338355
"metadata": {},
339356
"outputs": [],
340357
"source": [
341-
"# Create combined plot with matplotlib\n",
342-
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))\n",
358+
"# Display the global field\n",
359+
"print(\"Global Field:\")\n",
360+
"uxds[\"psi\"].plot(cmap=\"inferno\", periodic_elements=\"split\", title=\"Global Field\")\n",
343361
"\n",
344-
"# Left: Data field\n",
345-
"uxds[\"psi\"].plot(ax=ax1, cmap=\"inferno\")\n",
346-
"ax1.set_title(\"Global Field\")\n",
362+
"# Create zonal average plot\n",
363+
"fig, ax = plt.subplots(1, 1, figsize=(8, 6))\n",
347364
"\n",
348-
"# Right: Zonal average\n",
349-
"ax2.plot(\n",
365+
"ax.plot(\n",
350366
" zonal_mean_psi.values,\n",
351367
" zonal_mean_psi.coords[\"latitudes\"],\n",
352368
" \"o-\",\n",
353369
" linewidth=2,\n",
354-
" markersize=4,\n",
370+
" markersize=6,\n",
371+
" color=\"blue\",\n",
372+
" label=\"Zonal Mean\",\n",
355373
")\n",
356-
"ax2.set_xlabel(\"Zonal Mean Value\")\n",
357-
"ax2.set_ylabel(\"Latitude (degrees)\")\n",
358-
"ax2.set_title(\"Zonal Average\")\n",
359-
"ax2.grid(True, alpha=0.3)\n",
360-
"ax2.set_ylim(-90, 90)\n",
361-
"ax2.set_xlim(0.8, 1.2)\n",
362-
"\n",
363-
"plt.suptitle(\"Combined Zonal Average & Raster Plot\")\n",
374+
"\n",
375+
"ax.set_xlabel(\"Zonal Mean Value\")\n",
376+
"ax.set_ylabel(\"Latitude (degrees)\")\n",
377+
"ax.set_title(\"Zonal Average\")\n",
378+
"ax.grid(True, alpha=0.3)\n",
379+
"ax.set_ylim(-90, 90)\n",
380+
"ax.legend()\n",
381+
"\n",
382+
"# Add reference latitude lines\n",
383+
"sample_bands = [-60, -30, 0, 30, 60]\n",
384+
"for lat_band in sample_bands:\n",
385+
" ax.axhline(y=lat_band, color=\"red\", linestyle=\"--\", alpha=0.5, linewidth=1)\n",
386+
"\n",
364387
"plt.tight_layout()\n",
365388
"plt.show()"
366389
]

uxarray/core/zonal.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
gca_const_lat_intersection,
99
get_number_of_intersections,
1010
)
11-
from uxarray.grid.utils import _get_cartesian_face_edge_nodes_array
11+
from uxarray.grid.utils import (
12+
_get_cartesian_face_edge_nodes_array,
13+
_small_angle_of_2_vectors,
14+
)
1215

1316

1417
def _compute_non_conservative_zonal_mean(uxda, latitudes, use_robust_weights=False):
@@ -89,8 +92,21 @@ def _sort_points_by_angle(points):
8992

9093
# Calculate angles (longitude)
9194
angles = np.empty(n_points, dtype=np.float64)
95+
x_axis = np.array([1.0, 0.0, 0.0])
9296
for i in range(n_points):
93-
angles[i] = np.arctan2(points[i, 1], points[i, 0])
97+
# Project point to xy plane for longitude calculation
98+
point_xy = np.array([points[i, 0], points[i, 1], 0.0])
99+
point_xy_norm = np.linalg.norm(point_xy)
100+
101+
if point_xy_norm < 1e-15:
102+
angles[i] = 0.0 # Point at pole
103+
else:
104+
point_xy_unit = point_xy / point_xy_norm
105+
angle = _small_angle_of_2_vectors(x_axis, point_xy_unit)
106+
# Determine sign based on y coordinate
107+
if points[i, 1] < 0:
108+
angle = -angle
109+
angles[i] = angle
94110

95111
# Simple insertion sort (numba-friendly for small arrays)
96112
sorted_points = points.copy()

0 commit comments

Comments
 (0)