Skip to content

Commit fd757b0

Browse files
authored
Merge pull request #1052 from UXARRAY/zedwick/to_planar
Regional Delaunay
2 parents b11d011 + 9f8d840 commit fd757b0

File tree

6 files changed

+312
-30
lines changed

6 files changed

+312
-30
lines changed

docs/user-guide/from-points.ipynb

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"uxgrid_global = ux.open_grid(\"../../test/meshfiles/ugrid/outCSne30/outCSne30.ug\")\n",
8181
"uxgrid_global_ocean = ux.open_grid(\"../../test/meshfiles/mpas/QU/oQU480.231010.nc\")\n",
8282
"uxgrid_global_ocean.normalize_cartesian_coordinates()\n",
83-
"uxgrid_regional = uxgrid_global.subset.nearest_neighbor((0.0, 0.0), k=9)\n",
83+
"uxgrid_regional = uxgrid_global.subset.nearest_neighbor((0.0, 0.0), k=50)\n",
8484
"\n",
8585
"(\n",
8686
" uxgrid_global.plot.face_centers(\n",
@@ -723,9 +723,105 @@
723723
"source": [
724724
"## Regional Data\n",
725725
"\n",
726-
"```{warning}\n",
727-
"Constructing a grid from regional point data is not yet supported.\n",
728-
"```"
726+
"The regional delaunay method can be used to construct a grid from points in a regional area.\n",
727+
"\n",
728+
"### How It Works\n",
729+
"\n",
730+
"1. **Input Points on the Sphere**:\n",
731+
" - The method accepts input points defined in spherical coordinates (e.g., latitude and longitude) or Cartesian coordinates (x, y, z) that lie on the surface of the sphere. They are internally converted to normalized Cartesian coordinates.\n",
732+
"\n",
733+
"2. **Computing the Regional Delaunay Diagram**:\n",
734+
" - The method projects the points to a 2D plane using stereographic projection, followed by SciPy's `Delaunay` triangulation method to construct the grid.\n",
735+
"\n",
736+
"3. **Extracting Triangles**:\n",
737+
" - Once the triangles of 2D points are determined, the connectivity of the triangular faces are extracted. These triangles represent the Delaunay triangulation on the sphere's surface, ensuring that no point is inside the circumcircle of any triangle, which is a key property of Delaunay triangulations."
738+
]
739+
},
740+
{
741+
"cell_type": "code",
742+
"execution_count": null,
743+
"id": "5e15b602-70e1-434d-a0a4-51d5913abb6a",
744+
"metadata": {},
745+
"outputs": [],
746+
"source": [
747+
"grid_r = ux.Grid.from_points(points_regional, method=\"regional_delaunay\")"
748+
]
749+
},
750+
{
751+
"cell_type": "code",
752+
"execution_count": null,
753+
"id": "6e4d838a-4f7f-474f-a2f9-47569df02a91",
754+
"metadata": {},
755+
"outputs": [],
756+
"source": [
757+
"grid_r.plot(\n",
758+
" linewidth=0.5,\n",
759+
" periodic_elements=\"exclude\",\n",
760+
" height=500,\n",
761+
" width=1000,\n",
762+
" title=\"Regional Delaunay Regions\",\n",
763+
")"
764+
]
765+
},
766+
{
767+
"cell_type": "markdown",
768+
"id": "03dbafcc-a603-466d-b886-adb15764bd4c",
769+
"metadata": {},
770+
"source": [
771+
"### Antimerdian\n",
772+
"\n",
773+
"This also works on regions wrapping the antimerdian"
774+
]
775+
},
776+
{
777+
"cell_type": "code",
778+
"execution_count": null,
779+
"id": "95e37aa6-6591-47b0-8a64-9d38f9dec664",
780+
"metadata": {},
781+
"outputs": [],
782+
"source": [
783+
"antimerdian_region = uxgrid_global.subset.bounding_circle((-180, 0), 10)"
784+
]
785+
},
786+
{
787+
"cell_type": "code",
788+
"execution_count": null,
789+
"id": "2d630797-b2c8-46dc-bda2-60a69ed266d6",
790+
"metadata": {},
791+
"outputs": [],
792+
"source": [
793+
"x_antimerdian_region, y_antimerdian_region, z_antimerdian_region = (\n",
794+
" antimerdian_region.face_x.values,\n",
795+
" antimerdian_region.face_y.values,\n",
796+
" antimerdian_region.face_z.values,\n",
797+
")\n",
798+
"antimerdian_region = (x_antimerdian_region, y_antimerdian_region, z_antimerdian_region)"
799+
]
800+
},
801+
{
802+
"cell_type": "code",
803+
"execution_count": null,
804+
"id": "8f5e70c5-c31e-4183-af2a-24ed1dcaba94",
805+
"metadata": {},
806+
"outputs": [],
807+
"source": [
808+
"grid_r = ux.Grid.from_points(antimerdian_region, method=\"regional_delaunay\")"
809+
]
810+
},
811+
{
812+
"cell_type": "code",
813+
"execution_count": null,
814+
"id": "a83c11a8-fbc9-4dc9-ba10-8c3babdae83f",
815+
"metadata": {},
816+
"outputs": [],
817+
"source": [
818+
"grid_r.plot(\n",
819+
" linewidth=0.5,\n",
820+
" periodic_elements=\"exclude\",\n",
821+
" height=500,\n",
822+
" width=1000,\n",
823+
" title=\"Regional Delaunay Regions\",\n",
824+
")"
729825
]
730826
}
731827
],
@@ -745,7 +841,7 @@
745841
"name": "python",
746842
"nbconvert_exporter": "python",
747843
"pygments_lexer": "ipython3",
748-
"version": "3.12.0"
844+
"version": "3.12.7"
749845
}
750846
},
751847
"nbformat": 4,

