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.
@@ -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