gtrack 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gtrack/__init__.py +137 -0
- gtrack/boundaries.py +396 -0
- gtrack/config.py +202 -0
- gtrack/geometry.py +348 -0
- gtrack/hpc_integration.py +851 -0
- gtrack/initial_conditions.py +255 -0
- gtrack/io_formats.py +477 -0
- gtrack/logging.py +193 -0
- gtrack/mesh.py +101 -0
- gtrack/mor_seeds.py +390 -0
- gtrack/point_rotation.py +836 -0
- gtrack/polygon_filter.py +223 -0
- gtrack/spatial.py +397 -0
- gtrack-0.3.0.dist-info/METADATA +66 -0
- gtrack-0.3.0.dist-info/RECORD +17 -0
- gtrack-0.3.0.dist-info/WHEEL +5 -0
- gtrack-0.3.0.dist-info/top_level.txt +1 -0
gtrack/polygon_filter.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polygon filtering operations for point clouds.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to filter points based on polygon containment,
|
|
5
|
+
useful for selecting points inside continental polygons, exclusion zones, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Union
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolygonFilter:
|
|
15
|
+
"""
|
|
16
|
+
Filter points by polygon containment.
|
|
17
|
+
|
|
18
|
+
Supports filtering by:
|
|
19
|
+
- Continental polygons (keep only continental points)
|
|
20
|
+
- Custom polygons (user-provided)
|
|
21
|
+
- Exclusion zones (remove points inside polygons)
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
polygon_files : str or list of str
|
|
26
|
+
Path(s) to polygon files (.gpml, .gpmlz).
|
|
27
|
+
rotation_files : str or list of str
|
|
28
|
+
Paths to rotation model files (.rot).
|
|
29
|
+
|
|
30
|
+
Examples
|
|
31
|
+
--------
|
|
32
|
+
>>> filter = PolygonFilter(
|
|
33
|
+
... polygon_files='continental_polygons.gpmlz',
|
|
34
|
+
... rotation_files=['rotations.rot']
|
|
35
|
+
... )
|
|
36
|
+
>>>
|
|
37
|
+
>>> # Keep only continental points
|
|
38
|
+
>>> continental_cloud = filter.filter_inside(cloud, at_age=0.0)
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Remove continental points (keep oceanic)
|
|
41
|
+
>>> oceanic_cloud = filter.filter_outside(cloud, at_age=0.0)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
polygon_files: Union[str, List[str]],
|
|
47
|
+
rotation_files: Union[str, List[str]]
|
|
48
|
+
):
|
|
49
|
+
import pygplates
|
|
50
|
+
from .geometry import ensure_list
|
|
51
|
+
|
|
52
|
+
# Handle single file or Path as list
|
|
53
|
+
polygon_files = ensure_list(polygon_files)
|
|
54
|
+
rotation_files = ensure_list(rotation_files)
|
|
55
|
+
|
|
56
|
+
self.rotation_model = pygplates.RotationModel(rotation_files)
|
|
57
|
+
|
|
58
|
+
# Load polygon features
|
|
59
|
+
self.polygon_features = pygplates.FeatureCollection()
|
|
60
|
+
for file in polygon_files:
|
|
61
|
+
features = pygplates.FeatureCollection(file)
|
|
62
|
+
self.polygon_features.add(features)
|
|
63
|
+
|
|
64
|
+
def get_containment_mask(
|
|
65
|
+
self,
|
|
66
|
+
cloud: "PointCloud",
|
|
67
|
+
at_age: float
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
"""
|
|
70
|
+
Get boolean mask of points inside polygons.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
cloud : PointCloud
|
|
75
|
+
Points to check.
|
|
76
|
+
at_age : float
|
|
77
|
+
Geological age at which to check containment (Ma).
|
|
78
|
+
Use 0.0 for present-day polygons.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
np.ndarray
|
|
83
|
+
Boolean mask, shape (N,), True for points inside polygons.
|
|
84
|
+
"""
|
|
85
|
+
import pygplates
|
|
86
|
+
from .geometry import XYZ2LatLon
|
|
87
|
+
|
|
88
|
+
# Convert to lat/lon for pygplates
|
|
89
|
+
lats, lons = XYZ2LatLon(cloud.xyz)
|
|
90
|
+
|
|
91
|
+
# Create partitioner at specified age
|
|
92
|
+
partitioner = pygplates.PlatePartitioner(
|
|
93
|
+
self.polygon_features,
|
|
94
|
+
self.rotation_model,
|
|
95
|
+
reconstruction_time=at_age,
|
|
96
|
+
sort_partitioning_plates=(
|
|
97
|
+
pygplates.SortPartitioningPlates
|
|
98
|
+
.by_partition_type_then_plate_area
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Check each point
|
|
103
|
+
# Note: pygplates doesn't have batch partition_point, so we loop
|
|
104
|
+
# This is still efficient because PlatePartitioner does internal spatial indexing
|
|
105
|
+
mask = np.zeros(cloud.n_points, dtype=bool)
|
|
106
|
+
|
|
107
|
+
for i in range(cloud.n_points):
|
|
108
|
+
point = pygplates.PointOnSphere(lats[i], lons[i])
|
|
109
|
+
# partition_point returns None if not inside any polygon
|
|
110
|
+
mask[i] = partitioner.partition_point(point) is not None
|
|
111
|
+
|
|
112
|
+
return mask
|
|
113
|
+
|
|
114
|
+
def filter_inside(
|
|
115
|
+
self,
|
|
116
|
+
cloud: "PointCloud",
|
|
117
|
+
at_age: float
|
|
118
|
+
) -> "PointCloud":
|
|
119
|
+
"""
|
|
120
|
+
Keep only points inside polygons.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
cloud : PointCloud
|
|
125
|
+
Points to filter.
|
|
126
|
+
at_age : float
|
|
127
|
+
Geological age at which to check containment (Ma).
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
PointCloud
|
|
132
|
+
Points inside polygons.
|
|
133
|
+
|
|
134
|
+
Examples
|
|
135
|
+
--------
|
|
136
|
+
>>> # Keep only continental points at present day
|
|
137
|
+
>>> continental = filter.filter_inside(cloud, at_age=0.0)
|
|
138
|
+
"""
|
|
139
|
+
mask = self.get_containment_mask(cloud, at_age)
|
|
140
|
+
n_inside = np.sum(mask)
|
|
141
|
+
n_total = cloud.n_points
|
|
142
|
+
|
|
143
|
+
if n_inside == 0:
|
|
144
|
+
warnings.warn(
|
|
145
|
+
f"No points found inside polygons at {at_age} Ma. "
|
|
146
|
+
f"Returning empty PointCloud.",
|
|
147
|
+
UserWarning
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return cloud.subset(mask)
|
|
151
|
+
|
|
152
|
+
def filter_outside(
|
|
153
|
+
self,
|
|
154
|
+
cloud: "PointCloud",
|
|
155
|
+
at_age: float
|
|
156
|
+
) -> "PointCloud":
|
|
157
|
+
"""
|
|
158
|
+
Keep only points outside polygons.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
cloud : PointCloud
|
|
163
|
+
Points to filter.
|
|
164
|
+
at_age : float
|
|
165
|
+
Geological age at which to check containment (Ma).
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
PointCloud
|
|
170
|
+
Points outside polygons.
|
|
171
|
+
|
|
172
|
+
Examples
|
|
173
|
+
--------
|
|
174
|
+
>>> # Remove continental points (keep oceanic) at present day
|
|
175
|
+
>>> oceanic = filter.filter_outside(cloud, at_age=0.0)
|
|
176
|
+
"""
|
|
177
|
+
mask = self.get_containment_mask(cloud, at_age)
|
|
178
|
+
n_outside = np.sum(~mask)
|
|
179
|
+
|
|
180
|
+
if n_outside == 0:
|
|
181
|
+
warnings.warn(
|
|
182
|
+
f"All points are inside polygons at {at_age} Ma. "
|
|
183
|
+
f"Returning empty PointCloud.",
|
|
184
|
+
UserWarning
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return cloud.subset(~mask)
|
|
188
|
+
|
|
189
|
+
def get_statistics(
|
|
190
|
+
self,
|
|
191
|
+
cloud: "PointCloud",
|
|
192
|
+
at_age: float
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Get statistics about polygon containment.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
cloud : PointCloud
|
|
200
|
+
Points to analyze.
|
|
201
|
+
at_age : float
|
|
202
|
+
Geological age at which to check containment (Ma).
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
dict
|
|
207
|
+
Statistics including:
|
|
208
|
+
- total: Total number of points
|
|
209
|
+
- inside: Number of points inside polygons
|
|
210
|
+
- outside: Number of points outside polygons
|
|
211
|
+
- inside_fraction: Fraction of points inside
|
|
212
|
+
"""
|
|
213
|
+
mask = self.get_containment_mask(cloud, at_age)
|
|
214
|
+
n_inside = int(np.sum(mask))
|
|
215
|
+
n_total = cloud.n_points
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
'total': n_total,
|
|
219
|
+
'inside': n_inside,
|
|
220
|
+
'outside': n_total - n_inside,
|
|
221
|
+
'inside_fraction': n_inside / n_total if n_total > 0 else 0.0,
|
|
222
|
+
'at_age': at_age
|
|
223
|
+
}
|
gtrack/spatial.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spatial indexing for efficient point-in-polygon queries.
|
|
3
|
+
|
|
4
|
+
This module provides a quad tree spatial index for efficiently testing
|
|
5
|
+
many points against many polygons on a sphere. The algorithm groups
|
|
6
|
+
nearby points and uses bounding polygon tests to quickly determine
|
|
7
|
+
containment for large groups of points at once.
|
|
8
|
+
|
|
9
|
+
The implementation is based on the algorithm from GPlates Plate Tectonic Tools (ptt),
|
|
10
|
+
originally developed at the University of Sydney.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple, Any
|
|
15
|
+
|
|
16
|
+
import pygplates
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Default subdivision depth for the quad tree.
|
|
20
|
+
# A value of 4 works well for point spacings of ~1 degree or less.
|
|
21
|
+
DEFAULT_SUBDIVISION_DEPTH = 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PointsSpatialTree:
|
|
25
|
+
"""
|
|
26
|
+
Quad tree spatial index for points on a sphere.
|
|
27
|
+
|
|
28
|
+
Groups points into a hierarchical tree structure based on their
|
|
29
|
+
lat/lon coordinates. This enables efficient spatial queries by
|
|
30
|
+
allowing entire groups of points to be included or excluded
|
|
31
|
+
based on their bounding regions.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
points : sequence of pygplates.PointOnSphere
|
|
36
|
+
The points to index.
|
|
37
|
+
subdivision_depth : int, default=4
|
|
38
|
+
Maximum depth of the quad tree. Leaf nodes have lat/lon width
|
|
39
|
+
of 90 / 2^depth degrees. Higher values create finer subdivisions
|
|
40
|
+
but take longer to build.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
points: Sequence[pygplates.PointOnSphere],
|
|
46
|
+
subdivision_depth: int = DEFAULT_SUBDIVISION_DEPTH,
|
|
47
|
+
):
|
|
48
|
+
if subdivision_depth < 0 or subdivision_depth > 100:
|
|
49
|
+
raise ValueError("subdivision_depth must be in range [0, 100]")
|
|
50
|
+
|
|
51
|
+
# 8 root nodes covering the globe (2 lat bands x 4 lon quadrants)
|
|
52
|
+
self._root_nodes: List[Optional[_SpatialTreeNode]] = [None] * 8
|
|
53
|
+
|
|
54
|
+
# Insert each point into the tree
|
|
55
|
+
for point_index, point in enumerate(points):
|
|
56
|
+
lat, lon = point.to_lat_lon()
|
|
57
|
+
|
|
58
|
+
# Determine which root node this point belongs to
|
|
59
|
+
lat_idx = 1 if lat >= 0 else 0
|
|
60
|
+
if lon < -90:
|
|
61
|
+
lon_idx = 0
|
|
62
|
+
elif lon < 0:
|
|
63
|
+
lon_idx = 1
|
|
64
|
+
elif lon < 90:
|
|
65
|
+
lon_idx = 2
|
|
66
|
+
else:
|
|
67
|
+
lon_idx = 3
|
|
68
|
+
|
|
69
|
+
root_idx = 4 * lat_idx + lon_idx
|
|
70
|
+
is_north = lat_idx == 1
|
|
71
|
+
|
|
72
|
+
# Create root node if needed
|
|
73
|
+
if self._root_nodes[root_idx] is None:
|
|
74
|
+
centre_lon = -180 + 90 * lon_idx + 45
|
|
75
|
+
centre_lat = -90 + 90 * lat_idx + 45
|
|
76
|
+
self._root_nodes[root_idx] = _SpatialTreeNode(
|
|
77
|
+
centre_lon, centre_lat, 45.0, is_north
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Traverse down to the appropriate leaf node
|
|
81
|
+
node = self._root_nodes[root_idx]
|
|
82
|
+
node_centre_lon = -180 + 90 * lon_idx + 45
|
|
83
|
+
node_centre_lat = -90 + 90 * lat_idx + 45
|
|
84
|
+
node_half_width = 45.0
|
|
85
|
+
|
|
86
|
+
for _ in range(subdivision_depth):
|
|
87
|
+
child_half_width = node_half_width / 2
|
|
88
|
+
|
|
89
|
+
if lat < node_centre_lat:
|
|
90
|
+
child_lat_idx = 0
|
|
91
|
+
child_centre_lat = node_centre_lat - child_half_width
|
|
92
|
+
else:
|
|
93
|
+
child_lat_idx = 1
|
|
94
|
+
child_centre_lat = node_centre_lat + child_half_width
|
|
95
|
+
|
|
96
|
+
if lon < node_centre_lon:
|
|
97
|
+
child_lon_idx = 0
|
|
98
|
+
child_centre_lon = node_centre_lon - child_half_width
|
|
99
|
+
else:
|
|
100
|
+
child_lon_idx = 1
|
|
101
|
+
child_centre_lon = node_centre_lon + child_half_width
|
|
102
|
+
|
|
103
|
+
# Create child nodes list if needed
|
|
104
|
+
if node._children is None:
|
|
105
|
+
node._children = [None] * 4
|
|
106
|
+
|
|
107
|
+
child_idx = 2 * child_lat_idx + child_lon_idx
|
|
108
|
+
if node._children[child_idx] is None:
|
|
109
|
+
node._children[child_idx] = _SpatialTreeNode(
|
|
110
|
+
child_centre_lon, child_centre_lat, child_half_width, is_north
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
node = node._children[child_idx]
|
|
114
|
+
node_centre_lon = child_centre_lon
|
|
115
|
+
node_centre_lat = child_centre_lat
|
|
116
|
+
node_half_width = child_half_width
|
|
117
|
+
|
|
118
|
+
# Add point index to leaf node
|
|
119
|
+
if node._point_indices is None:
|
|
120
|
+
node._point_indices = []
|
|
121
|
+
node._point_indices.append(point_index)
|
|
122
|
+
|
|
123
|
+
def get_root_nodes(self) -> List["_SpatialTreeNode"]:
|
|
124
|
+
"""Get all non-empty root nodes."""
|
|
125
|
+
return [n for n in self._root_nodes if n is not None]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _SpatialTreeNode:
|
|
129
|
+
"""
|
|
130
|
+
A node in the spatial tree representing a lat/lon region.
|
|
131
|
+
|
|
132
|
+
Internal nodes have children; leaf nodes have point indices.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
centre_lon: float,
|
|
138
|
+
centre_lat: float,
|
|
139
|
+
half_width: float,
|
|
140
|
+
is_north: bool,
|
|
141
|
+
):
|
|
142
|
+
self._centre_lon = centre_lon
|
|
143
|
+
self._centre_lat = centre_lat
|
|
144
|
+
self._half_width = half_width
|
|
145
|
+
self._is_north = is_north
|
|
146
|
+
|
|
147
|
+
self._children: Optional[List[Optional["_SpatialTreeNode"]]] = None
|
|
148
|
+
self._point_indices: Optional[List[int]] = None
|
|
149
|
+
self._bounding_polygon: Optional[pygplates.PolygonOnSphere] = None
|
|
150
|
+
|
|
151
|
+
def is_internal(self) -> bool:
|
|
152
|
+
"""True if this is an internal node with children."""
|
|
153
|
+
return self._children is not None
|
|
154
|
+
|
|
155
|
+
def is_leaf(self) -> bool:
|
|
156
|
+
"""True if this is a leaf node with point indices."""
|
|
157
|
+
return self._point_indices is not None
|
|
158
|
+
|
|
159
|
+
def get_children(self) -> List["_SpatialTreeNode"]:
|
|
160
|
+
"""Get non-empty child nodes."""
|
|
161
|
+
if self._children is None:
|
|
162
|
+
return []
|
|
163
|
+
return [c for c in self._children if c is not None]
|
|
164
|
+
|
|
165
|
+
def get_point_indices(self) -> List[int]:
|
|
166
|
+
"""Get point indices in this leaf node."""
|
|
167
|
+
return self._point_indices if self._point_indices else []
|
|
168
|
+
|
|
169
|
+
def get_bounding_polygon(self) -> pygplates.PolygonOnSphere:
|
|
170
|
+
"""Get polygon bounding this node's region."""
|
|
171
|
+
if self._bounding_polygon is None:
|
|
172
|
+
self._bounding_polygon = self._create_bounding_polygon()
|
|
173
|
+
return self._bounding_polygon
|
|
174
|
+
|
|
175
|
+
def _create_bounding_polygon(self) -> pygplates.PolygonOnSphere:
|
|
176
|
+
"""Create a polygon that bounds this node's lat/lon region."""
|
|
177
|
+
left_lon = self._centre_lon - self._half_width
|
|
178
|
+
right_lon = self._centre_lon + self._half_width
|
|
179
|
+
bottom_lat = self._centre_lat - self._half_width
|
|
180
|
+
top_lat = self._centre_lat + self._half_width
|
|
181
|
+
|
|
182
|
+
points = []
|
|
183
|
+
|
|
184
|
+
if self._is_north:
|
|
185
|
+
# Northern hemisphere - need to handle bottom edge carefully
|
|
186
|
+
left_line = pygplates.PolylineOnSphere([(0, left_lon), (90, left_lon)])
|
|
187
|
+
right_line = pygplates.PolylineOnSphere([(0, right_lon), (90, right_lon)])
|
|
188
|
+
|
|
189
|
+
bottom_mid = pygplates.PointOnSphere(bottom_lat, (left_lon + right_lon) / 2)
|
|
190
|
+
|
|
191
|
+
# Great circle through bottom midpoint oriented toward pole
|
|
192
|
+
axis = pygplates.Vector3D.cross(
|
|
193
|
+
bottom_mid.to_xyz(),
|
|
194
|
+
pygplates.Vector3D.cross(
|
|
195
|
+
pygplates.PointOnSphere.north_pole.to_xyz(),
|
|
196
|
+
bottom_mid.to_xyz(),
|
|
197
|
+
),
|
|
198
|
+
).to_normalised()
|
|
199
|
+
rotation = pygplates.FiniteRotation(axis.to_xyz(), math.pi / 2)
|
|
200
|
+
|
|
201
|
+
bottom_line = pygplates.PolylineOnSphere([
|
|
202
|
+
rotation * bottom_mid,
|
|
203
|
+
bottom_mid,
|
|
204
|
+
rotation.get_inverse() * bottom_mid,
|
|
205
|
+
])
|
|
206
|
+
|
|
207
|
+
_, _, bl = pygplates.GeometryOnSphere.distance(
|
|
208
|
+
bottom_line, left_line, return_closest_positions=True
|
|
209
|
+
)
|
|
210
|
+
_, _, br = pygplates.GeometryOnSphere.distance(
|
|
211
|
+
bottom_line, right_line, return_closest_positions=True
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
points = [bl, br,
|
|
215
|
+
pygplates.PointOnSphere(top_lat, right_lon),
|
|
216
|
+
pygplates.PointOnSphere(top_lat, left_lon)]
|
|
217
|
+
else:
|
|
218
|
+
# Southern hemisphere - need to handle top edge carefully
|
|
219
|
+
left_line = pygplates.PolylineOnSphere([(0, left_lon), (-90, left_lon)])
|
|
220
|
+
right_line = pygplates.PolylineOnSphere([(0, right_lon), (-90, right_lon)])
|
|
221
|
+
|
|
222
|
+
top_mid = pygplates.PointOnSphere(top_lat, (left_lon + right_lon) / 2)
|
|
223
|
+
|
|
224
|
+
axis = pygplates.Vector3D.cross(
|
|
225
|
+
top_mid.to_xyz(),
|
|
226
|
+
pygplates.Vector3D.cross(
|
|
227
|
+
pygplates.PointOnSphere.north_pole.to_xyz(),
|
|
228
|
+
top_mid.to_xyz(),
|
|
229
|
+
),
|
|
230
|
+
).to_normalised()
|
|
231
|
+
rotation = pygplates.FiniteRotation(axis.to_xyz(), math.pi / 2)
|
|
232
|
+
|
|
233
|
+
top_line = pygplates.PolylineOnSphere([
|
|
234
|
+
rotation * top_mid,
|
|
235
|
+
top_mid,
|
|
236
|
+
rotation.get_inverse() * top_mid,
|
|
237
|
+
])
|
|
238
|
+
|
|
239
|
+
_, _, tl = pygplates.GeometryOnSphere.distance(
|
|
240
|
+
top_line, left_line, return_closest_positions=True
|
|
241
|
+
)
|
|
242
|
+
_, _, tr = pygplates.GeometryOnSphere.distance(
|
|
243
|
+
top_line, right_line, return_closest_positions=True
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
points = [tl, tr,
|
|
247
|
+
pygplates.PointOnSphere(bottom_lat, right_lon),
|
|
248
|
+
pygplates.PointOnSphere(bottom_lat, left_lon)]
|
|
249
|
+
|
|
250
|
+
return pygplates.PolygonOnSphere(points)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def find_polygons(
|
|
254
|
+
points: Sequence[pygplates.PointOnSphere],
|
|
255
|
+
polygons: Sequence[pygplates.PolygonOnSphere],
|
|
256
|
+
polygon_proxies: Optional[Sequence[Any]] = None,
|
|
257
|
+
all_polygons: bool = False,
|
|
258
|
+
subdivision_depth: int = DEFAULT_SUBDIVISION_DEPTH,
|
|
259
|
+
) -> List:
|
|
260
|
+
"""
|
|
261
|
+
Find which polygon(s) contain each point.
|
|
262
|
+
|
|
263
|
+
Uses a quad tree spatial index for efficient batch queries.
|
|
264
|
+
For uniformly distributed points, this is ~70x faster than
|
|
265
|
+
naive point-by-point testing.
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
points : sequence of pygplates.PointOnSphere
|
|
270
|
+
Points to test.
|
|
271
|
+
polygons : sequence of pygplates.PolygonOnSphere
|
|
272
|
+
Polygons to test against.
|
|
273
|
+
polygon_proxies : sequence, optional
|
|
274
|
+
Objects to return instead of polygons (e.g., features).
|
|
275
|
+
If None, returns the polygons themselves.
|
|
276
|
+
all_polygons : bool, default=False
|
|
277
|
+
If True, find all polygons containing each point (for overlapping polygons).
|
|
278
|
+
If False, find only the first polygon (for non-overlapping polygons).
|
|
279
|
+
subdivision_depth : int, default=4
|
|
280
|
+
Quad tree subdivision depth.
|
|
281
|
+
|
|
282
|
+
Returns
|
|
283
|
+
-------
|
|
284
|
+
list
|
|
285
|
+
For each point: the containing polygon proxy (or None), or
|
|
286
|
+
if all_polygons=True, a list of containing polygon proxies (or None).
|
|
287
|
+
"""
|
|
288
|
+
if polygon_proxies is None:
|
|
289
|
+
polygon_proxies = polygons
|
|
290
|
+
|
|
291
|
+
if len(polygons) != len(polygon_proxies):
|
|
292
|
+
raise ValueError("polygons and polygon_proxies must have same length")
|
|
293
|
+
|
|
294
|
+
# Build spatial tree
|
|
295
|
+
tree = PointsSpatialTree(points, subdivision_depth)
|
|
296
|
+
|
|
297
|
+
# Sort polygons by area (largest first) for efficiency
|
|
298
|
+
sorted_pairs = sorted(
|
|
299
|
+
zip(polygons, polygon_proxies),
|
|
300
|
+
key=lambda p: p[0].get_area(),
|
|
301
|
+
reverse=True,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Initialize results
|
|
305
|
+
results: List = [None] * len(points)
|
|
306
|
+
|
|
307
|
+
# Process each root node
|
|
308
|
+
for root in tree.get_root_nodes():
|
|
309
|
+
_process_node(root, points, sorted_pairs, results, all_polygons)
|
|
310
|
+
|
|
311
|
+
return results
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _process_node(
|
|
315
|
+
node: _SpatialTreeNode,
|
|
316
|
+
points: Sequence[pygplates.PointOnSphere],
|
|
317
|
+
polygons_and_proxies: List[Tuple[pygplates.PolygonOnSphere, Any]],
|
|
318
|
+
results: List,
|
|
319
|
+
all_polygons: bool,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Recursively process a spatial tree node."""
|
|
322
|
+
# Find polygons that overlap this node
|
|
323
|
+
overlapping = []
|
|
324
|
+
node_poly = node.get_bounding_polygon()
|
|
325
|
+
|
|
326
|
+
for polygon, proxy in polygons_and_proxies:
|
|
327
|
+
# Check if node and polygon overlap (distance < threshold means overlap)
|
|
328
|
+
dist = pygplates.GeometryOnSphere.distance(
|
|
329
|
+
node_poly, polygon, 1e-4,
|
|
330
|
+
geometry1_is_solid=True, geometry2_is_solid=True
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if dist is not None:
|
|
334
|
+
# Check if node is completely inside polygon
|
|
335
|
+
outline_dist = pygplates.GeometryOnSphere.distance(
|
|
336
|
+
node_poly, polygon, 1e-4
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
completely_inside = False
|
|
340
|
+
if outline_dist is None:
|
|
341
|
+
# Outlines don't touch - check if node is inside polygon
|
|
342
|
+
if polygon.is_point_in_polygon(node_poly.get_exterior_ring_points()[0]):
|
|
343
|
+
completely_inside = True
|
|
344
|
+
# Check for interior holes that might be inside node
|
|
345
|
+
for i in range(polygon.get_number_of_interior_rings()):
|
|
346
|
+
hole_pt = polygon.get_interior_ring_points(i)[0]
|
|
347
|
+
if node_poly.is_point_in_polygon(hole_pt):
|
|
348
|
+
completely_inside = False
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
if completely_inside:
|
|
352
|
+
# Fill entire subtree as inside this polygon
|
|
353
|
+
_fill_node(node, proxy, results, all_polygons)
|
|
354
|
+
if not all_polygons:
|
|
355
|
+
return
|
|
356
|
+
else:
|
|
357
|
+
overlapping.append((polygon, proxy))
|
|
358
|
+
|
|
359
|
+
if not overlapping:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Process children or test individual points
|
|
363
|
+
if node.is_internal():
|
|
364
|
+
for child in node.get_children():
|
|
365
|
+
_process_node(child, points, overlapping, results, all_polygons)
|
|
366
|
+
else:
|
|
367
|
+
for idx in node.get_point_indices():
|
|
368
|
+
point = points[idx]
|
|
369
|
+
for polygon, proxy in overlapping:
|
|
370
|
+
if polygon.is_point_in_polygon(point):
|
|
371
|
+
if all_polygons:
|
|
372
|
+
if results[idx] is None:
|
|
373
|
+
results[idx] = []
|
|
374
|
+
results[idx].append(proxy)
|
|
375
|
+
else:
|
|
376
|
+
results[idx] = proxy
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _fill_node(
|
|
381
|
+
node: _SpatialTreeNode,
|
|
382
|
+
proxy: Any,
|
|
383
|
+
results: List,
|
|
384
|
+
all_polygons: bool,
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Mark all points in a node's subtree as inside a polygon."""
|
|
387
|
+
if node.is_internal():
|
|
388
|
+
for child in node.get_children():
|
|
389
|
+
_fill_node(child, proxy, results, all_polygons)
|
|
390
|
+
else:
|
|
391
|
+
for idx in node.get_point_indices():
|
|
392
|
+
if all_polygons:
|
|
393
|
+
if results[idx] is None:
|
|
394
|
+
results[idx] = []
|
|
395
|
+
results[idx].append(proxy)
|
|
396
|
+
else:
|
|
397
|
+
results[idx] = proxy
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gtrack
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: GPlates-based Tracking of Lithosphere and Kinematics
|
|
5
|
+
Author: S. Ghelichkhani
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sghelichkhani/gtrack
|
|
8
|
+
Project-URL: Documentation, https://gtrack.gadopt.org
|
|
9
|
+
Project-URL: Repository, https://github.com/sghelichkhani/gtrack
|
|
10
|
+
Keywords: plate tectonics,seafloor age,geodynamics,pygplates,lithosphere,kinematics
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: numpy>=1.20
|
|
23
|
+
Requires-Dist: scipy>=1.7
|
|
24
|
+
Requires-Dist: pygplates
|
|
25
|
+
Provides-Extra: visualization
|
|
26
|
+
Requires-Dist: matplotlib>=3.5; extra == "visualization"
|
|
27
|
+
Requires-Dist: cartopy>=0.21; extra == "visualization"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=3.0; extra == "dev"
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: mkdocs>=1.5; extra == "docs"
|
|
33
|
+
Requires-Dist: mkdocs-material>=9.0; extra == "docs"
|
|
34
|
+
Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
|
|
35
|
+
Requires-Dist: mkdocs-jupyter>=0.24; extra == "docs"
|
|
36
|
+
Provides-Extra: demos
|
|
37
|
+
Requires-Dist: jupytext>=1.15; extra == "demos"
|
|
38
|
+
Requires-Dist: nbconvert>=7.0; extra == "demos"
|
|
39
|
+
Requires-Dist: ipykernel>=6.0; extra == "demos"
|
|
40
|
+
Requires-Dist: matplotlib>=3.5; extra == "demos"
|
|
41
|
+
Requires-Dist: cartopy>=0.21; extra == "demos"
|
|
42
|
+
Requires-Dist: h5py>=3.0; extra == "demos"
|
|
43
|
+
Provides-Extra: all
|
|
44
|
+
Requires-Dist: gtrack[demos,dev,docs,visualization]; extra == "all"
|
|
45
|
+
|
|
46
|
+
# gtrack
|
|
47
|
+
|
|
48
|
+
**GPlates-based Tracking of Lithosphere and Kinematics**
|
|
49
|
+
|
|
50
|
+
A Python package for computing lithospheric structure through geological time using plate tectonic reconstructions.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install gtrack
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Documentation
|
|
59
|
+
|
|
60
|
+
For full documentation, examples, and API reference, visit:
|
|
61
|
+
|
|
62
|
+
**[https://gtrack.gadopt.org](https://gtrack.gadopt.org)**
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT License
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
gtrack/__init__.py,sha256=HPCvSZ2YTM0uhaRoMT9LfFwxg4vpMyaQDipd_49YX8I,3618
|
|
2
|
+
gtrack/boundaries.py,sha256=6jF2omFc-YSUXc3_nh3UT6dl-iCCjNT7GWeiID3h6Zs,11581
|
|
3
|
+
gtrack/config.py,sha256=V3qeBvhPFiyeZVEvCnli7eSzljtV3ZnJ2yh02V5v860,7131
|
|
4
|
+
gtrack/geometry.py,sha256=P90FeKFbQTOjT63RI-uc13Wa2YmeVhqWWPKMdYOAKLU,10034
|
|
5
|
+
gtrack/hpc_integration.py,sha256=rcQAINQtCI0VjpJ8FB8JbkLhKuyEdOaJRZoCYm2HKms,29675
|
|
6
|
+
gtrack/initial_conditions.py,sha256=bTgVYBoIWgOq8KFjOUPMwj_P11cSnlkDXi6UrzrXh0U,9185
|
|
7
|
+
gtrack/io_formats.py,sha256=_zXOdn7rqlcD0fORNQmh8hemYKzABCax1X1W-WVGpiE,12769
|
|
8
|
+
gtrack/logging.py,sha256=6N8QivpVq0oahwi0K62zUgQici-rK40V9CEOPDSpwp0,4930
|
|
9
|
+
gtrack/mesh.py,sha256=BCgGWXLnflK_aiD9B6GBY0X_OJur645Sc1lVX6O4Q6Q,2733
|
|
10
|
+
gtrack/mor_seeds.py,sha256=mFF89nKOq7yIEOIE41AnNTJdbQ3Q5OLYGnAu6KbeXg8,13395
|
|
11
|
+
gtrack/point_rotation.py,sha256=FPmOZWKhaySsTZKTWlXIVPEtmcXjX-ufsDM0NPbg9gU,26304
|
|
12
|
+
gtrack/polygon_filter.py,sha256=5wDChgkxF7IU7of2JfkAWVbzxOHSgnuIQsg-eA0Yyoo,6333
|
|
13
|
+
gtrack/spatial.py,sha256=70UXaH1TyaQDR1P_1j2FoCdUTTa8a921h7Wt6kj1hZw,14475
|
|
14
|
+
gtrack-0.3.0.dist-info/METADATA,sha256=S5sBdsfGDbJZBO4ludNyDFO5tQVoIb1TexYwIGuPjGI,2268
|
|
15
|
+
gtrack-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
16
|
+
gtrack-0.3.0.dist-info/top_level.txt,sha256=x1GottZL9pxdL8Ff3ARhF6tAhM4-CIKgnS9Hl7PZzyQ,7
|
|
17
|
+
gtrack-0.3.0.dist-info/RECORD,,
|