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/point_rotation.py
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Point rotation API for rotating user-provided points through geological time.
|
|
3
|
+
|
|
4
|
+
This module provides the core API for:
|
|
5
|
+
- Managing collections of points with associated properties (PointCloud)
|
|
6
|
+
- Rotating points between geological ages using plate reconstructions (PointRotator)
|
|
7
|
+
|
|
8
|
+
The API uses Cartesian XYZ coordinates internally for compatibility with gadopt,
|
|
9
|
+
while interfacing with pygplates using lat/lon coordinates.
|
|
10
|
+
|
|
11
|
+
Terminology:
|
|
12
|
+
- geological_age (or just 'age'): Time before present in Ma (0 = present, 100 = 100 Myr ago)
|
|
13
|
+
- from_age / to_age: Source and target geological ages for rotation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pygplates
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PointCloud:
|
|
26
|
+
"""
|
|
27
|
+
Container for points with associated properties.
|
|
28
|
+
|
|
29
|
+
Stores points in Cartesian XYZ format internally (matches gadopt).
|
|
30
|
+
Properties (lithospheric_depth, etc.) are stored separately from positions.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
xyz : np.ndarray
|
|
35
|
+
Cartesian coordinates, shape (N, 3), in meters.
|
|
36
|
+
Points should lie on Earth's surface (radius ~6.3781e6 m).
|
|
37
|
+
properties : dict, optional
|
|
38
|
+
Dictionary mapping property names to arrays of shape (N,).
|
|
39
|
+
Properties are preserved during rotation operations.
|
|
40
|
+
plate_ids : np.ndarray, optional
|
|
41
|
+
Plate IDs for each point, shape (N,). Required for rotation.
|
|
42
|
+
|
|
43
|
+
Examples
|
|
44
|
+
--------
|
|
45
|
+
>>> xyz = np.random.randn(1000, 3)
|
|
46
|
+
>>> from gtrack.geometry import normalize_to_sphere
|
|
47
|
+
>>> xyz = normalize_to_sphere(xyz) # Project to Earth's surface
|
|
48
|
+
>>> cloud = PointCloud(xyz=xyz)
|
|
49
|
+
>>> cloud.add_property('lithospheric_depth', np.random.rand(1000) * 100e3)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
xyz: np.ndarray
|
|
53
|
+
properties: Dict[str, np.ndarray] = field(default_factory=dict)
|
|
54
|
+
plate_ids: Optional[np.ndarray] = None
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
"""Validate inputs after initialization."""
|
|
58
|
+
# Ensure xyz is a numpy array
|
|
59
|
+
self.xyz = np.asarray(self.xyz)
|
|
60
|
+
|
|
61
|
+
if self.xyz.ndim != 2 or self.xyz.shape[1] != 3:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"xyz must have shape (N, 3), got {self.xyz.shape}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
n_points = len(self.xyz)
|
|
67
|
+
|
|
68
|
+
# Validate properties
|
|
69
|
+
for name, prop in self.properties.items():
|
|
70
|
+
prop = np.asarray(prop)
|
|
71
|
+
if len(prop) != n_points:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Property '{name}' has {len(prop)} values, expected {n_points}"
|
|
74
|
+
)
|
|
75
|
+
self.properties[name] = prop
|
|
76
|
+
|
|
77
|
+
# Validate plate_ids if provided
|
|
78
|
+
if self.plate_ids is not None:
|
|
79
|
+
self.plate_ids = np.asarray(self.plate_ids)
|
|
80
|
+
if len(self.plate_ids) != n_points:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"plate_ids has {len(self.plate_ids)} values, expected {n_points}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def n_points(self) -> int:
|
|
87
|
+
"""Number of points in the cloud."""
|
|
88
|
+
return len(self.xyz)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def latlon(self) -> np.ndarray:
|
|
92
|
+
"""
|
|
93
|
+
Get lat/lon coordinates (computed from XYZ).
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
np.ndarray
|
|
98
|
+
Array of shape (N, 2) with [lat, lon] in degrees.
|
|
99
|
+
Latitude: -90 to 90, Longitude: -180 to 180.
|
|
100
|
+
"""
|
|
101
|
+
from .geometry import XYZ2LatLon
|
|
102
|
+
lats, lons = XYZ2LatLon(self.xyz)
|
|
103
|
+
return np.column_stack([lats, lons])
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def lonlat(self) -> np.ndarray:
|
|
107
|
+
"""
|
|
108
|
+
Get lon/lat coordinates (computed from XYZ).
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
np.ndarray
|
|
113
|
+
Array of shape (N, 2) with [lon, lat] in degrees.
|
|
114
|
+
Longitude: -180 to 180, Latitude: -90 to 90.
|
|
115
|
+
"""
|
|
116
|
+
from .geometry import XYZ2LatLon
|
|
117
|
+
lats, lons = XYZ2LatLon(self.xyz)
|
|
118
|
+
return np.column_stack([lons, lats])
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_latlon(
|
|
122
|
+
cls,
|
|
123
|
+
latlon: np.ndarray,
|
|
124
|
+
properties: Optional[Dict[str, np.ndarray]] = None
|
|
125
|
+
) -> "PointCloud":
|
|
126
|
+
"""
|
|
127
|
+
Create PointCloud from lat/lon coordinates.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
latlon : np.ndarray
|
|
132
|
+
Coordinates, shape (N, 2) with [lat, lon] in degrees.
|
|
133
|
+
properties : dict, optional
|
|
134
|
+
Properties to attach to the points.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
PointCloud
|
|
139
|
+
New PointCloud with XYZ coordinates computed from lat/lon.
|
|
140
|
+
|
|
141
|
+
Examples
|
|
142
|
+
--------
|
|
143
|
+
>>> latlon = np.array([[45.0, -120.0], [30.0, 90.0]])
|
|
144
|
+
>>> cloud = PointCloud.from_latlon(latlon)
|
|
145
|
+
"""
|
|
146
|
+
from .geometry import LatLon2XYZ
|
|
147
|
+
latlon = np.asarray(latlon)
|
|
148
|
+
xyz = LatLon2XYZ(latlon)
|
|
149
|
+
return cls(xyz=xyz, properties=properties or {})
|
|
150
|
+
|
|
151
|
+
def add_property(self, name: str, values: np.ndarray) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Add or update a property.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
name : str
|
|
158
|
+
Name of the property.
|
|
159
|
+
values : np.ndarray
|
|
160
|
+
Property values, shape (N,).
|
|
161
|
+
|
|
162
|
+
Raises
|
|
163
|
+
------
|
|
164
|
+
ValueError
|
|
165
|
+
If values length doesn't match number of points.
|
|
166
|
+
"""
|
|
167
|
+
values = np.asarray(values)
|
|
168
|
+
if len(values) != self.n_points:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Property '{name}' has {len(values)} values, expected {self.n_points}"
|
|
171
|
+
)
|
|
172
|
+
self.properties[name] = values
|
|
173
|
+
|
|
174
|
+
def remove_property(self, name: str) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Remove a property.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
name : str
|
|
181
|
+
Name of the property to remove.
|
|
182
|
+
"""
|
|
183
|
+
if name in self.properties:
|
|
184
|
+
del self.properties[name]
|
|
185
|
+
|
|
186
|
+
def get_property(self, name: str) -> np.ndarray:
|
|
187
|
+
"""
|
|
188
|
+
Get a property by name.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
name : str
|
|
193
|
+
Name of the property.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
np.ndarray
|
|
198
|
+
Property values.
|
|
199
|
+
|
|
200
|
+
Raises
|
|
201
|
+
------
|
|
202
|
+
KeyError
|
|
203
|
+
If property not found.
|
|
204
|
+
"""
|
|
205
|
+
if name not in self.properties:
|
|
206
|
+
raise KeyError(f"Property '{name}' not found")
|
|
207
|
+
return self.properties[name]
|
|
208
|
+
|
|
209
|
+
def subset(self, mask: np.ndarray) -> "PointCloud":
|
|
210
|
+
"""
|
|
211
|
+
Create subset of points using boolean mask.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
mask : np.ndarray
|
|
216
|
+
Boolean mask, shape (N,). True values are kept.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
PointCloud
|
|
221
|
+
New PointCloud with subset of points.
|
|
222
|
+
"""
|
|
223
|
+
mask = np.asarray(mask, dtype=bool)
|
|
224
|
+
new_xyz = self.xyz[mask]
|
|
225
|
+
new_properties = {
|
|
226
|
+
name: prop[mask] for name, prop in self.properties.items()
|
|
227
|
+
}
|
|
228
|
+
new_plate_ids = self.plate_ids[mask] if self.plate_ids is not None else None
|
|
229
|
+
return PointCloud(
|
|
230
|
+
xyz=new_xyz,
|
|
231
|
+
properties=new_properties,
|
|
232
|
+
plate_ids=new_plate_ids
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def copy(self) -> "PointCloud":
|
|
236
|
+
"""
|
|
237
|
+
Create a deep copy.
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
PointCloud
|
|
242
|
+
Deep copy of this PointCloud.
|
|
243
|
+
"""
|
|
244
|
+
return PointCloud(
|
|
245
|
+
xyz=self.xyz.copy(),
|
|
246
|
+
properties={k: v.copy() for k, v in self.properties.items()},
|
|
247
|
+
plate_ids=self.plate_ids.copy() if self.plate_ids is not None else None
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def concatenate(cls, clouds: List["PointCloud"], warn: bool = True) -> "PointCloud":
|
|
252
|
+
"""
|
|
253
|
+
Merge multiple PointClouds into a single PointCloud.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
clouds : list of PointCloud
|
|
258
|
+
List of PointCloud instances to merge.
|
|
259
|
+
warn : bool, default=True
|
|
260
|
+
Whether to emit warnings when properties or plate_ids are
|
|
261
|
+
dropped due to inconsistency between clouds. Set to False
|
|
262
|
+
when this is expected behavior.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
PointCloud
|
|
267
|
+
New PointCloud with concatenated data from all input clouds.
|
|
268
|
+
|
|
269
|
+
Raises
|
|
270
|
+
------
|
|
271
|
+
ValueError
|
|
272
|
+
If clouds list is empty.
|
|
273
|
+
TypeError
|
|
274
|
+
If any element is not a PointCloud.
|
|
275
|
+
|
|
276
|
+
Notes
|
|
277
|
+
-----
|
|
278
|
+
- The xyz arrays are vertically stacked.
|
|
279
|
+
- Properties are merged: only properties present in ALL clouds are included
|
|
280
|
+
in the result. If clouds have different property sets, a warning is issued
|
|
281
|
+
and only the common properties are kept.
|
|
282
|
+
- plate_ids are concatenated only if ALL clouds have them; otherwise
|
|
283
|
+
the result has plate_ids=None.
|
|
284
|
+
|
|
285
|
+
Examples
|
|
286
|
+
--------
|
|
287
|
+
>>> cloud1 = PointCloud(xyz=np.random.randn(100, 3))
|
|
288
|
+
>>> cloud1.add_property('temperature', np.random.rand(100))
|
|
289
|
+
>>> cloud2 = PointCloud(xyz=np.random.randn(50, 3))
|
|
290
|
+
>>> cloud2.add_property('temperature', np.random.rand(50))
|
|
291
|
+
>>> merged = PointCloud.concatenate([cloud1, cloud2])
|
|
292
|
+
>>> merged.n_points
|
|
293
|
+
150
|
|
294
|
+
"""
|
|
295
|
+
# Validate input
|
|
296
|
+
if not clouds:
|
|
297
|
+
raise ValueError("Cannot concatenate empty list of PointClouds")
|
|
298
|
+
|
|
299
|
+
if len(clouds) == 1:
|
|
300
|
+
return clouds[0].copy()
|
|
301
|
+
|
|
302
|
+
# Type check
|
|
303
|
+
for i, cloud in enumerate(clouds):
|
|
304
|
+
if not isinstance(cloud, cls):
|
|
305
|
+
raise TypeError(
|
|
306
|
+
f"Element {i} is {type(cloud).__name__}, expected PointCloud"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Concatenate xyz arrays
|
|
310
|
+
xyz_arrays = [cloud.xyz for cloud in clouds]
|
|
311
|
+
merged_xyz = np.vstack(xyz_arrays)
|
|
312
|
+
|
|
313
|
+
# Find common properties across all clouds
|
|
314
|
+
all_property_sets = [set(cloud.properties.keys()) for cloud in clouds]
|
|
315
|
+
common_properties = set.intersection(*all_property_sets) if all_property_sets else set()
|
|
316
|
+
|
|
317
|
+
# Check if any properties are being dropped
|
|
318
|
+
all_properties = set.union(*all_property_sets) if all_property_sets else set()
|
|
319
|
+
dropped_properties = all_properties - common_properties
|
|
320
|
+
|
|
321
|
+
if dropped_properties and warn:
|
|
322
|
+
warnings.warn(
|
|
323
|
+
f"Properties {dropped_properties} are not present in all clouds "
|
|
324
|
+
f"and will be excluded from the merged result. "
|
|
325
|
+
f"Only common properties {common_properties} are included.",
|
|
326
|
+
UserWarning
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Merge common properties
|
|
330
|
+
merged_properties = {}
|
|
331
|
+
for prop_name in common_properties:
|
|
332
|
+
prop_arrays = [cloud.properties[prop_name] for cloud in clouds]
|
|
333
|
+
merged_properties[prop_name] = np.concatenate(prop_arrays)
|
|
334
|
+
|
|
335
|
+
# Handle plate_ids: only include if ALL clouds have them
|
|
336
|
+
all_have_plate_ids = all(cloud.plate_ids is not None for cloud in clouds)
|
|
337
|
+
if all_have_plate_ids:
|
|
338
|
+
plate_id_arrays = [cloud.plate_ids for cloud in clouds]
|
|
339
|
+
merged_plate_ids = np.concatenate(plate_id_arrays)
|
|
340
|
+
else:
|
|
341
|
+
merged_plate_ids = None
|
|
342
|
+
# Warn if some but not all have plate_ids
|
|
343
|
+
some_have_plate_ids = any(cloud.plate_ids is not None for cloud in clouds)
|
|
344
|
+
if some_have_plate_ids and warn:
|
|
345
|
+
warnings.warn(
|
|
346
|
+
"Some clouds have plate_ids and some do not. "
|
|
347
|
+
"The merged cloud will have plate_ids=None.",
|
|
348
|
+
UserWarning
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return cls(
|
|
352
|
+
xyz=merged_xyz,
|
|
353
|
+
properties=merged_properties,
|
|
354
|
+
plate_ids=merged_plate_ids
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def __len__(self) -> int:
|
|
358
|
+
"""Return number of points."""
|
|
359
|
+
return self.n_points
|
|
360
|
+
|
|
361
|
+
def __repr__(self) -> str:
|
|
362
|
+
"""String representation."""
|
|
363
|
+
props = list(self.properties.keys())
|
|
364
|
+
has_plate_ids = self.plate_ids is not None
|
|
365
|
+
return (
|
|
366
|
+
f"PointCloud(n_points={self.n_points}, "
|
|
367
|
+
f"properties={props}, "
|
|
368
|
+
f"has_plate_ids={has_plate_ids})"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# =============================================================================
|
|
373
|
+
# Helper functions for plate operations
|
|
374
|
+
# =============================================================================
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _get_plate_ids(
|
|
378
|
+
xyz: np.ndarray,
|
|
379
|
+
partitioning_features,
|
|
380
|
+
rotation_model: pygplates.RotationModel,
|
|
381
|
+
time: float
|
|
382
|
+
) -> np.ndarray:
|
|
383
|
+
"""
|
|
384
|
+
Assign plate IDs to points based on their positions.
|
|
385
|
+
|
|
386
|
+
Uses pygplates.partition_into_plates for efficient bulk plate ID assignment.
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
xyz : np.ndarray
|
|
391
|
+
Cartesian coordinates, shape (N, 3).
|
|
392
|
+
partitioning_features : pygplates.FeatureCollection
|
|
393
|
+
Static polygons or topology features for partitioning.
|
|
394
|
+
rotation_model : pygplates.RotationModel
|
|
395
|
+
Rotation model.
|
|
396
|
+
time : float
|
|
397
|
+
Reconstruction time in Ma.
|
|
398
|
+
|
|
399
|
+
Returns
|
|
400
|
+
-------
|
|
401
|
+
plate_ids : np.ndarray
|
|
402
|
+
Array of plate IDs, shape (N,). Unassigned points get plate_id=0.
|
|
403
|
+
"""
|
|
404
|
+
from .geometry import XYZ2LatLon
|
|
405
|
+
|
|
406
|
+
# Convert XYZ to lat/lon
|
|
407
|
+
lats, lons = XYZ2LatLon(xyz)
|
|
408
|
+
|
|
409
|
+
# Create point features for partitioning
|
|
410
|
+
point_features = []
|
|
411
|
+
for lat, lon in zip(lats, lons):
|
|
412
|
+
point_feature = pygplates.Feature()
|
|
413
|
+
point_feature.set_geometry(pygplates.PointOnSphere(lat, lon))
|
|
414
|
+
point_features.append(point_feature)
|
|
415
|
+
|
|
416
|
+
# Partition into plates
|
|
417
|
+
assigned_point_features = pygplates.partition_into_plates(
|
|
418
|
+
partitioning_features,
|
|
419
|
+
rotation_model,
|
|
420
|
+
point_features,
|
|
421
|
+
reconstruction_time=time,
|
|
422
|
+
properties_to_copy=[pygplates.PartitionProperty.reconstruction_plate_id]
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Extract plate IDs
|
|
426
|
+
plate_ids = np.array([
|
|
427
|
+
f.get_reconstruction_plate_id() for f in assigned_point_features
|
|
428
|
+
], dtype=int)
|
|
429
|
+
|
|
430
|
+
return plate_ids
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _rotate_points_batch(
|
|
434
|
+
lats: np.ndarray,
|
|
435
|
+
lons: np.ndarray,
|
|
436
|
+
rotation: pygplates.FiniteRotation
|
|
437
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
438
|
+
"""
|
|
439
|
+
Apply rotation to a batch of points using MultiPointOnSphere.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
lats : np.ndarray
|
|
444
|
+
Latitudes in degrees, shape (N,).
|
|
445
|
+
lons : np.ndarray
|
|
446
|
+
Longitudes in degrees, shape (N,).
|
|
447
|
+
rotation : pygplates.FiniteRotation
|
|
448
|
+
Rotation to apply.
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
rotated_lats : np.ndarray
|
|
453
|
+
Rotated latitudes, shape (N,).
|
|
454
|
+
rotated_lons : np.ndarray
|
|
455
|
+
Rotated longitudes, shape (N,).
|
|
456
|
+
"""
|
|
457
|
+
if len(lats) == 0:
|
|
458
|
+
return np.array([]), np.array([])
|
|
459
|
+
|
|
460
|
+
# Create MultiPointOnSphere for batch rotation (single C++ call)
|
|
461
|
+
multi_point = pygplates.MultiPointOnSphere(zip(lats, lons))
|
|
462
|
+
|
|
463
|
+
# Apply rotation (single C++ operation)
|
|
464
|
+
rotated_multi_point = rotation * multi_point
|
|
465
|
+
|
|
466
|
+
# Extract rotated coordinates
|
|
467
|
+
rotated_lats = np.empty(len(lats))
|
|
468
|
+
rotated_lons = np.empty(len(lats))
|
|
469
|
+
for i, point in enumerate(rotated_multi_point.get_points()):
|
|
470
|
+
rotated_lats[i], rotated_lons[i] = point.to_lat_lon()
|
|
471
|
+
|
|
472
|
+
return rotated_lats, rotated_lons
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _move_points_batched(
|
|
476
|
+
xyz: np.ndarray,
|
|
477
|
+
rotation_model: pygplates.RotationModel,
|
|
478
|
+
plate_ids: np.ndarray,
|
|
479
|
+
from_age: float,
|
|
480
|
+
to_age: float
|
|
481
|
+
) -> np.ndarray:
|
|
482
|
+
"""
|
|
483
|
+
Move points according to plate motions using batched rotation operations.
|
|
484
|
+
|
|
485
|
+
This provides significant speedup by grouping points by plate_id and
|
|
486
|
+
computing rotation once per plate instead of per point.
|
|
487
|
+
|
|
488
|
+
Parameters
|
|
489
|
+
----------
|
|
490
|
+
xyz : np.ndarray
|
|
491
|
+
Cartesian coordinates, shape (N, 3).
|
|
492
|
+
rotation_model : pygplates.RotationModel
|
|
493
|
+
Rotation model.
|
|
494
|
+
plate_ids : np.ndarray
|
|
495
|
+
Plate IDs for each point, shape (N,).
|
|
496
|
+
from_age : float
|
|
497
|
+
Source time in Ma.
|
|
498
|
+
to_age : float
|
|
499
|
+
Target time in Ma.
|
|
500
|
+
|
|
501
|
+
Returns
|
|
502
|
+
-------
|
|
503
|
+
new_xyz : np.ndarray
|
|
504
|
+
Updated positions, shape (N, 3).
|
|
505
|
+
"""
|
|
506
|
+
from .geometry import XYZ2LatLon, LatLon2XYZ
|
|
507
|
+
|
|
508
|
+
# Convert all positions to lat/lon once
|
|
509
|
+
lats, lons = XYZ2LatLon(xyz)
|
|
510
|
+
|
|
511
|
+
# Pre-allocate output arrays
|
|
512
|
+
new_lats = lats.copy()
|
|
513
|
+
new_lons = lons.copy()
|
|
514
|
+
|
|
515
|
+
# Group points by unique plate IDs
|
|
516
|
+
unique_plates = np.unique(plate_ids)
|
|
517
|
+
|
|
518
|
+
# Process each plate's points in batch
|
|
519
|
+
for plate_id in unique_plates:
|
|
520
|
+
# Get rotation for this plate (computed ONCE per plate)
|
|
521
|
+
rotation = rotation_model.get_rotation(to_age, plate_id, from_age)
|
|
522
|
+
|
|
523
|
+
# Find all points on this plate
|
|
524
|
+
mask = (plate_ids == plate_id)
|
|
525
|
+
|
|
526
|
+
# Get positions for this plate's points
|
|
527
|
+
plate_lats = lats[mask]
|
|
528
|
+
plate_lons = lons[mask]
|
|
529
|
+
|
|
530
|
+
# Apply rotation to all points on this plate
|
|
531
|
+
rotated_lats, rotated_lons = _rotate_points_batch(
|
|
532
|
+
plate_lats, plate_lons, rotation
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Update positions
|
|
536
|
+
new_lats[mask] = rotated_lats
|
|
537
|
+
new_lons[mask] = rotated_lons
|
|
538
|
+
|
|
539
|
+
# Convert back to XYZ
|
|
540
|
+
new_latlon = np.column_stack([new_lats, new_lons])
|
|
541
|
+
new_xyz = LatLon2XYZ(new_latlon)
|
|
542
|
+
|
|
543
|
+
return new_xyz
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class PointRotator:
|
|
547
|
+
"""
|
|
548
|
+
Rotate points between geological ages using plate reconstructions.
|
|
549
|
+
|
|
550
|
+
This class provides the main API for rotating user-provided points
|
|
551
|
+
according to plate tectonic reconstructions.
|
|
552
|
+
|
|
553
|
+
Key Features:
|
|
554
|
+
- Cartesian XYZ internal representation (matches gadopt)
|
|
555
|
+
- Properties stored separately from positions and preserved during rotation
|
|
556
|
+
- Batched rotation for performance (10-50x speedup by grouping by plate_id)
|
|
557
|
+
- Handles undefined plates with warnings
|
|
558
|
+
|
|
559
|
+
Parameters
|
|
560
|
+
----------
|
|
561
|
+
rotation_files : list of str
|
|
562
|
+
Paths to rotation model files (.rot).
|
|
563
|
+
topology_files : list of str, optional
|
|
564
|
+
Paths to topology/plate boundary files (.gpmlz).
|
|
565
|
+
Used for partitioning if static_polygons not provided.
|
|
566
|
+
static_polygons : str, optional
|
|
567
|
+
Path to static polygons for plate ID assignment.
|
|
568
|
+
If None, uses topology_files for partitioning.
|
|
569
|
+
|
|
570
|
+
Examples
|
|
571
|
+
--------
|
|
572
|
+
>>> rotator = PointRotator(
|
|
573
|
+
... rotation_files=['rotations.rot'],
|
|
574
|
+
... topology_files=['topologies.gpmlz'],
|
|
575
|
+
... static_polygons='static_polygons.gpmlz'
|
|
576
|
+
... )
|
|
577
|
+
>>>
|
|
578
|
+
>>> # Load user points
|
|
579
|
+
>>> cloud = PointCloud.from_latlon(my_latlon_array)
|
|
580
|
+
>>>
|
|
581
|
+
>>> # Assign plate IDs at present day
|
|
582
|
+
>>> cloud = rotator.assign_plate_ids(cloud, at_age=0.0)
|
|
583
|
+
>>>
|
|
584
|
+
>>> # Rotate to 50 Ma
|
|
585
|
+
>>> rotated = rotator.rotate(cloud, from_age=0.0, to_age=50.0)
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
def __init__(
|
|
589
|
+
self,
|
|
590
|
+
rotation_files: Union[str, List[str]],
|
|
591
|
+
topology_files: Optional[Union[str, List[str]]] = None,
|
|
592
|
+
static_polygons: Optional[str] = None
|
|
593
|
+
):
|
|
594
|
+
import pygplates
|
|
595
|
+
from .geometry import ensure_list
|
|
596
|
+
|
|
597
|
+
# Handle single file or Path as list
|
|
598
|
+
rotation_files = ensure_list(rotation_files)
|
|
599
|
+
topology_files = ensure_list(topology_files)
|
|
600
|
+
|
|
601
|
+
self.rotation_model = pygplates.RotationModel(rotation_files)
|
|
602
|
+
|
|
603
|
+
# Load topology features if provided
|
|
604
|
+
if topology_files:
|
|
605
|
+
self.topology_features = pygplates.FeatureCollection()
|
|
606
|
+
for file in topology_files:
|
|
607
|
+
features = pygplates.FeatureCollection(file)
|
|
608
|
+
self.topology_features.add(features)
|
|
609
|
+
else:
|
|
610
|
+
self.topology_features = None
|
|
611
|
+
|
|
612
|
+
# Load static polygons if provided
|
|
613
|
+
if static_polygons is not None:
|
|
614
|
+
self.static_polygons = pygplates.FeatureCollection(static_polygons)
|
|
615
|
+
else:
|
|
616
|
+
self.static_polygons = None
|
|
617
|
+
|
|
618
|
+
# Ensure we have some features for partitioning
|
|
619
|
+
if self.static_polygons is None and self.topology_features is None:
|
|
620
|
+
raise ValueError(
|
|
621
|
+
"Either topology_files or static_polygons must be provided "
|
|
622
|
+
"for plate ID assignment."
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def assign_plate_ids(
|
|
626
|
+
self,
|
|
627
|
+
cloud: PointCloud,
|
|
628
|
+
at_age: float,
|
|
629
|
+
use_static_polygons: bool = True,
|
|
630
|
+
remove_undefined: bool = True
|
|
631
|
+
) -> PointCloud:
|
|
632
|
+
"""
|
|
633
|
+
Assign plate IDs to points based on their positions.
|
|
634
|
+
|
|
635
|
+
Parameters
|
|
636
|
+
----------
|
|
637
|
+
cloud : PointCloud
|
|
638
|
+
Points to assign plate IDs to.
|
|
639
|
+
at_age : float
|
|
640
|
+
Geological age at which to assign plate IDs (Ma).
|
|
641
|
+
Use 0.0 for present-day positions.
|
|
642
|
+
use_static_polygons : bool, default=True
|
|
643
|
+
If True and static_polygons are loaded, use them for assignment.
|
|
644
|
+
Otherwise use topology_features.
|
|
645
|
+
remove_undefined : bool, default=True
|
|
646
|
+
If True, remove points with undefined plate IDs (plate_id=0)
|
|
647
|
+
and emit a warning.
|
|
648
|
+
If False, keep points with plate_id=0.
|
|
649
|
+
|
|
650
|
+
Returns
|
|
651
|
+
-------
|
|
652
|
+
PointCloud
|
|
653
|
+
Cloud with plate_ids assigned. May have fewer points if
|
|
654
|
+
remove_undefined=True and some points had undefined plates.
|
|
655
|
+
|
|
656
|
+
Warns
|
|
657
|
+
-----
|
|
658
|
+
UserWarning
|
|
659
|
+
If any points have undefined plate IDs.
|
|
660
|
+
"""
|
|
661
|
+
# Select partitioning features
|
|
662
|
+
partitioning_features = (
|
|
663
|
+
self.static_polygons
|
|
664
|
+
if (use_static_polygons and self.static_polygons is not None)
|
|
665
|
+
else self.topology_features
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
plate_ids = _get_plate_ids(
|
|
669
|
+
cloud.xyz,
|
|
670
|
+
partitioning_features,
|
|
671
|
+
self.rotation_model,
|
|
672
|
+
at_age
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# Check for undefined plates (plate_id = 0)
|
|
676
|
+
undefined_mask = (plate_ids == 0)
|
|
677
|
+
n_undefined = np.sum(undefined_mask)
|
|
678
|
+
|
|
679
|
+
if n_undefined > 0:
|
|
680
|
+
action = "These points will be removed." if remove_undefined else "They will be assigned plate_id=0."
|
|
681
|
+
warnings.warn(
|
|
682
|
+
f"{n_undefined} points ({100*n_undefined/cloud.n_points:.1f}%) "
|
|
683
|
+
f"have undefined plate IDs at {at_age} Ma. {action}",
|
|
684
|
+
UserWarning
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
if remove_undefined and n_undefined > 0:
|
|
688
|
+
valid_mask = ~undefined_mask
|
|
689
|
+
result = cloud.subset(valid_mask)
|
|
690
|
+
result.plate_ids = plate_ids[valid_mask]
|
|
691
|
+
else:
|
|
692
|
+
result = cloud.copy()
|
|
693
|
+
result.plate_ids = plate_ids
|
|
694
|
+
|
|
695
|
+
return result
|
|
696
|
+
|
|
697
|
+
def rotate(
|
|
698
|
+
self,
|
|
699
|
+
cloud: PointCloud,
|
|
700
|
+
from_age: float,
|
|
701
|
+
to_age: float,
|
|
702
|
+
reassign_plate_ids: bool = False
|
|
703
|
+
) -> PointCloud:
|
|
704
|
+
"""
|
|
705
|
+
Rotate points from one geological age to another.
|
|
706
|
+
|
|
707
|
+
Uses batched rotation operations for performance (10-50x speedup
|
|
708
|
+
by grouping points by plate_id).
|
|
709
|
+
|
|
710
|
+
Parameters
|
|
711
|
+
----------
|
|
712
|
+
cloud : PointCloud
|
|
713
|
+
Points to rotate. Must have plate_ids assigned.
|
|
714
|
+
from_age : float
|
|
715
|
+
Source geological age (Ma).
|
|
716
|
+
to_age : float
|
|
717
|
+
Target geological age (Ma).
|
|
718
|
+
reassign_plate_ids : bool, default=False
|
|
719
|
+
If True, reassign plate IDs at target age.
|
|
720
|
+
If False, keep original plate IDs.
|
|
721
|
+
|
|
722
|
+
Returns
|
|
723
|
+
-------
|
|
724
|
+
PointCloud
|
|
725
|
+
Rotated points with same properties.
|
|
726
|
+
|
|
727
|
+
Raises
|
|
728
|
+
------
|
|
729
|
+
ValueError
|
|
730
|
+
If cloud does not have plate_ids assigned.
|
|
731
|
+
|
|
732
|
+
Notes
|
|
733
|
+
-----
|
|
734
|
+
Direction of rotation:
|
|
735
|
+
- from_age=0, to_age=50: Rotate present-day positions to 50 Ma
|
|
736
|
+
- from_age=50, to_age=0: Rotate 50 Ma positions to present day
|
|
737
|
+
|
|
738
|
+
Examples
|
|
739
|
+
--------
|
|
740
|
+
>>> # Rotate present-day continental points to 50 Ma
|
|
741
|
+
>>> rotated = rotator.rotate(cloud, from_age=0.0, to_age=50.0)
|
|
742
|
+
"""
|
|
743
|
+
if cloud.plate_ids is None:
|
|
744
|
+
raise ValueError(
|
|
745
|
+
"Cloud must have plate_ids assigned. "
|
|
746
|
+
"Call assign_plate_ids() first."
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Apply batched rotation (groups by plate_id for efficiency)
|
|
750
|
+
rotated_xyz = _move_points_batched(
|
|
751
|
+
cloud.xyz,
|
|
752
|
+
self.rotation_model,
|
|
753
|
+
cloud.plate_ids,
|
|
754
|
+
from_age,
|
|
755
|
+
to_age
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Create result cloud with rotated positions
|
|
759
|
+
result = PointCloud(
|
|
760
|
+
xyz=rotated_xyz,
|
|
761
|
+
properties={k: v.copy() for k, v in cloud.properties.items()},
|
|
762
|
+
plate_ids=cloud.plate_ids.copy()
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Optionally reassign plate IDs at new age
|
|
766
|
+
if reassign_plate_ids:
|
|
767
|
+
result = self.assign_plate_ids(result, at_age=to_age)
|
|
768
|
+
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
def rotate_incremental(
|
|
772
|
+
self,
|
|
773
|
+
cloud: PointCloud,
|
|
774
|
+
from_age: float,
|
|
775
|
+
to_age: float,
|
|
776
|
+
time_step: float = 1.0,
|
|
777
|
+
reassign_at_each_step: bool = True
|
|
778
|
+
) -> PointCloud:
|
|
779
|
+
"""
|
|
780
|
+
Rotate points incrementally through geological time.
|
|
781
|
+
|
|
782
|
+
Useful when plate IDs may change during rotation (e.g.,
|
|
783
|
+
points crossing plate boundaries over long time spans).
|
|
784
|
+
|
|
785
|
+
Parameters
|
|
786
|
+
----------
|
|
787
|
+
cloud : PointCloud
|
|
788
|
+
Points to rotate. Must have plate_ids assigned.
|
|
789
|
+
from_age : float
|
|
790
|
+
Source geological age (Ma).
|
|
791
|
+
to_age : float
|
|
792
|
+
Target geological age (Ma).
|
|
793
|
+
time_step : float, default=1.0
|
|
794
|
+
Time step for incremental rotation (Myr).
|
|
795
|
+
reassign_at_each_step : bool, default=True
|
|
796
|
+
If True, reassign plate IDs after each time step.
|
|
797
|
+
This handles points that cross plate boundaries.
|
|
798
|
+
|
|
799
|
+
Returns
|
|
800
|
+
-------
|
|
801
|
+
PointCloud
|
|
802
|
+
Rotated points.
|
|
803
|
+
|
|
804
|
+
Examples
|
|
805
|
+
--------
|
|
806
|
+
>>> # Rotate with 1 Myr steps, reassigning plates
|
|
807
|
+
>>> rotated = rotator.rotate_incremental(
|
|
808
|
+
... cloud, from_age=0.0, to_age=100.0, time_step=1.0
|
|
809
|
+
... )
|
|
810
|
+
"""
|
|
811
|
+
if cloud.plate_ids is None:
|
|
812
|
+
raise ValueError("Cloud must have plate_ids assigned.")
|
|
813
|
+
|
|
814
|
+
result = cloud.copy()
|
|
815
|
+
current_age = from_age
|
|
816
|
+
direction = 1 if to_age > from_age else -1
|
|
817
|
+
|
|
818
|
+
while (direction > 0 and current_age < to_age) or \
|
|
819
|
+
(direction < 0 and current_age > to_age):
|
|
820
|
+
|
|
821
|
+
# Calculate step size (don't overshoot)
|
|
822
|
+
remaining = abs(to_age - current_age)
|
|
823
|
+
actual_step = min(time_step, remaining) * direction
|
|
824
|
+
next_age = current_age + actual_step
|
|
825
|
+
|
|
826
|
+
# Rotate one step
|
|
827
|
+
result = self.rotate(
|
|
828
|
+
result,
|
|
829
|
+
from_age=current_age,
|
|
830
|
+
to_age=next_age,
|
|
831
|
+
reassign_plate_ids=reassign_at_each_step
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
current_age = next_age
|
|
835
|
+
|
|
836
|
+
return result
|