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