test/test_from_points.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ def test_spherical_delaunay():
2525
assert uxgrid_dt_xyz.triangular
2626
assert uxgrid_dt_latlon.triangular
2727

28+
29+
def test_regional_delaunay():
30+
uxgrid = ux.open_grid(grid_path)
31+
32+
uxgrid_regional = uxgrid.subset.nearest_neighbor((0.0, 0.0), k=50)
33+
34+
points_xyz = (uxgrid_regional.node_x.values, uxgrid_regional.node_y.values, uxgrid_regional.node_z.values)
35+
points_latlon = (uxgrid_regional.node_lon.values, uxgrid_regional.node_lat.values)
36+
37+
uxgrid_dt_xyz = ux.Grid.from_points(points_xyz, method='regional_delaunay')
38+
uxgrid_dt_latlon = ux.Grid.from_points(points_latlon, method='regional_delaunay')
39+
40+
assert uxgrid_dt_xyz.n_node == uxgrid_dt_latlon.n_node == len(points_xyz[0])
41+
assert uxgrid_dt_xyz.triangular
42+
assert uxgrid_dt_latlon.triangular
43+
44+
2845
def test_spherical_voronoi():
2946
uxgrid = ux.open_grid(grid_path)
3047
points_xyz = (uxgrid.node_x.values, uxgrid.node_y.values, uxgrid.node_z.values)

test/test_geometry.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from uxarray.grid.coordinates import _populate_node_latlon, _lonlat_rad_to_xyz, _normalize_xyz, _xyz_to_lonlat_rad
1313
from uxarray.grid.arcs import extreme_gca_latitude
1414
from uxarray.grid.utils import _get_cartesian_face_edge_nodes, _get_lonlat_rad_face_edge_nodes
15-
from uxarray.grid.geometry import _populate_face_latlon_bound, _populate_bounds
15+
from uxarray.grid.geometry import _populate_face_latlon_bound, _populate_bounds, stereographic_projection, \
16+
inverse_stereographic_projection
1617

1718
from spatialpandas.geometry import MultiPolygon
1819

@@ -27,14 +28,14 @@
2728
grid_files = [gridfile_CSne8, gridfile_geoflow]
2829
data_files = [datafile_CSne30, datafile_geoflow]
2930

