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,851 @@
1
+ """
2
+ Seafloor age tracking using Lagrangian particle tracking.
3
+
4
+ This module provides the main API for computing seafloor ages
5
+ using pygplates' C++ backend for efficient reconstruction.
6
+ """
7
+
8
+ import numpy as np
9
+ from typing import Callable, Dict, List, Optional, Union
10
+
11
+ import pygplates
12
+
13
+ from .config import TracerConfig
14
+ from .point_rotation import PointCloud
15
+ from .mesh import create_sphere_mesh_latlon
16
+ from .mor_seeds import generate_mor_seeds
17
+ from .boundaries import ContinentalPolygonCache
18
+ from .initial_conditions import compute_initial_ages, default_age_distance_law
19
+ from .logging import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class SeafloorAgeTracker:
25
+ """
26
+ Seafloor age tracker using Lagrangian particle tracking with C++ backend.
27
+
28
+ Uses pygplates.TopologicalModel.reconstruct_geometry() for efficient
29
+ point advection with built-in collision detection.
30
+
31
+ Key features:
32
+ - GPlately-compatible: Matches GPlately's SeafloorGrid output
33
+ - C++ backend: Fast reconstruction using pygplates internals
34
+ - Icosahedral initialization: Full ocean coverage from start
35
+ - Continental filtering: Via polygon queries with caching
36
+ - Checkpointing: Save/restore state for restarts
37
+
38
+ Parameters
39
+ ----------
40
+ rotation_files : list of str
41
+ Paths to rotation model files (.rot).
42
+ topology_files : list of str
43
+ Paths to topology/plate boundary files (.gpml/.gpmlz).
44
+ continental_polygons : str, optional
45
+ Path to continental polygon file. If None, tracers are not
46
+ removed when they enter continental regions.
47
+ config : TracerConfig, optional
48
+ Configuration parameters. If None, uses defaults.
49
+ verbose : bool, default=True
50
+ Deprecated. Use GTRACK_LOGLEVEL environment variable instead.
51
+ Set GTRACK_LOGLEVEL=INFO for progress messages or DEBUG for details.
52
+
53
+ Examples
54
+ --------
55
+ >>> # Initialize tracker
56
+ >>> tracker = SeafloorAgeTracker(
57
+ ... rotation_files=['rotations.rot'],
58
+ ... topology_files=['topologies.gpmlz'],
59
+ ... continental_polygons='continents.gpmlz'
60
+ ... )
61
+ >>>
62
+ >>> # Initialize with sphere mesh (GPlately-compatible)
63
+ >>> tracker.initialize(starting_age=200)
64
+ >>>
65
+ >>> # Step forward (decreasing geological age toward present)
66
+ >>> for target_age in range(199, -1, -1):
67
+ ... cloud = tracker.step_to(target_age)
68
+ ... xyz = cloud.xyz
69
+ ... ages = cloud.get_property('age')
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ rotation_files: Union[str, List[str]],
75
+ topology_files: Union[str, List[str]],
76
+ continental_polygons: Optional[str] = None,
77
+ config: Optional[TracerConfig] = None,
78
+ verbose: bool = True,
79
+ ):
80
+ from .geometry import ensure_list
81
+
82
+ self._config = config if config else TracerConfig()
83
+
84
+ # Handle deprecated verbose flag
85
+ if verbose:
86
+ from .logging import enable_verbose
87
+ enable_verbose()
88
+
89
+ # Handle single file or Path as list
90
+ rotation_files = ensure_list(rotation_files)
91
+ topology_files = ensure_list(topology_files)
92
+
93
+ self._rotation_files = rotation_files
94
+ self._topology_files = topology_files
95
+ self._continental_polygons_path = continental_polygons
96
+
97
+ logger.info("Initializing SeafloorAgeTracker...")
98
+
99
+ # Load rotation model
100
+ self._rotation_model = pygplates.RotationModel(rotation_files)
101
+
102
+ # Load topology features
103
+ self._topology_features = []
104
+ for f in topology_files:
105
+ self._topology_features.extend(pygplates.FeatureCollection(f))
106
+
107
+ # Create TopologicalModel for C++ backend reconstruction
108
+ self._topological_model = pygplates.TopologicalModel(
109
+ self._topology_features, self._rotation_model
110
+ )
111
+
112
+ # Set up continental polygon cache
113
+ if continental_polygons is not None:
114
+ self._continental_cache = ContinentalPolygonCache(
115
+ continental_polygons,
116
+ self._rotation_model,
117
+ max_cache_size=self._config.continental_cache_size,
118
+ )
119
+ else:
120
+ self._continental_cache = None
121
+
122
+ # State variables
123
+ self._current_age: Optional[float] = None
124
+ self._lats: Optional[np.ndarray] = None
125
+ self._lons: Optional[np.ndarray] = None
126
+ self._ages: Optional[np.ndarray] = None # Material ages (time since formation)
127
+ self._initialized = False
128
+
129
+ logger.info(" Initialization complete.")
130
+
131
+ def initialize(
132
+ self,
133
+ starting_age: float,
134
+ method: str = 'mesh',
135
+ n_points: Optional[int] = None,
136
+ initial_ocean_mean_spreading_rate: Optional[float] = None,
137
+ age_distance_law: Optional[Callable[[np.ndarray, float], np.ndarray]] = None,
138
+ ) -> int:
139
+ """
140
+ Initialize tracers for given geological age.
141
+
142
+ Parameters
143
+ ----------
144
+ starting_age : float
145
+ Starting geological age (Ma). Tracers are placed based on
146
+ ocean structure at this age.
147
+ method : str, default='mesh'
148
+ Initialization method:
149
+ - 'mesh': Full ocean mesh with computed ages (GPlately-compatible)
150
+ - 'ridge_only': Tracers only at ridges with age=0 (legacy gtrack)
151
+ n_points : int, optional
152
+ Number of points for the sphere mesh. If None, uses config default.
153
+ initial_ocean_mean_spreading_rate : float, optional
154
+ Spreading rate for age calculation (mm/yr). If None, uses config default.
155
+ age_distance_law : callable, optional
156
+ Custom function to convert distance to age.
157
+ Signature: (distances_km, spreading_rate_mm_yr) -> ages_myr
158
+
159
+ Returns
160
+ -------
161
+ int
162
+ Number of tracers initialized.
163
+
164
+ Examples
165
+ --------
166
+ >>> # GPlately-compatible initialization
167
+ >>> tracker.initialize(starting_age=200)
168
+ >>>
169
+ >>> # Higher resolution
170
+ >>> tracker.initialize(starting_age=200, n_points=40000)
171
+ >>>
172
+ >>> # Custom age calculation
173
+ >>> def my_age_law(distances, rate):
174
+ ... return distances / (rate / 2) * 1.1 # 10% older
175
+ >>> tracker.initialize(starting_age=200, age_distance_law=my_age_law)
176
+ """
177
+ if n_points is None:
178
+ n_points = self._config.default_mesh_points
179
+ if initial_ocean_mean_spreading_rate is None:
180
+ initial_ocean_mean_spreading_rate = self._config.initial_ocean_mean_spreading_rate
181
+
182
+ logger.info(f"Initializing tracers at {starting_age} Ma (method='{method}')...")
183
+
184
+ if method == 'mesh':
185
+ self._initialize_mesh(
186
+ starting_age,
187
+ n_points,
188
+ initial_ocean_mean_spreading_rate,
189
+ age_distance_law,
190
+ )
191
+ elif method == 'ridge_only':
192
+ self._initialize_ridge_only(starting_age)
193
+ else:
194
+ raise ValueError(f"Unknown initialization method: {method}")
195
+
196
+ self._current_age = starting_age
197
+ self._initialized = True
198
+
199
+ logger.info(f" Initialized with {len(self._lats)} tracers at {starting_age} Ma")
200
+
201
+ return len(self._lats)
202
+
203
+ def _initialize_mesh(
204
+ self,
205
+ starting_age: float,
206
+ n_points: int,
207
+ spreading_rate: float,
208
+ age_distance_law: Optional[Callable],
209
+ ):
210
+ """Initialize with sphere mesh (GPlately-compatible)."""
211
+ logger.debug(f" Creating sphere mesh ({n_points:,} points)...")
212
+
213
+ # Create sphere mesh and get lat/lon coordinates directly
214
+ mesh_lats, mesh_lons = create_sphere_mesh_latlon(n_points)
215
+
216
+ logger.debug(f" Created mesh with {len(mesh_lats)} points")
217
+
218
+ # Resolve topologies at starting time (needed for compute_initial_ages)
219
+ resolved_topologies = []
220
+ shared_boundary_sections = []
221
+ pygplates.resolve_topologies(
222
+ self._topology_features,
223
+ self._rotation_model,
224
+ resolved_topologies,
225
+ starting_age,
226
+ shared_boundary_sections,
227
+ )
228
+
229
+ # Filter out continental points if we have continental polygons
230
+ if self._continental_cache is not None:
231
+ # Get continental mask
232
+ continental_mask = self._continental_cache.get_continental_mask(
233
+ mesh_lats, mesh_lons, starting_age
234
+ )
235
+
236
+ # Filter to ocean points
237
+ ocean_mask = ~continental_mask
238
+ ocean_lats = mesh_lats[ocean_mask]
239
+ ocean_lons = mesh_lons[ocean_mask]
240
+
241
+ logger.debug(f" Filtered to {len(ocean_lats)} ocean points (removed {continental_mask.sum()} continental)")
242
+
243
+ # Create ocean point MultiPointOnSphere for compute_initial_ages
244
+ ocean_points = pygplates.MultiPointOnSphere(zip(ocean_lats, ocean_lons))
245
+ else:
246
+ ocean_lats = mesh_lats
247
+ ocean_lons = mesh_lons
248
+ ocean_points = pygplates.MultiPointOnSphere(zip(ocean_lats, ocean_lons))
249
+
250
+ # Compute initial ages from distance to ridge (plate-based approach)
251
+ logger.debug(" Computing initial ages from distance to ridge...")
252
+
253
+ lons, lats, ages = compute_initial_ages(
254
+ ocean_points,
255
+ resolved_topologies,
256
+ shared_boundary_sections,
257
+ initial_ocean_mean_spreading_rate=spreading_rate,
258
+ age_distance_law=age_distance_law,
259
+ )
260
+
261
+ self._lats = lats
262
+ self._lons = lons
263
+ self._ages = ages
264
+
265
+ def _initialize_ridge_only(self, starting_age: float):
266
+ """Initialize with tracers only at ridges (legacy gtrack approach)."""
267
+ logger.debug(" Generating ridge seed points...")
268
+
269
+ lats, lons = generate_mor_seeds(
270
+ starting_age,
271
+ self._topology_features,
272
+ self._rotation_model,
273
+ ridge_sampling_degrees=self._config.ridge_sampling_degrees,
274
+ spreading_offset_degrees=self._config.spreading_offset_degrees,
275
+ )
276
+
277
+ self._lats = lats
278
+ self._lons = lons
279
+ self._ages = np.zeros(len(lats)) # All tracers start with age 0
280
+
281
+ def initialize_from_cloud(
282
+ self,
283
+ cloud: PointCloud,
284
+ current_age: float,
285
+ ) -> int:
286
+ """
287
+ Initialize from existing PointCloud.
288
+
289
+ Use this to restart from a checkpoint or to provide
290
+ custom initial tracer positions.
291
+
292
+ Parameters
293
+ ----------
294
+ cloud : PointCloud
295
+ Point cloud with 'age' property containing material
296
+ age of each tracer (time since ridge formation).
297
+ current_age : float
298
+ Current geological age (Ma).
299
+
300
+ Returns
301
+ -------
302
+ int
303
+ Number of tracers initialized.
304
+
305
+ Raises
306
+ ------
307
+ ValueError
308
+ If cloud does not have 'age' property.
309
+ """
310
+ if 'age' not in cloud.properties:
311
+ raise ValueError(
312
+ "PointCloud must have 'age' property. "
313
+ "This should contain the material age of each tracer."
314
+ )
315
+
316
+ logger.info(f"Initializing from PointCloud at {current_age} Ma...")
317
+
318
+ # Convert XYZ to lat/lon
319
+ lonlat = cloud.lonlat
320
+ self._lons = lonlat[:, 0]
321
+ self._lats = lonlat[:, 1]
322
+ self._ages = cloud.get_property('age').copy()
323
+ self._current_age = current_age
324
+ self._initialized = True
325
+
326
+ logger.info(f" Initialized with {len(self._lats)} tracers at {current_age} Ma")
327
+
328
+ return len(self._lats)
329
+
330
+ def step_to(self, target_age: float) -> PointCloud:
331
+ """
332
+ Evolve tracers to target geological age using C++ backend.
333
+
334
+ Can only step forward (decreasing geological age toward 0).
335
+
336
+ Parameters
337
+ ----------
338
+ target_age : float
339
+ Target geological age (Ma). Must be less than current_age.
340
+
341
+ Returns
342
+ -------
343
+ PointCloud
344
+ Point cloud with 'age' property containing material ages.
345
+
346
+ Raises
347
+ ------
348
+ RuntimeError
349
+ If tracker is not initialized.
350
+ ValueError
351
+ If target_age > current_age (can only go forward).
352
+ """
353
+ if not self._initialized:
354
+ raise RuntimeError(
355
+ "Must call initialize() or initialize_from_cloud() first!"
356
+ )
357
+
358
+ if target_age > self._current_age:
359
+ raise ValueError(
360
+ f"Can only step forward (decreasing age). "
361
+ f"Current age: {self._current_age}, target: {target_age}"
362
+ )
363
+
364
+ if target_age == self._current_age:
365
+ logger.debug(f"Already at {target_age} Ma, no update needed")
366
+ return self.get_current_state()
367
+
368
+ logger.info(f"Evolving: {self._current_age} Ma -> {target_age} Ma")
369
+
370
+ # Process in time_step increments
371
+ time = self._current_age
372
+ while time > target_age:
373
+ next_time = max(time - self._config.time_step, target_age)
374
+
375
+ if len(self._lats) == 0:
376
+ logger.warning(" No points to reconstruct")
377
+ break
378
+
379
+ # Create MultiPointOnSphere from lat/lon tuples (faster than individual PointOnSphere)
380
+ points = pygplates.MultiPointOnSphere(
381
+ zip(self._lats, self._lons)
382
+ )
383
+
384
+ # Reconstruct using C++ backend
385
+ # Note: reconstruct_geometry needs integral time values
386
+ reconstructed_time_span = self._topological_model.reconstruct_geometry(
387
+ points,
388
+ initial_time=int(time),
389
+ youngest_time=int(next_time),
390
+ time_increment=int(self._config.time_step),
391
+ deactivate_points=pygplates.ReconstructedGeometryTimeSpan.DefaultDeactivatePoints(
392
+ threshold_velocity_delta=self._config.velocity_delta_threshold_cm_yr,
393
+ threshold_distance_to_boundary=self._config.distance_threshold_per_myr,
394
+ deactivate_points_that_fall_outside_a_network=True,
395
+ ),
396
+ )
397
+
398
+ # Get reconstructed points (inactive points are None)
399
+ reconstructed_points = reconstructed_time_span.get_geometry_points(
400
+ int(next_time), return_inactive_points=True
401
+ )
402
+
403
+ # Update coordinates and ages, removing inactive points
404
+ self._update_from_reconstructed(reconstructed_points, time - next_time)
405
+
406
+ # Remove continental points
407
+ if self._continental_cache is not None:
408
+ self._remove_continental_points(next_time)
409
+
410
+ # Add new MOR seed points
411
+ self._add_mor_seeds(next_time)
412
+
413
+ time = next_time
414
+
415
+ self._current_age = target_age
416
+
417
+ logger.info(f" Current tracer count: {len(self._lats)}")
418
+
419
+ return self.get_current_state()
420
+
421
+ def _update_from_reconstructed(
422
+ self,
423
+ reconstructed_points: List,
424
+ delta_time: float,
425
+ ):
426
+ """Update coordinates from reconstruction, removing inactive points."""
427
+ # Create boolean mask for active (non-None) points
428
+ active_mask = np.array([p is not None for p in reconstructed_points], dtype=bool)
429
+ n_active = active_mask.sum()
430
+
431
+ if n_active == 0:
432
+ self._lats = np.array([])
433
+ self._lons = np.array([])
434
+ self._ages = np.array([])
435
+ return
436
+
437
+ # Pre-allocate arrays
438
+ new_lats = np.empty(n_active)
439
+ new_lons = np.empty(n_active)
440
+
441
+ # Extract coordinates from active points
442
+ j = 0
443
+ for i, point in enumerate(reconstructed_points):
444
+ if point is not None:
445
+ new_lats[j], new_lons[j] = point.to_lat_lon()
446
+ j += 1
447
+
448
+ # Update ages using vectorized operation
449
+ self._lats = new_lats
450
+ self._lons = new_lons
451
+ self._ages = self._ages[active_mask] + delta_time
452
+
453
+ def _remove_continental_points(self, time: float):
454
+ """Remove points inside continental polygons."""
455
+ if len(self._lats) == 0:
456
+ return
457
+
458
+ continental_mask = self._continental_cache.get_continental_mask(
459
+ self._lats, self._lons, time
460
+ )
461
+
462
+ if continental_mask.any():
463
+ ocean_mask = ~continental_mask
464
+ self._lats = self._lats[ocean_mask]
465
+ self._lons = self._lons[ocean_mask]
466
+ self._ages = self._ages[ocean_mask]
467
+
468
+ logger.debug(f" Removed {continental_mask.sum()} continental points")
469
+
470
+ def _add_mor_seeds(self, time: float):
471
+ """Add new MOR seed points."""
472
+ new_lats, new_lons = generate_mor_seeds(
473
+ time,
474
+ self._topology_features,
475
+ self._rotation_model,
476
+ ridge_sampling_degrees=self._config.ridge_sampling_degrees,
477
+ spreading_offset_degrees=self._config.spreading_offset_degrees,
478
+ )
479
+
480
+ if len(new_lats) > 0:
481
+ self._lats = np.concatenate([self._lats, new_lats])
482
+ self._lons = np.concatenate([self._lons, new_lons])
483
+ self._ages = np.concatenate([self._ages, np.zeros(len(new_lats))])
484
+
485
+ logger.debug(f" Added {len(new_lats)} new MOR seed points")
486
+
487
+ def get_current_state(self) -> PointCloud:
488
+ """
489
+ Get current tracers as PointCloud without evolving.
490
+
491
+ Returns
492
+ -------
493
+ PointCloud
494
+ Current tracer positions with 'age' property.
495
+ """
496
+ if self._lats is None or len(self._lats) == 0:
497
+ return PointCloud(
498
+ xyz=np.zeros((0, 3)),
499
+ properties={'age': np.array([])}
500
+ )
501
+
502
+ # Convert lat/lon to XYZ
503
+ lats_rad = np.radians(self._lats)
504
+ lons_rad = np.radians(self._lons)
505
+ r = self._config.earth_radius
506
+
507
+ x = r * np.cos(lats_rad) * np.cos(lons_rad)
508
+ y = r * np.cos(lats_rad) * np.sin(lons_rad)
509
+ z = r * np.sin(lats_rad)
510
+
511
+ xyz = np.column_stack([x, y, z])
512
+
513
+ return PointCloud(
514
+ xyz=xyz,
515
+ properties={'age': self._ages.copy()}
516
+ )
517
+
518
+ def reinitialize(
519
+ self,
520
+ n_points: Optional[int] = None,
521
+ max_distance_km: Optional[float] = None,
522
+ k_neighbors: int = 3,
523
+ ) -> PointCloud:
524
+ """
525
+ Reinitialize the tracer field with a new sphere mesh.
526
+
527
+ Generates a fresh sphere mesh and interpolates ages from existing
528
+ tracers using inverse distance weighting. Points without nearby tracers
529
+ (beyond max_distance_km) are dropped.
530
+
531
+ This is useful for resampling the ocean when point density becomes
532
+ uneven due to spreading patterns. The reinitialized points will be
533
+ assigned plate IDs during the next step_to() call.
534
+
535
+ Parameters
536
+ ----------
537
+ n_points : int, optional
538
+ Number of points for the mesh. If None, uses config.default_mesh_points.
539
+ max_distance_km : float, optional
540
+ Maximum distance in kilometers to search for neighbors. Points with
541
+ no neighbors within this distance are dropped (no age data means gap).
542
+ If None, calculated as 2× the mesh spacing for the given number of points.
543
+ k_neighbors : int, default=3
544
+ Number of nearest neighbors for inverse distance weighting.
545
+ Use k_neighbors=1 for simple nearest-neighbor interpolation.
546
+
547
+ Returns
548
+ -------
549
+ PointCloud
550
+ The reinitialized point cloud with interpolated ages.
551
+
552
+ Raises
553
+ ------
554
+ RuntimeError
555
+ If tracker is not initialized.
556
+ ValueError
557
+ If all points are filtered out (max_distance_km too small or no data).
558
+
559
+ Examples
560
+ --------
561
+ >>> tracker.initialize(starting_age=200)
562
+ >>> tracker.step_to(150)
563
+ >>> # Reinitialize with higher resolution mesh
564
+ >>> cloud = tracker.reinitialize(n_points=40000)
565
+ >>> tracker.step_to(100) # Continue time-stepping
566
+ """
567
+ if not self._initialized:
568
+ raise RuntimeError(
569
+ "Must call initialize() or initialize_from_cloud() before reinitialize()"
570
+ )
571
+
572
+ from scipy.spatial import cKDTree
573
+ from .geometry import inverse_distance_weighted_interpolation, compute_mesh_spacing_km
574
+
575
+ # Set defaults
576
+ if n_points is None:
577
+ n_points = self._config.default_mesh_points
578
+
579
+ if max_distance_km is None:
580
+ # Default: 2× mesh spacing
581
+ max_distance_km = 2.0 * compute_mesh_spacing_km(n_points)
582
+
583
+ # Convert max_distance to meters for KDTree queries
584
+ max_distance_m = max_distance_km * 1000.0
585
+
586
+ logger.info(
587
+ f"Reinitializing to sphere mesh ({n_points:,} points, "
588
+ f"max_distance={max_distance_km:.1f} km, k={k_neighbors})..."
589
+ )
590
+
591
+ # Check we have existing points
592
+ if len(self._lats) == 0:
593
+ raise ValueError("No existing tracers to interpolate from")
594
+
595
+ # Create new sphere mesh
596
+ mesh_lats, mesh_lons = create_sphere_mesh_latlon(n_points)
597
+ n_mesh = len(mesh_lats)
598
+ logger.debug(f" Created mesh with {n_mesh} points")
599
+
600
+ # Helper function for lat/lon to XYZ conversion
601
+ def latlon_to_xyz(lats, lons, r):
602
+ lats_rad = np.radians(lats)
603
+ lons_rad = np.radians(lons)
604
+ x = r * np.cos(lats_rad) * np.cos(lons_rad)
605
+ y = r * np.cos(lats_rad) * np.sin(lons_rad)
606
+ z = r * np.sin(lats_rad)
607
+ return np.column_stack([x, y, z])
608
+
609
+ # Convert to XYZ for KDTree
610
+ current_xyz = latlon_to_xyz(self._lats, self._lons, self._config.earth_radius)
611
+ mesh_xyz = latlon_to_xyz(mesh_lats, mesh_lons, self._config.earth_radius)
612
+
613
+ # Build KDTree from existing tracers
614
+ tree = cKDTree(current_xyz)
615
+
616
+ # Handle case where k_neighbors > number of existing points
617
+ k = min(k_neighbors, len(self._lats))
618
+
619
+ # Query K nearest neighbors for each mesh point
620
+ distances, indices = tree.query(mesh_xyz, k=k)
621
+
622
+ # Ensure 2D arrays even for k=1
623
+ if k == 1:
624
+ distances = distances.reshape(-1, 1)
625
+ indices = indices.reshape(-1, 1)
626
+
627
+ # Determine which mesh points have valid neighbors (within max_distance)
628
+ # A point is valid if at least one neighbor is within max_distance
629
+ min_distances = distances[:, 0] # Distance to nearest neighbor
630
+ valid_mask = min_distances < max_distance_m
631
+
632
+ n_valid = valid_mask.sum()
633
+ if n_valid == 0:
634
+ raise ValueError(
635
+ f"All mesh points filtered out. No existing tracers within "
636
+ f"{max_distance_km:.1f} km of any mesh point. "
637
+ f"Try increasing max_distance_km or check tracer distribution."
638
+ )
639
+
640
+ logger.debug(f" {n_valid}/{n_mesh} mesh points have nearby tracers")
641
+
642
+ # Filter to valid points only
643
+ valid_mesh_lats = mesh_lats[valid_mask]
644
+ valid_mesh_lons = mesh_lons[valid_mask]
645
+ valid_distances = distances[valid_mask]
646
+ valid_indices = indices[valid_mask]
647
+
648
+ # For IDW, only use neighbors within max_distance
649
+ # Build values array from ages
650
+ ages_for_interp = np.zeros_like(valid_distances)
651
+ for i in range(n_valid):
652
+ ages_for_interp[i] = self._ages[valid_indices[i]]
653
+
654
+ # Mask out neighbors beyond max_distance (set distance to inf so they get zero weight)
655
+ beyond_threshold = valid_distances >= max_distance_m
656
+ valid_distances_masked = valid_distances.copy()
657
+ valid_distances_masked[beyond_threshold] = np.inf
658
+
659
+ # Compute IDW interpolation
660
+ new_ages = inverse_distance_weighted_interpolation(ages_for_interp, valid_distances_masked)
661
+
662
+ # Update internal state
663
+ self._lats = valid_mesh_lats
664
+ self._lons = valid_mesh_lons
665
+ self._ages = new_ages
666
+
667
+ logger.info(f" Reinitialized to {len(self._lats)} points")
668
+
669
+ return self.get_current_state()
670
+
671
+ # Keep old name as alias for backwards compatibility
672
+ def reinitialize_to_mesh(
673
+ self,
674
+ n_points: Optional[int] = None,
675
+ k_neighbors: Optional[int] = None,
676
+ max_distance: Optional[float] = None,
677
+ ) -> int:
678
+ """
679
+ Deprecated: Use reinitialize() instead.
680
+
681
+ This method is kept for backwards compatibility.
682
+ """
683
+ import warnings
684
+ warnings.warn(
685
+ "reinitialize_to_mesh() is deprecated, use reinitialize() instead",
686
+ DeprecationWarning,
687
+ stacklevel=2
688
+ )
689
+ # Convert max_distance from meters to km for new API
690
+ max_distance_km = max_distance / 1000.0 if max_distance is not None else None
691
+ k = k_neighbors if k_neighbors is not None else 3
692
+
693
+ self.reinitialize(
694
+ n_points=n_points,
695
+ max_distance_km=max_distance_km,
696
+ k_neighbors=k,
697
+ )
698
+ return len(self._lats)
699
+
700
+ @property
701
+ def current_age(self) -> Optional[float]:
702
+ """Current geological age."""
703
+ return self._current_age
704
+
705
+ @property
706
+ def n_tracers(self) -> int:
707
+ """Number of tracers."""
708
+ return len(self._lats) if self._lats is not None else 0
709
+
710
+ def save_checkpoint(self, filepath: str) -> None:
711
+ """
712
+ Save state to checkpoint file.
713
+
714
+ Parameters
715
+ ----------
716
+ filepath : str
717
+ Path to save checkpoint (.npz format).
718
+ """
719
+ from .io_formats import PointCloudCheckpoint
720
+
721
+ if not self._initialized:
722
+ raise RuntimeError("No state to save - not initialized")
723
+
724
+ cloud = self.get_current_state()
725
+ checkpoint = PointCloudCheckpoint()
726
+ checkpoint.save(
727
+ cloud,
728
+ filepath,
729
+ geological_age=self._current_age,
730
+ metadata={
731
+ 'time_step': self._config.time_step,
732
+ }
733
+ )
734
+
735
+ logger.info(f"Saved checkpoint to {filepath} ({self._current_age} Ma)")
736
+
737
+ def load_checkpoint(self, filepath: str) -> None:
738
+ """
739
+ Load state from checkpoint file.
740
+
741
+ Parameters
742
+ ----------
743
+ filepath : str
744
+ Path to checkpoint file.
745
+ """
746
+ from .io_formats import PointCloudCheckpoint
747
+
748
+ checkpoint = PointCloudCheckpoint()
749
+ cloud, metadata = checkpoint.load(filepath)
750
+
751
+ geological_age = metadata.get('geological_age')
752
+ if geological_age is None:
753
+ raise ValueError("Checkpoint missing 'geological_age' in metadata")
754
+
755
+ self.initialize_from_cloud(cloud, geological_age)
756
+
757
+ logger.info(f"Loaded checkpoint from {filepath}")
758
+ logger.info(f" Geological age: {self._current_age} Ma")
759
+ logger.info(f" Tracers: {len(self._lats)}")
760
+
761
+ def get_statistics(self) -> Dict:
762
+ """
763
+ Get statistics about current tracer state.
764
+
765
+ Returns
766
+ -------
767
+ dict
768
+ Statistics including mean age, max age, coverage, etc.
769
+ """
770
+ if not self._initialized or len(self._ages) == 0:
771
+ return {
772
+ 'count': 0,
773
+ 'mean_age': 0,
774
+ 'max_age': 0,
775
+ 'min_age': 0,
776
+ 'geological_age': self._current_age
777
+ }
778
+
779
+ return {
780
+ 'count': len(self._ages),
781
+ 'mean_age': float(np.mean(self._ages)),
782
+ 'max_age': float(np.max(self._ages)),
783
+ 'min_age': float(np.min(self._ages)),
784
+ 'std_age': float(np.std(self._ages)),
785
+ 'geological_age': self._current_age
786
+ }
787
+
788
+ @classmethod
789
+ def compute_ages(
790
+ cls,
791
+ target_age: float,
792
+ starting_age: float,
793
+ rotation_files: Union[str, List[str]],
794
+ topology_files: Union[str, List[str]],
795
+ continental_polygons: Optional[str] = None,
796
+ config: Optional[TracerConfig] = None,
797
+ verbose: bool = True,
798
+ ) -> PointCloud:
799
+ """
800
+ One-shot computation of seafloor ages (functional interface).
801
+
802
+ Creates a tracker, initializes at starting_age, and evolves
803
+ to target_age in a single call.
804
+
805
+ Parameters
806
+ ----------
807
+ target_age : float
808
+ Target geological age (Ma).
809
+ starting_age : float
810
+ Starting geological age (Ma).
811
+ rotation_files : list of str
812
+ Paths to rotation model files (.rot).
813
+ topology_files : list of str
814
+ Paths to topology/plate boundary files (.gpml/.gpmlz).
815
+ continental_polygons : str, optional
816
+ Path to continental polygon file.
817
+ config : TracerConfig, optional
818
+ Configuration parameters.
819
+ verbose : bool, default=True
820
+ Print progress information.
821
+
822
+ Returns
823
+ -------
824
+ PointCloud
825
+ Point cloud with 'age' property.
826
+
827
+ Examples
828
+ --------
829
+ >>> cloud = SeafloorAgeTracker.compute_ages(
830
+ ... target_age=100,
831
+ ... starting_age=200,
832
+ ... rotation_files=['rotations.rot'],
833
+ ... topology_files=['topologies.gpmlz']
834
+ ... )
835
+ >>> ages = cloud.get_property('age')
836
+ """
837
+ tracker = cls(
838
+ rotation_files=rotation_files,
839
+ topology_files=topology_files,
840
+ continental_polygons=continental_polygons,
841
+ config=config,
842
+ verbose=verbose,
843
+ )
844
+
845
+ tracker.initialize(starting_age)
846
+ return tracker.step_to(target_age)
847
+
848
+
849
+ # Backwards compatibility aliases
850
+ HPCSeafloorAgeTracker = SeafloorAgeTracker
851
+ MemoryEfficientSeafloorAgeTracker = SeafloorAgeTracker