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/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """Configuration classes for gtrack package."""
2
+
3
+ from dataclasses import dataclass
4
+ import numpy as np
5
+
6
+
7
+ @dataclass
8
+ class TracerConfig:
9
+ """
10
+ Configuration parameters for seafloor age tracking.
11
+
12
+ Parameters match GPlately's SeafloorGrid for compatibility.
13
+
14
+ Attributes
15
+ ----------
16
+ time_step : float
17
+ Time step size in Myr (default: 1.0)
18
+ earth_radius : float
19
+ Earth's radius in meters (default: 6.3781e6)
20
+
21
+ Collision Detection (C++ backend)
22
+ ----------------------------------
23
+ velocity_delta_threshold : float
24
+ Minimum velocity difference to trigger collision check, in km/Myr.
25
+ Converted to cm/yr (divide by 10) for pygplates C++ API.
26
+ Default: 7.0 km/Myr (= 0.7 cm/yr)
27
+ distance_threshold_per_myr : float
28
+ Base distance threshold for collision detection, in km/Myr.
29
+ Default: 10.0 km/Myr
30
+
31
+ Initialization
32
+ --------------
33
+ default_mesh_points : int
34
+ Number of points for the initial sphere mesh.
35
+ Default: 10000
36
+ initial_ocean_mean_spreading_rate : float
37
+ Mean spreading rate in mm/yr for initial age calculation.
38
+ Used to compute age = distance / (rate / 2).
39
+ Default: 75.0 mm/yr (GPlately default)
40
+
41
+ MOR Seed Generation
42
+ -------------------
43
+ ridge_sampling_degrees : float
44
+ Ridge tessellation resolution in degrees (~50 km at equator).
45
+ Default: 0.5 degrees
46
+ spreading_offset_degrees : float
47
+ Angle to rotate new points off ridge, in degrees.
48
+ Default: 0.01 degrees (~1 km)
49
+
50
+ Continental Handling
51
+ --------------------
52
+ continental_cache_size : int
53
+ Number of timesteps to cache for continental polygon queries.
54
+ Default: 10
55
+
56
+ Reinitialization
57
+ ----------------
58
+ reinit_k_neighbors : int
59
+ Number of nearest neighbors for interpolation during reinitialization.
60
+ Default: 5
61
+ reinit_max_distance : float
62
+ Maximum distance (meters) for valid interpolation neighbors.
63
+ Default: 500e3 (500 km)
64
+
65
+ Examples
66
+ --------
67
+ >>> # Use default configuration (GPlately-compatible)
68
+ >>> config = TracerConfig()
69
+ >>>
70
+ >>> # Custom configuration with higher resolution
71
+ >>> config = TracerConfig(
72
+ ... default_mesh_points=40000, # Higher resolution mesh
73
+ ... ridge_sampling_degrees=0.25, # ~25 km resolution
74
+ ... time_step=0.5 # 0.5 Myr timesteps
75
+ ... )
76
+ """
77
+
78
+ # Time stepping
79
+ time_step: float = 1.0 # Myr
80
+ earth_radius: float = 6.3781e6 # meters
81
+
82
+ # Collision detection (C++ backend - GPlately compatible)
83
+ # These are passed to pygplates.ReconstructedGeometryTimeSpan.DefaultDeactivatePoints
84
+ velocity_delta_threshold: float = 7.0 # km/Myr (converted to 0.7 cm/yr for API)
85
+ distance_threshold_per_myr: float = 10.0 # km/Myr
86
+
87
+ # Initialization - sphere mesh
88
+ default_mesh_points: int = 10000
89
+ initial_ocean_mean_spreading_rate: float = 75.0 # mm/yr (GPlately default)
90
+
91
+ # MOR seed generation
92
+ ridge_sampling_degrees: float = 0.5 # ~50 km at equator
93
+ spreading_offset_degrees: float = 0.01 # ~1 km offset from ridge
94
+
95
+ # Continental polygon caching
96
+ continental_cache_size: int = 10
97
+
98
+ # Reinitialization parameters
99
+ reinit_k_neighbors: int = 5
100
+ reinit_max_distance: float = 500e3 # meters
101
+
102
+ def __post_init__(self):
103
+ """Validate configuration parameters."""
104
+ if self.time_step <= 0:
105
+ raise ValueError(f"time_step must be positive, got {self.time_step}")
106
+ if self.earth_radius <= 0:
107
+ raise ValueError(f"earth_radius must be positive, got {self.earth_radius}")
108
+ if self.velocity_delta_threshold < 0:
109
+ raise ValueError(
110
+ f"velocity_delta_threshold must be non-negative, "
111
+ f"got {self.velocity_delta_threshold}"
112
+ )
113
+ if self.distance_threshold_per_myr < 0:
114
+ raise ValueError(
115
+ f"distance_threshold_per_myr must be non-negative, "
116
+ f"got {self.distance_threshold_per_myr}"
117
+ )
118
+ if self.default_mesh_points < 1:
119
+ raise ValueError(
120
+ f"default_mesh_points must be at least 1, "
121
+ f"got {self.default_mesh_points}"
122
+ )
123
+ if self.initial_ocean_mean_spreading_rate <= 0:
124
+ raise ValueError(
125
+ f"initial_ocean_mean_spreading_rate must be positive, "
126
+ f"got {self.initial_ocean_mean_spreading_rate}"
127
+ )
128
+ if self.ridge_sampling_degrees <= 0:
129
+ raise ValueError(
130
+ f"ridge_sampling_degrees must be positive, "
131
+ f"got {self.ridge_sampling_degrees}"
132
+ )
133
+ if self.spreading_offset_degrees <= 0:
134
+ raise ValueError(
135
+ f"spreading_offset_degrees must be positive, "
136
+ f"got {self.spreading_offset_degrees}"
137
+ )
138
+ if self.continental_cache_size < 0:
139
+ raise ValueError(
140
+ f"continental_cache_size must be non-negative, "
141
+ f"got {self.continental_cache_size}"
142
+ )
143
+ if self.reinit_k_neighbors < 1:
144
+ raise ValueError(
145
+ f"reinit_k_neighbors must be at least 1, "
146
+ f"got {self.reinit_k_neighbors}"
147
+ )
148
+ if self.reinit_max_distance <= 0:
149
+ raise ValueError(
150
+ f"reinit_max_distance must be positive, "
151
+ f"got {self.reinit_max_distance}"
152
+ )
153
+
154
+ @property
155
+ def velocity_delta_threshold_cm_yr(self) -> float:
156
+ """
157
+ Velocity threshold in cm/yr for pygplates C++ API.
158
+
159
+ The C++ API expects cm/yr, while we store km/Myr for readability.
160
+ Conversion: 1 km/Myr = 0.1 cm/yr
161
+ """
162
+ return self.velocity_delta_threshold / 10.0
163
+
164
+ def to_dict(self) -> dict:
165
+ """
166
+ Convert configuration to dictionary.
167
+
168
+ Returns
169
+ -------
170
+ dict
171
+ Configuration as dictionary
172
+ """
173
+ return {
174
+ 'time_step': self.time_step,
175
+ 'earth_radius': self.earth_radius,
176
+ 'velocity_delta_threshold': self.velocity_delta_threshold,
177
+ 'distance_threshold_per_myr': self.distance_threshold_per_myr,
178
+ 'default_mesh_points': self.default_mesh_points,
179
+ 'initial_ocean_mean_spreading_rate': self.initial_ocean_mean_spreading_rate,
180
+ 'ridge_sampling_degrees': self.ridge_sampling_degrees,
181
+ 'spreading_offset_degrees': self.spreading_offset_degrees,
182
+ 'continental_cache_size': self.continental_cache_size,
183
+ 'reinit_k_neighbors': self.reinit_k_neighbors,
184
+ 'reinit_max_distance': self.reinit_max_distance,
185
+ }
186
+
187
+ @classmethod
188
+ def from_dict(cls, config_dict: dict):
189
+ """
190
+ Create configuration from dictionary.
191
+
192
+ Parameters
193
+ ----------
194
+ config_dict : dict
195
+ Dictionary with configuration parameters
196
+
197
+ Returns
198
+ -------
199
+ TracerConfig
200
+ Configuration object
201
+ """
202
+ return cls(**config_dict)
gtrack/geometry.py ADDED
@@ -0,0 +1,348 @@
1
+ """
2
+ Geometric utility functions for coordinate transformations and operations on a sphere.
3
+
4
+ All functions are vectorized using numpy for performance.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+
12
+ # Earth's radius in meters
13
+ EARTH_RADIUS = 6.3781e6
14
+
15
+
16
+ def ensure_list(x):
17
+ """
18
+ Ensure x is a list. Wrap single items (str, Path) in a list.
19
+
20
+ This is useful for APIs that accept either a single file path or a list
21
+ of file paths, allowing users to write either:
22
+ - rotation_files="rotations.rot"
23
+ - rotation_files=["rot1.rot", "rot2.rot"]
24
+
25
+ Parameters
26
+ ----------
27
+ x : str, Path, list, or None
28
+ Input to convert to a list.
29
+
30
+ Returns
31
+ -------
32
+ list
33
+ The input as a list. Single str/Path items are wrapped in a list.
34
+ None returns an empty list.
35
+
36
+ Examples
37
+ --------
38
+ >>> ensure_list("file.rot")
39
+ ['file.rot']
40
+ >>> ensure_list(Path("file.rot"))
41
+ [PosixPath('file.rot')]
42
+ >>> ensure_list(["file1.rot", "file2.rot"])
43
+ ['file1.rot', 'file2.rot']
44
+ >>> ensure_list(None)
45
+ []
46
+ """
47
+ if x is None:
48
+ return []
49
+ if isinstance(x, (str, Path)):
50
+ return [x]
51
+ return list(x)
52
+
53
+
54
+ def LatLon2XYZ(latlon):
55
+ """
56
+ Convert geographic coordinates (lat, lon) to Cartesian coordinates (x, y, z).
57
+
58
+ Parameters
59
+ ----------
60
+ latlon : np.ndarray
61
+ Array of shape (N, 2) containing [latitude, longitude] pairs in degrees.
62
+
63
+ Returns
64
+ -------
65
+ xyz : np.ndarray
66
+ Array of shape (N, 3) containing [x, y, z] Cartesian coordinates in meters.
67
+
68
+ Examples
69
+ --------
70
+ >>> latlons = np.array([[0, 0], [90, 0], [-90, 0]])
71
+ >>> xyz = LatLon2XYZ(latlons)
72
+ >>> xyz.shape
73
+ (3, 3)
74
+ """
75
+ R = EARTH_RADIUS
76
+ lat = latlon[:, 0]
77
+ lon = latlon[:, 1]
78
+ lon = np.deg2rad(lon)
79
+ lat = np.deg2rad(lat)
80
+ x = np.zeros([len(lon), 3]) # initialize coordinate-array
81
+ x[:, 0] = R * np.cos(lat) * np.cos(lon) # load x-coordinates
82
+ x[:, 1] = R * np.cos(lat) * np.sin(lon) # load y-coordinates
83
+ x[:, 2] = R * np.sin(lat) # load z-coordinates
84
+ return x
85
+
86
+
87
+ def XYZ2LatLon(xyz):
88
+ """
89
+ Convert Cartesian coordinates (x, y, z) to geographic coordinates (lat, lon).
90
+
91
+ Parameters
92
+ ----------
93
+ xyz : np.ndarray
94
+ Array of shape (N, 3) containing [x, y, z] Cartesian coordinates in meters.
95
+
96
+ Returns
97
+ -------
98
+ lats : np.ndarray
99
+ Array of latitudes in degrees, shape (N,).
100
+ lons : np.ndarray
101
+ Array of longitudes in degrees, shape (N,).
102
+
103
+ Examples
104
+ --------
105
+ >>> xyz = np.array([[6.3781e6, 0, 0], [0, 0, 6.3781e6]])
106
+ >>> lats, lons = XYZ2LatLon(xyz)
107
+ >>> np.allclose(lats, [0, 90])
108
+ True
109
+ >>> np.allclose(lons, [0, 0])
110
+ True
111
+ """
112
+ R = EARTH_RADIUS
113
+ x = xyz[:, 0]
114
+ y = xyz[:, 1]
115
+ z = xyz[:, 2]
116
+ lats = np.arcsin(z / R) * 360 / (2 * np.pi)
117
+ lons = np.arctan2(y, x) * 360 / (2 * np.pi)
118
+ return lats, lons
119
+
120
+
121
+ def RefineGreatCircleArcSegment(p1, p2, N):
122
+ """
123
+ Refine a great circle arc segment defined by two points into N segments.
124
+
125
+ This function takes two points on a sphere and creates a refined path
126
+ between them by interpolating along the great circle arc.
127
+
128
+ Parameters
129
+ ----------
130
+ p1 : tuple or list
131
+ First point as (latitude, longitude) in degrees.
132
+ p2 : tuple or list
133
+ Second point as (latitude, longitude) in degrees.
134
+ N : int
135
+ Number of segments to create (N+1 points total).
136
+
137
+ Returns
138
+ -------
139
+ lats : np.ndarray
140
+ Array of latitudes along the refined segment, shape (N+1,).
141
+ lons : np.ndarray
142
+ Array of longitudes along the refined segment, shape (N+1,).
143
+
144
+ Notes
145
+ -----
146
+ Two points that define a line going through Earth's center will give
147
+ erroneous results.
148
+
149
+ Examples
150
+ --------
151
+ >>> p1 = (0, 0) # Equator at prime meridian
152
+ >>> p2 = (0, 90) # Equator at 90E
153
+ >>> lats, lons = RefineGreatCircleArcSegment(p1, p2, 2)
154
+ >>> len(lats)
155
+ 3
156
+ """
157
+ R = EARTH_RADIUS
158
+
159
+ LatLon = np.array([p1, p2])
160
+ XYZ = LatLon2XYZ(LatLon)
161
+ p1 = XYZ[0, :]
162
+ p2 = XYZ[1, :]
163
+
164
+ n = np.arange(N + 1) # weights
165
+ p = np.zeros([N + 1, 3]) #
166
+ for i in range(0, N + 1):
167
+ p[i, :] = (float(N) - n[i]) / N * p1 + (n[i] / float(N)) * p2
168
+ x_mag = np.sqrt(p[:, 0] ** 2 + p[:, 1] ** 2 + p[:, 2] ** 2) # compute vector magnitude
169
+ x_mag = np.transpose(np.array([x_mag, x_mag, x_mag]))
170
+ p = (p / x_mag) * R # project new position back to Earth surface
171
+ lats, lons = XYZ2LatLon(p)
172
+ return lats, lons
173
+
174
+
175
+ def Segments2Points(segments, res):
176
+ """
177
+ Convert great circle arc segments to a set of points with specified resolution.
178
+
179
+ Takes line segments defined by endpoint pairs and refines them into a set of
180
+ points spaced at approximately the specified resolution.
181
+
182
+ Parameters
183
+ ----------
184
+ segments : np.ndarray
185
+ Array of shape (N, 4) where each row is [lat1, lon1, lat2, lon2] defining
186
+ a segment's endpoints in degrees.
187
+ res : float
188
+ Target resolution for point spacing in meters.
189
+
190
+ Returns
191
+ -------
192
+ lats : np.ndarray
193
+ Array of latitudes for all refined points in degrees.
194
+ lons : np.ndarray
195
+ Array of longitudes for all refined points in degrees.
196
+
197
+ Examples
198
+ --------
199
+ >>> segments = np.array([[0, 0, 0, 10], [0, 10, 0, 20]])
200
+ >>> lats, lons = Segments2Points(segments, 100e3) # 100 km resolution
201
+ >>> len(lats) > 2 # Should have more points than segments
202
+ True
203
+ """
204
+ num_segments = len(segments[:, 0])
205
+ lats, lons = np.array([]), np.array([])
206
+
207
+ R = EARTH_RADIUS
208
+ # Fixed: Process all segments starting from index 0 (was bug in original starting at 1)
209
+ for i in range(0, num_segments):
210
+ p1, p2 = segments[i, :2], segments[i, 2:]
211
+ lat1, lon1, lat2, lon2 = np.deg2rad(p1[0]), np.deg2rad(p1[1]), np.deg2rad(p2[0]), np.deg2rad(p2[1])
212
+ dlon = abs(lon2 - lon1)
213
+ dist = R * np.arccos(np.sin(lat1) * np.sin(lat2) + np.cos(lat1) * np.cos(lat2) * np.cos(dlon))
214
+
215
+ if dist > 0:
216
+ N = int(round(dist / res))
217
+ else:
218
+ N = 0
219
+
220
+ if N > 0:
221
+ lats_, lons_ = RefineGreatCircleArcSegment(p1, p2, N)
222
+ lats = np.append(lats, lats_)
223
+ lons = np.append(lons, lons_)
224
+
225
+ return lats, lons
226
+
227
+
228
+ def normalize_to_sphere(xyz, radius=None):
229
+ """
230
+ Normalize XYZ coordinates to lie on a sphere of given radius.
231
+
232
+ Parameters
233
+ ----------
234
+ xyz : np.ndarray
235
+ Array of shape (N, 3) containing Cartesian coordinates.
236
+ radius : float, optional
237
+ Radius of the sphere in meters. If None, uses Earth's radius.
238
+
239
+ Returns
240
+ -------
241
+ xyz_normalized : np.ndarray
242
+ Array of shape (N, 3) with points projected onto the sphere.
243
+
244
+ Examples
245
+ --------
246
+ >>> xyz = np.array([[1, 0, 0], [0, 1, 0]])
247
+ >>> normalized = normalize_to_sphere(xyz, radius=1.0)
248
+ >>> np.allclose(np.linalg.norm(normalized, axis=1), 1.0)
249
+ True
250
+ """
251
+ if radius is None:
252
+ radius = EARTH_RADIUS
253
+
254
+ magnitudes = np.linalg.norm(xyz, axis=1, keepdims=True)
255
+ return (xyz / magnitudes) * radius
256
+
257
+
258
+ def inverse_distance_weighted_interpolation(values, distances, epsilon=1e-10):
259
+ """
260
+ Compute inverse distance weighted (IDW) interpolation.
261
+
262
+ For each query point, computes a weighted average of neighbor values
263
+ where weights are inversely proportional to distance.
264
+
265
+ Parameters
266
+ ----------
267
+ values : np.ndarray
268
+ Array of shape (N, K) containing values from K neighbors for N points.
269
+ distances : np.ndarray
270
+ Array of shape (N, K) containing distances to K neighbors for N points.
271
+ epsilon : float, default=1e-10
272
+ Small value to detect zero distances. Points with distance < epsilon
273
+ are treated as exact matches and their value is used directly.
274
+
275
+ Returns
276
+ -------
277
+ result : np.ndarray
278
+ Array of shape (N,) containing interpolated values.
279
+
280
+ Notes
281
+ -----
282
+ For points where any neighbor has distance < epsilon (essentially zero),
283
+ the value from that neighbor is used directly to avoid division by zero.
284
+
285
+ The IDW formula is: result = sum(v_i / d_i) / sum(1 / d_i)
286
+
287
+ Examples
288
+ --------
289
+ >>> values = np.array([[10, 20], [30, 40]])
290
+ >>> distances = np.array([[1.0, 2.0], [1.0, 1.0]])
291
+ >>> result = inverse_distance_weighted_interpolation(values, distances)
292
+ >>> # First point: (10/1 + 20/2) / (1/1 + 1/2) = 20/1.5 = 13.33
293
+ >>> # Second point: (30/1 + 40/1) / (1/1 + 1/1) = 70/2 = 35
294
+ """
295
+ values = np.atleast_2d(values)
296
+ distances = np.atleast_2d(distances)
297
+
298
+ n_points = values.shape[0]
299
+ result = np.zeros(n_points)
300
+
301
+ # Handle each point
302
+ for i in range(n_points):
303
+ dists = distances[i]
304
+ vals = values[i]
305
+
306
+ # Check for zero distance (exact match)
307
+ zero_mask = dists < epsilon
308
+ if np.any(zero_mask):
309
+ # Use the value from the first zero-distance neighbor
310
+ zero_idx = np.argmax(zero_mask)
311
+ result[i] = vals[zero_idx]
312
+ else:
313
+ # Standard IDW: w_i = 1/d_i
314
+ weights = 1.0 / dists
315
+ result[i] = np.sum(vals * weights) / np.sum(weights)
316
+
317
+ return result
318
+
319
+
320
+ def compute_mesh_spacing_km(n_points, earth_radius_km=6378.1):
321
+ """
322
+ Compute approximate mesh spacing for a given number of points on a sphere.
323
+
324
+ Parameters
325
+ ----------
326
+ n_points : int
327
+ Number of points on the sphere.
328
+ earth_radius_km : float, default=6378.1
329
+ Earth's radius in kilometers.
330
+
331
+ Returns
332
+ -------
333
+ spacing_km : float
334
+ Approximate average spacing between mesh points in kilometers.
335
+
336
+ Notes
337
+ -----
338
+ Average area per point = 4 * pi * R^2 / N
339
+ Approximate spacing = sqrt(area per point)
340
+
341
+ Examples
342
+ --------
343
+ >>> spacing = compute_mesh_spacing_km(10000)
344
+ >>> 200 < spacing < 300 # ~10k points gives ~220 km spacing
345
+ True
346
+ """
347
+ avg_area = 4 * np.pi * earth_radius_km**2 / n_points
348
+ return np.sqrt(avg_area)