30-
grid_quad_hex = current_path/ "meshfiles" / "ugrid" / "quad-hexagon" / "grid.nc"
31-
grid_geoflow = current_path/ "meshfiles" / "ugrid" / "geoflow-small" / "grid.nc"
32-
grid_mpas = current_path/ "meshfiles" / "mpas" / "QU" / "oQU480.231010.nc"
33-
31+
grid_quad_hex = current_path / "meshfiles" / "ugrid" / "quad-hexagon" / "grid.nc"
32+
grid_geoflow = current_path / "meshfiles" / "ugrid" / "geoflow-small" / "grid.nc"
33+
grid_mpas = current_path / "meshfiles" / "mpas" / "QU" / "oQU480.231010.nc"
3434

3535
# List of grid files to test
3636
grid_files_latlonBound = [grid_quad_hex, grid_geoflow, gridfile_CSne8, grid_mpas]
3737

38+
3839
class TestAntimeridian(TestCase):
3940

4041
def test_crossing(self):
@@ -63,8 +64,6 @@ def test_linecollection_execution(self):
6364
lines = uxgrid.to_linecollection()
6465

6566

66-
67-
6867
class TestPredicate(TestCase):
6968

7069
def test_pole_point_inside_polygon_from_vertice_north(self):
@@ -377,7 +376,7 @@ def test_extreme_gca_latitude_max(self):
377376

378377
def test_extreme_gca_latitude_max_short(self):
379378
# Define a great circle arc in 3D space that has a small span
380-
gca_cart = np.array( [[ 0.65465367, -0.37796447, -0.65465367], [ 0.6652466, -0.33896007, -0.6652466 ]])
379+
gca_cart = np.array([[0.65465367, -0.37796447, -0.65465367], [0.6652466, -0.33896007, -0.6652466]])
381380

382381
# Calculate the maximum latitude
383382
max_latitude = ux.grid.arcs.extreme_gca_latitude(gca_cart, 'max')
@@ -463,7 +462,7 @@ def test_insert_pt_in_empty_state(self):
463462
class TestLatlonBoundsGCA(TestCase):
464463

465464
def _get_cartesian_face_edge_nodes_testcase_helper(
466-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
465+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
467466
):
468467
"""This function is only used to help generating the testcase and
469468
should not be used in the actual implementation. Construct an array to
@@ -522,7 +521,7 @@ def _get_cartesian_face_edge_nodes_testcase_helper(
522521
return cartesian_coordinates
523522

524523
def _get_lonlat_rad_face_edge_nodes_testcase_helper(
525-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
524+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
526525
):
527526
"""This function is only used to help generating the testcase and
528527
should not be used in the actual implementation. Construct an array to
@@ -691,7 +690,7 @@ def test_populate_bounds_near_pole(self):
691690
[[_xyz_to_lonlat_rad(*edge[0]), _xyz_to_lonlat_rad(*edge[1])] for edge in face_edges_cart])
692691

693692
bounds = _populate_face_latlon_bound(face_edges_cart, face_edges_lonlat)
694-
expected_bounds = np.array([[-1.20427718, -1.14935491], [0,0.13568803]])
693+
expected_bounds = np.array([[-1.20427718, -1.14935491], [0, 0.13568803]])
695694
nt.assert_allclose(bounds, expected_bounds, atol=ERROR_TOLERANCE)
696695

697696
def test_populate_bounds_near_pole2(self):
@@ -703,13 +702,12 @@ def test_populate_bounds_near_pole2(self):
703702
[[4.06271283e-01, -4.78221112e-02, -9.12500241e-01], [3.57939780e-01, -4.88684203e-02, -9.32465008e-01]]
704703
])
705704

706-
707705
# Apply the inverse transformation to get the lat lon coordinates
708706
face_edges_lonlat = np.array(
709707
[[_xyz_to_lonlat_rad(*edge[0]), _xyz_to_lonlat_rad(*edge[1])] for edge in face_edges_cart])
710708

711709
bounds = _populate_face_latlon_bound(face_edges_cart, face_edges_lonlat)
712-
expected_bounds = np.array([[-1.20427718, -1.14935491], [6.147497,4.960524e-16]])
710+
expected_bounds = np.array([[-1.20427718, -1.14935491], [6.147497, 4.960524e-16]])
713711
nt.assert_allclose(bounds, expected_bounds, atol=ERROR_TOLERANCE)
714712

715713
def test_populate_bounds_long_face(self):
@@ -735,10 +733,7 @@ def test_populate_bounds_long_face(self):
735733
bounds = _populate_face_latlon_bound(face_edges_cart, face_edges_lonlat)
736734

737735
# The expected bounds should not contains the south pole [0,-0.5*np.pi]
738-
self.assertTrue(bounds[1][0] != 0.0)
739-
740-
741-
736+
self.assertTrue(bounds[1][0] != 0.0)
742737

743738
def test_populate_bounds_node_on_pole(self):
744739
# Generate a normal face that is crossing the antimeridian
@@ -828,7 +823,7 @@ def test_populate_bounds_pole_inside(self):
828823
class TestLatlonBoundsLatLonFace(TestCase):
829824

830825
def _get_cartesian_face_edge_nodes_testcase_helper(
831-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
826+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
832827
):
833828
"""This function is only used to help generating the testcase and
834829
should not be used in the actual implementation. Construct an array to
@@ -887,7 +882,7 @@ def _get_cartesian_face_edge_nodes_testcase_helper(
887882
return cartesian_coordinates
888883

889884
def _get_lonlat_rad_face_edge_nodes_testcase_helper(
890-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
885+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
891886
):
892887
"""This function is only used to help generating the testcase and
893888
should not be used in the actual implementation. Construct an array to
@@ -1088,7 +1083,7 @@ def test_populate_bounds_pole_inside(self):
10881083

10891084
class TestLatlonBoundsGCAList(TestCase):
10901085
def _get_cartesian_face_edge_nodes_testcase_helper(
1091-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
1086+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_x, node_y, node_z
10921087
):
10931088
"""This function is only used to help generating the testcase and
10941089
should not be used in the actual implementation. Construct an array to
@@ -1147,7 +1142,7 @@ def _get_cartesian_face_edge_nodes_testcase_helper(
11471142
return cartesian_coordinates
11481143

11491144
def _get_lonlat_rad_face_edge_nodes_testcase_helper(
1150-
self,face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
1145+
self, face_nodes_ind, face_edges_ind, edge_nodes_grid, node_lon, node_lat
11511146
):
11521147
"""This function is only used to help generating the testcase and
11531148
should not be used in the actual implementation. Construct an array to
@@ -1235,8 +1230,6 @@ def test_populate_bounds_normal(self):
12351230
is_GCA_list=[True, False, True, False])
12361231
nt.assert_allclose(bounds, expected_bounds, atol=ERROR_TOLERANCE)
12371232

1238-
1239-
12401233
def test_populate_bounds_antimeridian(self):
12411234
# Generate a normal face that is crossing the antimeridian
12421235
vertices_lonlat = [[350, 60.0], [350, 10.0], [50.0, 10.0], [50.0, 60.0]]
@@ -1436,7 +1429,6 @@ def test_face_bounds(self):
14361429

14371430
class TestGeoDataFrame(TestCase):
14381431

1439-
14401432
def test_engine(self):
14411433
uxgrid = ux.open_grid(gridfile_geoflow)
14421434
for engine in ['geopandas', 'spatialpandas']:
@@ -1483,3 +1475,20 @@ def test_cache_and_override(self):
14831475
gdf_f = uxgrid.to_geodataframe(exclude_antimeridian=True)
14841476

14851477
assert gdf_f is not gdf_e
1478+
1479+
1480+
class TestStereographicProjection(TestCase):
1481+
def test_stereographic_projection(self):
1482+
lon = np.array(0)
1483+
lat = np.array(0)
1484+
1485+
central_lon = np.array(0)
1486+
central_lat = np.array(0)
1487+
1488+
x, y = stereographic_projection(lon, lat, central_lon, central_lat)
1489+
1490+
new_lon, new_lat = inverse_stereographic_projection(x, y, central_lon, central_lat)
1491+
1492+
self.assertTrue(lon == new_lon)
1493+
self.assertTrue(lat == new_lat)
1494+
self.assertTrue(x == y == 0)

0 commit comments

Comments
 (0)